diff --git a/rust/agama-lib/share/examples/storage/md_raids.json b/rust/agama-lib/share/examples/storage/md_raids.json new file mode 100644 index 0000000000..4ad83e8255 --- /dev/null +++ b/rust/agama-lib/share/examples/storage/md_raids.json @@ -0,0 +1,89 @@ +{ + "storage": { + "drives": [ + { + "search": "/dev/vda", + "partitions": [ + { + "search": "*", + "delete": true + }, + { + "alias": "system-device1", + "size": "10 GiB" + }, + { + "alias": "home-device1", + "size": "10 GiB" + } + ] + }, + { + "search": "/dev/vdb", + "partitions": [ + { + "search": "*", + "delete": true + }, + { + "alias": "system-device2", + "size": "10 GiB" + }, + { + "alias": "home-device2", + "size": "10 GiB" + } + ] + } + ], + "mdRaids": [ + { + "alias": "system", + "name": "system", + "level": "raid1", + "parity": "left_symmetric", + "chunkSize": "4 KiB", + "devices": ["system-device1", "system-device2"], + "ptableType": "gpt", + "partitions": [ + { + "encryption": { + "luks1": { + "password": "notsecret" + } + }, + "filesystem": { + "type": { + "btrfs": { + "snapshots": true + } + }, + "path": "/" + } + } + ] + }, + { + "alias": "home", + "name": "home", + "level": "raid0", + "devices": ["home-device1", "home-device2"], + "encryption": { + "luks1": { + "password": "notsecret" + } + }, + "filesystem": { + "type": "xfs", + "path": "/home" + } + }, + { + "search": "/dev/md1", + "name": "data", + "level": "raid1", + "filesystem": {"path": "/data" } + } + ] + } +} diff --git a/rust/agama-lib/share/storage.schema.json b/rust/agama-lib/share/storage.schema.json index 808433fd67..9e454da6c1 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -16,6 +16,11 @@ "description": "LVM volume groups.", "type": "array", "items": { "$ref": "#/$defs/volumeGroup" } + }, + "mdRaids": { + "description": "MD RAIDs.", + "type": "array", + "items": { "$ref": "#/$defs/mdRaidElement" } } }, "$defs": { @@ -34,15 +39,14 @@ }, "driveElement": { "anyOf": [ - { "$ref": "#/$defs/formattedDrive" }, + { "$ref": "#/$defs/nonPartitionedDrive" }, { "$ref": "#/$defs/partitionedDrive" } ] }, - "formattedDrive": { + "nonPartitionedDrive": { "description": "Drive without a partition table (e.g., directly formatted).", "type": "object", "additionalProperties": false, - "required": ["filesystem"], "properties": { "search": { "$ref": "#/$defs/searchElement" }, "alias": { "$ref": "#/$defs/alias" }, @@ -53,9 +57,51 @@ "partitionedDrive": { "type": "object", "additionalProperties": false, + "required": ["partitions"], + "properties": { + "search": { "$ref": "#/$defs/searchElement" }, + "alias": { "$ref": "#/$defs/alias" }, + "ptableType": { "$ref": "#/$defs/ptableType" }, + "partitions": { + "type": "array", + "items": { "$ref": "#/$defs/partitionElement" } + } + } + }, + "mdRaidElement": { + "anyOf": [ + { "$ref": "#/$defs/nonPartitionedMdRaid" }, + { "$ref": "#/$defs/partitionedMdRaid" } + ] + }, + "nonPartitionedMdRaid": { + "description": "MD RAID without a partition table (e.g., directly formatted).", + "type": "object", + "additionalProperties": false, + "properties": { + "search": { "$ref": "#/$defs/searchElement" }, + "alias": { "$ref": "#/$defs/alias" }, + "name": { "$ref": "#/$defs/mdRaidName" }, + "level": { "$ref": "#/$defs/mdRaidLevel" }, + "parity": { "$ref": "#/$defs/mdRaidParity" }, + "chunkSize": { "$ref": "#/$defs/sizeValue" }, + "devices": { "$ref": "#/$defs/mdRaidDevices" }, + "encryption": { "$ref": "#/$defs/encryption" }, + "filesystem": { "$ref": "#/$defs/filesystem" } + } + }, + "partitionedMdRaid": { + "type": "object", + "additionalProperties": false, + "required": ["partitions"], "properties": { "search": { "$ref": "#/$defs/searchElement" }, "alias": { "$ref": "#/$defs/alias" }, + "name": { "$ref": "#/$defs/mdRaidName" }, + "level": { "$ref": "#/$defs/mdRaidLevel" }, + "parity": { "$ref": "#/$defs/mdRaidParity" }, + "chunkSize": { "$ref": "#/$defs/sizeValue" }, + "devices": { "$ref": "#/$defs/mdRaidDevices" }, "ptableType": { "$ref": "#/$defs/ptableType" }, "partitions": { "type": "array", @@ -63,6 +109,44 @@ } } }, + "mdRaidName": { + "description": "MD base name.", + "type": "string", + "examples": ["system"] + }, + "mdRaidLevel": { + "title": "MD level", + "enum": [ + "raid0", + "raid1", + "raid5", + "raid6", + "raid10" + ] + }, + "mdRaidParity": { + "title": "MD parity", + "description": "Only applies to raid5, raid6 and raid10", + "enum": [ + "left_asymmetric", + "left_symmetric", + "right_asymmetric", + "right_symmetric", + "first", + "last", + "near_2", + "offset_2", + "far_2", + "near_3", + "offset_3", + "far_3" + ] + }, + "mdRaidDevices": { + "description": "Devices used by the MD RAID.", + "type": "array", + "items": { "$ref": "#/$defs/alias" } + }, "ptableType": { "description": "Partition table type.", "$comment": "The partition table is created only if all the current partitions are deleted.", diff --git a/rust/package/agama.changes b/rust/package/agama.changes index e18fdeae46..378fc28e75 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed May 7 06:35:19 UTC 2025 - José Iván López González + +- Add MD RAIDs to the storage schema (gh#agama-project/agama#2286). + ------------------------------------------------------------------- Mon May 5 06:38:07 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index d38443ebd7..c0232996b8 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -42,7 +42,7 @@ class Config # @return [Array] attr_accessor :volume_groups - # @return [Array] + # @return [Array] attr_accessor :md_raids # @return [Array] @@ -67,37 +67,46 @@ def boot_device drives.find { |d| d.alias?(boot.device.device_alias) } end - # Device config containing root. - # - # @return [Configs::Drive, Configs::VolumeGroup, nil] - def root_device - root_drive || root_volume_group - end - # Drive config containing root. # # @return [Configs::Drive, nil] def root_drive - drives.find { |d| d.root? || d.partitions.any?(&:root?) } + drives.find { |d| root_device?(d) } + end + + # MD RAID config containing root. + # + # @return [Configs::MdRaid, nil] + def root_md_raid + md_raids.find { |m| root_device?(m) } end # Volume group config containing a logical volume for root. # # @return [Configs::LogicalVolume, nil] def root_volume_group - volume_groups.find { |v| v.logical_volumes.any?(&:root?) } + volume_groups.find { |v| root_device?(v) } end # Drive with the given alias. # + # @param device_alias [String] # @return [Configs::Drive, nil] def drive(device_alias) drives.find { |d| d.alias?(device_alias) } end + # MD RAID with the given alias. + # + # @param device_alias [String] + # @return [Configs::MdRaid, nil] + def md_raid(device_alias) + md_raids.find { |d| d.alias?(device_alias) } + end + # @return [Array] def partitions - drives.flat_map(&:partitions) + supporting_partitions.flat_map(&:partitions) end # @return [Array] @@ -107,11 +116,125 @@ def logical_volumes # @return [Array] def filesystems - ( - drives.map(&:filesystem) + - partitions.map(&:filesystem) + - logical_volumes.map(&:filesystem) - ).compact + supporting_filesystem.map(&:filesystem).compact + end + + # Configs with configurable encryption. + # + # @return [Array<#encryption>] + def supporting_encryption + drives + md_raids + partitions + logical_volumes + end + + # Configs with configurable filesystem. + # + # @return [Array<#filesystem>] + def supporting_filesystem + drives + md_raids + partitions + logical_volumes + end + + # Configs with configurable size. + # + # @return [Array<#size>] + def supporting_size + partitions + logical_volumes + end + + # Configs with configurable partitions. + # + # @return [#partitions] + def supporting_partitions + drives + md_raids + end + + # Config objects that could act as physical volume + # + # @return [Array] + def potential_for_pv + drives + md_raids + partitions + end + + # Config objects that could be used to create automatic physical volume + # + # @return [Array] + def potential_for_pv_device + drives + md_raids + end + + # Config objects that could act as MD RAID member devices. + # + # @return [Array] + def potential_for_md_device + drives + drives.flat_map(&:partitions) + end + + # Configs directly using a device with the given alias. + # + # @note Devices using the given alias as a target device (e.g., for creating physical volumes) + # are not considered as users because the device is not directly used. + # + # @param device_alias [String] + # @return [Array] + def users(device_alias) + md_users(device_alias) + vg_users(device_alias) + end + + # Configs directly using the given alias as target device. + # + # @param device_alias [String] + # @return [Array] + def target_users(device_alias) + vg_target_users(device_alias) + end + + private + + # MD RAIDs using the given alias as member device. + # + # @param device_alias [String] + # @return [Array] + def md_users(device_alias) + device = potential_for_md_device.find { |d| d.alias?(device_alias) } + return [] unless device + + md_raids.select { |m| m.devices.include?(device_alias) } + end + + # Volume groups using the given alias as physical volume. + # + # @param device_alias [String] + # @return [Array] + def vg_users(device_alias) + device = potential_for_pv.find { |d| d.alias?(device_alias) } + return [] unless device + + volume_groups.select { |v| v.physical_volumes.include?(device_alias) } + end + + # Volume groups using the given alias as target for physical volumes. + # + # @param device_alias [String] + # @return [Array] + def vg_target_users(device_alias) + device = potential_for_pv_device.find { |d| d.alias?(device_alias) } + return [] unless device + + volume_groups.select { |v| v.physical_volumes_devices.include?(device_alias) } + end + + # Whether the device config contains root. + # + # @param device [Configs::Drive, Configs::MdRaid, Configs::VolumeGroup] + # @return [Boolean] + def root_device?(device) + case device + when Configs::Drive, Configs::MdRaid + device.root? || device.partitions.any?(&:root?) + when Configs::VolumeGroup + device.logical_volumes.any?(&:root?) + else + false + end end end end diff --git a/service/lib/agama/storage/config_checker.rb b/service/lib/agama/storage/config_checker.rb index b6f04d3967..62828e41ed 100644 --- a/service/lib/agama/storage/config_checker.rb +++ b/service/lib/agama/storage/config_checker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -23,6 +23,7 @@ require "agama/storage/config_checkers/boot" require "agama/storage/config_checkers/filesystems" require "agama/storage/config_checkers/drive" +require "agama/storage/config_checkers/md_raid" require "agama/storage/config_checkers/volume_group" require "agama/storage/config_checkers/volume_groups" @@ -45,6 +46,7 @@ def issues filesystems_issues, boot_issues, drives_issues, + md_raids_issues, volume_groups_issues ].flatten end @@ -61,7 +63,7 @@ def issues # # @return [Array] def boot_issues - ConfigCheckers::Boot.new(storage_config, product_config).issues + ConfigCheckers::Boot.new(storage_config).issues end # Issues related to the list of filesystems (mount paths) @@ -84,9 +86,22 @@ def drive_issues(config) ConfigCheckers::Drive.new(config, storage_config, product_config).issues end + # Issues from MD RAIDs. + # + # @return [Array] + def md_raids_issues + storage_config.md_raids.flat_map { |m| md_raid_issues(m) } + end + + # @param config [Configs::MdRaid] + # @return [Array] + def md_raid_issues(config) + ConfigCheckers::MdRaid.new(config, storage_config, product_config).issues + end + # @return [Array] def volume_groups_issues - section_issues = ConfigCheckers::VolumeGroups.new(storage_config, product_config).issues + section_issues = ConfigCheckers::VolumeGroups.new(storage_config).issues issues = storage_config.volume_groups.flat_map { |v| volume_group_issues(v) } [ diff --git a/service/lib/agama/storage/config_checkers.rb b/service/lib/agama/storage/config_checkers.rb index 1903c22f07..831c9df1d7 100644 --- a/service/lib/agama/storage/config_checkers.rb +++ b/service/lib/agama/storage/config_checkers.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -24,6 +24,7 @@ require "agama/storage/config_checkers/encryption" require "agama/storage/config_checkers/filesystem" require "agama/storage/config_checkers/logical_volume" +require "agama/storage/config_checkers/md_raid" require "agama/storage/config_checkers/partition" require "agama/storage/config_checkers/physical_volumes_encryption" require "agama/storage/config_checkers/search" diff --git a/service/lib/agama/storage/config_checkers/alias.rb b/service/lib/agama/storage/config_checkers/alias.rb new file mode 100644 index 0000000000..7ce591e284 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/alias.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/base" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking a config with alias. + class Alias < Base + include Yast::I18n + + # @param config [#alias] + # @param storage_config [Storage::Config] + def initialize(config, storage_config) + super() + + textdomain "agama" + @config = config + @storage_config = storage_config + end + + # Alias related issues. + # + # @return [Array] + def issues + [ + overused_alias_issue, + formatted_issue, + partitioned_issue + ].compact + end + + private + + # @return [#filesystem] + attr_reader :config + + # @return [Storage::Config] + attr_reader :storage_config + + # Issue when the device has several users. + # + # @return [Issue, nil] + def overused_alias_issue + return unless config.alias + return unless storage_config.users(config.alias).size > 1 + + error( + format(_("The device with alias '%s' is used by more than one device"), config.alias), + kind: :overused_alias + ) + end + + # Issue when the device has both filesystem and a user. + # + # @return [Issue, nil] + def formatted_issue + return unless config.alias && storage_config.supporting_filesystem.include?(config) + + return unless config.filesystem + + users = storage_config.users(config.alias) + target_users = storage_config.target_users(config.alias) + any_user = (users + target_users).any? + + return unless users.any? || target_users.any? + + error( + format( + _( + # TRANSLATORS: %s is replaced by a device alias (e.g., "disk1"). + "The device with alias '%s' cannot be formatted because it is used by other device" + ), + config.alias + ), + kind: :formatted_with_user + ) + end + + # Issue when the device has both partitions and a user. + # + # @return [Issue, nil] + def partitioned_issue + return unless config.alias && storage_config.supporting_partitions.include?(config) + + return unless config.partitions.any? + + users = storage_config.users(config.alias) + return unless users.any? + + error( + format( + _( + # TRANSLATORS: %s is replaced by a device alias (e.g., "disk1"). + "The device with alias '%s' cannot be partitioned because it is used by other " \ + "device" + ), + config.alias + ), + kind: :partitioned_with_user + ) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/base.rb b/service/lib/agama/storage/config_checkers/base.rb index 6513253399..66672c3957 100644 --- a/service/lib/agama/storage/config_checkers/base.rb +++ b/service/lib/agama/storage/config_checkers/base.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -26,13 +26,6 @@ module Storage module ConfigCheckers # Base class for checking a config. class Base - # @param storage_config [Storage::Config] - # @param product_config [Agama::Config] - def initialize(storage_config, product_config) - @storage_config = storage_config - @product_config = product_config - end - # List of issues (implemented by derived classes). # # @return [Array] @@ -42,12 +35,6 @@ def issues private - # @return [Storage::Config] - attr_reader :storage_config - - # @return [Agama::Config] - attr_reader :product_config - # Creates an error issue. # # @param message [String] diff --git a/service/lib/agama/storage/config_checkers/boot.rb b/service/lib/agama/storage/config_checkers/boot.rb index 72b629211c..383ea756d2 100644 --- a/service/lib/agama/storage/config_checkers/boot.rb +++ b/service/lib/agama/storage/config_checkers/boot.rb @@ -29,6 +29,14 @@ module ConfigCheckers class Boot < Base include Yast::I18n + # @param storage_config [Storage::Config] + def initialize(storage_config) + super() + + textdomain "agama" + @storage_config = storage_config + end + # Boot config issues. # # @return [Array] @@ -41,6 +49,9 @@ def issues private + # @return [Storage::Config] + attr_reader :storage_config + # @return [Boolean] def configure? storage_config.boot.configure? diff --git a/service/lib/agama/storage/config_checkers/drive.rb b/service/lib/agama/storage/config_checkers/drive.rb index 74ff8b528b..43d9a9c9dd 100644 --- a/service/lib/agama/storage/config_checkers/drive.rb +++ b/service/lib/agama/storage/config_checkers/drive.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_checkers/base" +require "agama/storage/config_checkers/with_alias" require "agama/storage/config_checkers/with_encryption" require "agama/storage/config_checkers/with_filesystem" require "agama/storage/config_checkers/with_partitions" @@ -30,6 +31,7 @@ module Storage module ConfigCheckers # Class for checking a drive config. class Drive < Base + include WithAlias include WithEncryption include WithFilesystem include WithPartitions @@ -39,9 +41,11 @@ class Drive < Base # @param storage_config [Storage::Config] # @param product_config [Agama::Config] def initialize(config, storage_config, product_config) - super(storage_config, product_config) + super() @config = config + @storage_config = storage_config + @product_config = product_config end # Drive config issues. @@ -49,6 +53,7 @@ def initialize(config, storage_config, product_config) # @return [Array] def issues [ + alias_issues, search_issues, filesystem_issues, encryption_issues, @@ -60,6 +65,12 @@ def issues # @return [Configs::Drive] attr_reader :config + + # @return [Storage::Config] + attr_reader :storage_config + + # @return [Agama::Config] + attr_reader :product_config end end end diff --git a/service/lib/agama/storage/config_checkers/encryption.rb b/service/lib/agama/storage/config_checkers/encryption.rb index 55da2d0cc5..d08e93e733 100644 --- a/service/lib/agama/storage/config_checkers/encryption.rb +++ b/service/lib/agama/storage/config_checkers/encryption.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -32,10 +32,8 @@ class Encryption < Base include Yast::I18n # @param config [#encryption] - # @param storage_config [Storage::Config] - # @param product_config [Agama::Config] - def initialize(config, storage_config, product_config) - super(storage_config, product_config) + def initialize(config) + super() textdomain "agama" @config = config diff --git a/service/lib/agama/storage/config_checkers/filesystem.rb b/service/lib/agama/storage/config_checkers/filesystem.rb index 6501168473..c6a94dab0a 100644 --- a/service/lib/agama/storage/config_checkers/filesystem.rb +++ b/service/lib/agama/storage/config_checkers/filesystem.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -31,13 +31,13 @@ class Filesystem < Base include Yast::I18n # @param config [#filesystem] - # @param storage_config [Storage::Config] # @param product_config [Agama::Config] - def initialize(config, storage_config, product_config) - super(storage_config, product_config) + def initialize(config, product_config) + super() textdomain "agama" @config = config + @product_config = product_config end # Filesystem config issues. @@ -57,6 +57,9 @@ def issues # @return [#filesystem] attr_reader :config + # @return [Agama::Config] + attr_reader :product_config + # @return [Configs::Filesystem, nil] def filesystem config.filesystem diff --git a/service/lib/agama/storage/config_checkers/filesystems.rb b/service/lib/agama/storage/config_checkers/filesystems.rb index 2e920100e1..d2d83ac6fb 100644 --- a/service/lib/agama/storage/config_checkers/filesystems.rb +++ b/service/lib/agama/storage/config_checkers/filesystems.rb @@ -30,6 +30,16 @@ module ConfigCheckers class Filesystems < Base include Yast::I18n + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(storage_config, product_config) + super() + + textdomain "agama" + @storage_config = storage_config + @product_config = product_config + end + # Issues related to the configured set of filesystems. # # @return [Array] @@ -39,6 +49,12 @@ def issues private + # @return [Storage::Config] + attr_reader :storage_config + + # @return [Agama::Config] + attr_reader :product_config + # @return [Issue, nil] def missing_paths_issue missing_paths = mandatory_paths.reject { |p| configured_path?(p) } diff --git a/service/lib/agama/storage/config_checkers/logical_volume.rb b/service/lib/agama/storage/config_checkers/logical_volume.rb index 2d66ec0e21..85c3113b16 100644 --- a/service/lib/agama/storage/config_checkers/logical_volume.rb +++ b/service/lib/agama/storage/config_checkers/logical_volume.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -35,14 +35,14 @@ class LogicalVolume < Base # @param config [Configs::LogicalVolume] # @param volume_group_config [Configs::VolumeGroup] - # @param storage_config [Storage::Config] # @param product_config [Agama::Config] - def initialize(config, volume_group_config, storage_config, product_config) - super(storage_config, product_config) + def initialize(config, volume_group_config, product_config) + super() textdomain "agama" @config = config @volume_group_config = volume_group_config + @product_config = product_config end # Logical volume config issues. @@ -64,6 +64,9 @@ def issues # @return [Configs::VolumeGroup] attr_reader :volume_group_config + # @return [Agama::Config] + attr_reader :product_config + # @return [Issue, nil] def missing_thin_pool_issue return unless config.thin_volume? diff --git a/service/lib/agama/storage/config_checkers/md_raid.rb b/service/lib/agama/storage/config_checkers/md_raid.rb new file mode 100644 index 0000000000..220321c39b --- /dev/null +++ b/service/lib/agama/storage/config_checkers/md_raid.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/base" +require "agama/storage/config_checkers/with_alias" +require "agama/storage/config_checkers/with_encryption" +require "agama/storage/config_checkers/with_filesystem" +require "agama/storage/config_checkers/with_partitions" +require "agama/storage/config_checkers/with_search" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking a MD RAID config. + class MdRaid < Base + include Yast::I18n + include WithAlias + include WithEncryption + include WithFilesystem + include WithPartitions + include WithSearch + + # @param config [Configs::MdRaid] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super() + + textdomain "agama" + @config = config + @storage_config = storage_config + @product_config = product_config + end + + # MD RAID config issues. + # + # @return [Array] + def issues + [ + alias_issues, + search_issues, + filesystem_issues, + encryption_issues, + partitions_issues, + devices_issues, + level_issue, + devices_size_issue + ].flatten.compact + end + + private + + # @return [Configs::MdRaid] + attr_reader :config + + # @return [Storage::Config] + attr_reader :storage_config + + # @return [Agama::Config] + attr_reader :product_config + + # Issues from MD RAID member devices. + # + # @return [Array] + def devices_issues + config.devices.map { |d| missing_device_issue(d) }.compact + end + + # @see #devices_issues + # + # @param device_alias [String] + # @return [Issue, nil] + def missing_device_issue(device_alias) + return if storage_config.potential_for_md_device.any? { |d| d.alias?(device_alias) } + + error( + # TRANSLATORS: %s is the replaced by a device alias (e.g., "md1"). + format(_("There is no MD RAID member device with alias '%s'"), device_alias), + kind: :no_such_alias + ) + end + + # Issue if the MD RAID level is missing and the device is not reused. + # + # @return [Issue, nil] + def level_issue + return if config.level + return unless config.create? + + error(format(_("There is a MD RAID without level")), kind: :md_raid) + end + + # Issue if the MD RAID does not contain enough member devices. + # + # @return [Issue, nil] + def devices_size_issue + return unless config.level + return if used_devices.size >= config.min_devices + + error( + format(_("At least %s devices are required for %s"), config.min_devices, config.level), + kind: :md_raid + ) + end + + # Devices used as MD RAID member devices. + # + # @return [Array] + def used_devices + storage_config.potential_for_md_device + .select { |d| config.devices.include?(d.alias) } + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/partition.rb b/service/lib/agama/storage/config_checkers/partition.rb index d05f0db563..d6f6036869 100644 --- a/service/lib/agama/storage/config_checkers/partition.rb +++ b/service/lib/agama/storage/config_checkers/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_checkers/base" +require "agama/storage/config_checkers/with_alias" require "agama/storage/config_checkers/with_encryption" require "agama/storage/config_checkers/with_filesystem" require "agama/storage/config_checkers/with_search" @@ -29,6 +30,7 @@ module Storage module ConfigCheckers # Class for checking the partition config. class Partition < Base + include WithAlias include WithEncryption include WithFilesystem include WithSearch @@ -37,9 +39,11 @@ class Partition < Base # @param storage_config [Storage::Config] # @param product_config [Agama::Config] def initialize(config, storage_config, product_config) - super(storage_config, product_config) + super() @config = config + @storage_config = storage_config + @product_config = product_config end # Partition config issues. @@ -47,6 +51,7 @@ def initialize(config, storage_config, product_config) # @return [Array] def issues [ + alias_issues, search_issues, filesystem_issues, encryption_issues @@ -57,6 +62,12 @@ def issues # @return [Configs::Partition] attr_reader :config + + # @return [Storage::Config] + attr_reader :storage_config + + # @return [Agama::Config] + attr_reader :product_config end end end diff --git a/service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb b/service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb index 9032679a16..35d408bb8a 100644 --- a/service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb +++ b/service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb @@ -31,9 +31,7 @@ class PhysicalVolumesEncryption < Encryption include Yast::I18n # @param config [Configs::VolumeGroup] - # @param storage_config [Storage::Config] - # @param product_config [Agama::Config] - def initialize(config, storage_config, product_config) + def initialize(config) super textdomain "agama" diff --git a/service/lib/agama/storage/config_checkers/search.rb b/service/lib/agama/storage/config_checkers/search.rb index ae9b7a6f73..c4faead5d2 100644 --- a/service/lib/agama/storage/config_checkers/search.rb +++ b/service/lib/agama/storage/config_checkers/search.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -22,6 +22,7 @@ require "agama/storage/config_checkers/base" require "agama/storage/configs/drive" require "agama/storage/configs/logical_volume" +require "agama/storage/configs/md_raid" require "agama/storage/configs/partition" require "yast/i18n" @@ -33,10 +34,8 @@ class Search < Base include Yast::I18n # @param config [#search] - # @param storage_config [Storage::Config] - # @param product_config [Agama::Config] - def initialize(config, storage_config, product_config) - super(storage_config, product_config) + def initialize(config) + super() textdomain "agama" @config = config @@ -84,6 +83,8 @@ def device_type case config when Agama::Storage::Configs::Drive _("drive") + when Agama::Storage::Configs::MdRaid + _("MD RAID") when Agama::Storage::Configs::Partition _("partition") when Agama::Storage::Configs::LogicalVolume diff --git a/service/lib/agama/storage/config_checkers/volume_group.rb b/service/lib/agama/storage/config_checkers/volume_group.rb index 37d08baa88..fe943a8af9 100644 --- a/service/lib/agama/storage/config_checkers/volume_group.rb +++ b/service/lib/agama/storage/config_checkers/volume_group.rb @@ -35,10 +35,12 @@ class VolumeGroup < Base # @param storage_config [Storage::Config] # @param product_config [Agama::Config] def initialize(config, storage_config, product_config) - super(storage_config, product_config) + super() textdomain "agama" @config = config + @storage_config = storage_config + @product_config = product_config end # Issues from a volume group config. @@ -59,6 +61,12 @@ def issues # @return [Configs::VolumeGroup] attr_reader :config + # @return [Storage::Config] + attr_reader :storage_config + + # @return [Agama::Config] + attr_reader :product_config + # Issue if the volume group name is missing. # # @return [Issue, nil] @@ -74,7 +82,7 @@ def name_issue def logical_volumes_issues config.logical_volumes.flat_map do |logical_volume| ConfigCheckers::LogicalVolume - .new(logical_volume, config, storage_config, product_config) + .new(logical_volume, config, product_config) .issues end end @@ -91,8 +99,7 @@ def physical_volumes_issues # @param pv_alias [String] # @return [Issue, nil] def missing_physical_volume_issue(pv_alias) - configs = storage_config.drives + storage_config.drives.flat_map(&:partitions) - return if configs.any? { |c| c.alias == pv_alias } + return if storage_config.potential_for_pv.any? { |c| c.alias == pv_alias } error( # TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). @@ -105,9 +112,32 @@ def missing_physical_volume_issue(pv_alias) # # @return [Array] def physical_volumes_devices_issues - config.physical_volumes_devices + issues = config.physical_volumes_devices .map { |d| missing_physical_volumes_device_issue(d) } .compact + + [issues, incompatible_physical_volumes_devices_issue].flatten.compact + end + + # Issue when the target devices mix reused and new devices. + # + # @return [Issue, nil] + def incompatible_physical_volumes_devices_issue + devices = config.physical_volumes_devices.map { |d| physical_volume_device(d) }.compact + return if devices.all?(&:create?) + return if devices.none?(&:create?) + + error( + # TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). + format( + _( + "The list of target devices for the volume group '%s' is mixing reused devices " \ + "and new devices" + ), + config.name + ), + kind: :incompatible_pv_targets + ) end # @see #physical_volumes_devices_issue @@ -115,7 +145,7 @@ def physical_volumes_devices_issues # @param device_alias [String] # @return [Issue, nil] def missing_physical_volumes_device_issue(device_alias) - return if storage_config.drives.any? { |d| d.alias == device_alias } + return if physical_volume_device(device_alias) error( format( @@ -132,9 +162,17 @@ def missing_physical_volumes_device_issue(device_alias) # @return [Array] def physical_volumes_encryption_issues ConfigCheckers::PhysicalVolumesEncryption - .new(config, storage_config, product_config) + .new(config) .issues end + + # Config of the device used as target for physical volumes. + # + # @param device_alias [String] + # @return [Configs::Drive, Configs::MdRaid, nil] + def physical_volume_device(device_alias) + storage_config.potential_for_pv_device.find { |d| d.alias?(device_alias) } + end end end end diff --git a/service/lib/agama/storage/config_checkers/volume_groups.rb b/service/lib/agama/storage/config_checkers/volume_groups.rb index 5d4e621617..61376cf554 100644 --- a/service/lib/agama/storage/config_checkers/volume_groups.rb +++ b/service/lib/agama/storage/config_checkers/volume_groups.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -29,6 +29,14 @@ module ConfigCheckers class VolumeGroups < Base include Yast::I18n + # @param storage_config [Storage::Config] + def initialize(storage_config) + super() + + textdomain "agama" + @storage_config = storage_config + end + # Volume groups issues. # # @return [Array] @@ -38,6 +46,9 @@ def issues private + # @return [Storage::Config] + attr_reader :storage_config + # Issues for overused target devices for physical volumes. # # @note The Agama proposal is not able to calculate if the same target device is used by diff --git a/service/lib/agama/storage/config_checkers/with_alias.rb b/service/lib/agama/storage/config_checkers/with_alias.rb new file mode 100644 index 0000000000..560167d8bc --- /dev/null +++ b/service/lib/agama/storage/config_checkers/with_alias.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/alias" + +module Agama + module Storage + module ConfigCheckers + # Mixin for alias issues. + module WithAlias + # @return [Array] + def alias_issues + ConfigCheckers::Alias.new(config, storage_config).issues + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/with_encryption.rb b/service/lib/agama/storage/config_checkers/with_encryption.rb index 89b26dc1af..a9452ea9a8 100644 --- a/service/lib/agama/storage/config_checkers/with_encryption.rb +++ b/service/lib/agama/storage/config_checkers/with_encryption.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/config_checkers/filesystem" +require "agama/storage/config_checkers/encryption" module Agama module Storage @@ -28,7 +28,7 @@ module ConfigCheckers module WithEncryption # @return [Array] def encryption_issues - ConfigCheckers::Encryption.new(config, storage_config, product_config).issues + ConfigCheckers::Encryption.new(config).issues end end end diff --git a/service/lib/agama/storage/config_checkers/with_filesystem.rb b/service/lib/agama/storage/config_checkers/with_filesystem.rb index 57a4642c55..cc5389731e 100644 --- a/service/lib/agama/storage/config_checkers/with_filesystem.rb +++ b/service/lib/agama/storage/config_checkers/with_filesystem.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -28,7 +28,7 @@ module ConfigCheckers module WithFilesystem # @return [Array] def filesystem_issues - ConfigCheckers::Filesystem.new(config, storage_config, product_config).issues + ConfigCheckers::Filesystem.new(config, product_config).issues end end end diff --git a/service/lib/agama/storage/config_checkers/with_partitions.rb b/service/lib/agama/storage/config_checkers/with_partitions.rb index f0e59c25b0..d639372065 100644 --- a/service/lib/agama/storage/config_checkers/with_partitions.rb +++ b/service/lib/agama/storage/config_checkers/with_partitions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # diff --git a/service/lib/agama/storage/config_checkers/with_search.rb b/service/lib/agama/storage/config_checkers/with_search.rb index 6c1b8b0663..70ef0b87d7 100644 --- a/service/lib/agama/storage/config_checkers/with_search.rb +++ b/service/lib/agama/storage/config_checkers/with_search.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -28,7 +28,7 @@ module ConfigCheckers module WithSearch # @return [Array] def search_issues - ConfigCheckers::Search.new(config, storage_config, product_config).issues + ConfigCheckers::Search.new(config).issues end end end diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions.rb b/service/lib/agama/storage/config_conversions/from_json_conversions.rb index e463c51003..2dfe811de8 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -27,6 +27,7 @@ require "agama/storage/config_conversions/from_json_conversions/filesystem" require "agama/storage/config_conversions/from_json_conversions/filesystem_type" require "agama/storage/config_conversions/from_json_conversions/logical_volume" +require "agama/storage/config_conversions/from_json_conversions/md_raid" require "agama/storage/config_conversions/from_json_conversions/partition" require "agama/storage/config_conversions/from_json_conversions/search" require "agama/storage/config_conversions/from_json_conversions/size" 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 46b0ea71fd..b2f24b7842 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -22,6 +22,7 @@ require "agama/storage/config_conversions/from_json_conversions/base" require "agama/storage/config_conversions/from_json_conversions/boot" require "agama/storage/config_conversions/from_json_conversions/drive" +require "agama/storage/config_conversions/from_json_conversions/md_raid" require "agama/storage/config_conversions/from_json_conversions/volume_group" require "agama/storage/config" @@ -45,7 +46,8 @@ def conversions { boot: convert_boot, drives: convert_drives, - volume_groups: convert_volume_groups + volume_groups: convert_volume_groups, + md_raids: convert_md_raids } end @@ -84,6 +86,20 @@ def convert_volume_groups def convert_volume_group(volume_group_json) FromJSONConversions::VolumeGroup.new(volume_group_json).convert end + + # @return [Array, nil] + def convert_md_raids + md_raids_json = config_json[:mdRaids] + return unless md_raids_json + + md_raids_json.map { |m| convert_md_raid(m) } + end + + # @param md_raid_json [Hash] + # @return [Configs::MdRaid] + def convert_md_raid(md_raid_json) + FromJSONConversions::MdRaid.new(md_raid_json).convert + end end end end diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/md_raid.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/md_raid.rb new file mode 100644 index 0000000000..a041ee8292 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/md_raid.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/from_json_conversions/base" +require "agama/storage/config_conversions/from_json_conversions/with_encryption" +require "agama/storage/config_conversions/from_json_conversions/with_filesystem" +require "agama/storage/config_conversions/from_json_conversions/with_partitions" +require "agama/storage/config_conversions/from_json_conversions/with_ptable_type" +require "agama/storage/configs/md_raid" +require "y2storage/disk_size" +require "y2storage/md_level" +require "y2storage/md_parity" + +module Agama + module Storage + module ConfigConversions + module FromJSONConversions + # MD RAID conversion from JSON hash according to schema. + class MdRaid < Base + private + + include WithSearch + include WithEncryption + include WithFilesystem + include WithPtableType + include WithPartitions + + alias_method :md_raid_json, :config_json + + # @see Base + # @return [Configs::MdRaid] + def default_config + Configs::MdRaid.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + name: md_raid_json[:name], + search: convert_search, + alias: md_raid_json[:alias], + level: convert_level, + parity: convert_parity, + chunk_size: convert_chunk_size, + devices: md_raid_json[:devices], + encryption: convert_encryption, + filesystem: convert_filesystem, + ptable_type: convert_ptable_type, + partitions: convert_partitions + } + end + + # @return [Y2Storage::MdParity, nil] + def convert_parity + value = md_raid_json[:parity] + return unless value && md_parity_values.include?(value) + + Y2Storage::MdParity.find(value) + end + + # @return [Y2Storage::MdLevel, nil] + def convert_level + value = md_raid_json[:level] + return unless value && md_level_values.include?(value) + + Y2Storage::MdLevel.find(value) + end + + # @return [Y2Storage::DiskSize, nil] + def convert_chunk_size + value = md_raid_json[:chunkSize] + return unless value + + Y2Storage::DiskSize.new(value) + end + + # @return [Array] + def md_parity_values + @md_parity_values ||= Y2Storage::MdParity.all.map(&:to_s) + end + + # @return [Array] + def md_level_values + @md_level_values ||= Y2Storage::MdLevel.all.map(&:to_s) + end + end + end + end + end +end 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 8770d41a06..3a7688ab97 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -23,6 +23,7 @@ require "agama/storage/config_conversions/from_json_conversions/encryption" require "agama/storage/config_conversions/from_json_conversions/logical_volume" require "agama/storage/configs/volume_group" +require "y2storage/disk_size" module Agama module Storage diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions.rb b/service/lib/agama/storage/config_conversions/to_json_conversions.rb index b405e26849..81dd7767ca 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -27,6 +27,7 @@ require "agama/storage/config_conversions/from_json_conversions/encryption_properties" require "agama/storage/config_conversions/from_json_conversions/filesystem" require "agama/storage/config_conversions/from_json_conversions/logical_volume" +require "agama/storage/config_conversions/from_json_conversions/md_raid" require "agama/storage/config_conversions/from_json_conversions/partition" require "agama/storage/config_conversions/from_json_conversions/search" require "agama/storage/config_conversions/from_json_conversions/size" 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 21114a2d2c..01b2fce465 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -22,6 +22,7 @@ require "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" +require "agama/storage/config_conversions/to_json_conversions/md_raid" require "agama/storage/config_conversions/to_json_conversions/volume_group" module Agama @@ -43,7 +44,8 @@ def conversions { boot: convert_boot, drives: convert_drives, - volumeGroups: convert_volume_groups + volumeGroups: convert_volume_groups, + mdRaids: convert_md_raids } end @@ -61,6 +63,11 @@ def convert_drives def convert_volume_groups config.volume_groups.map { |v| ToJSONConversions::VolumeGroup.new(v).convert } end + + # @return [Array] + def convert_md_raids + config.md_raids.map { |m| ToJSONConversions::MdRaid.new(m).convert } + end end end end 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 bb3c5ce60c..b025ae0abf 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 @@ -52,7 +52,7 @@ def conversions name: config.name, stripes: config.stripes, stripeSize: config.stripe_size&.to_i, - pool: config.pool?, + pool: config.pool? ? true : nil, usedPool: config.used_pool } end diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/md_raid.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/md_raid.rb new file mode 100644 index 0000000000..739b51fba9 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/md_raid.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/to_json_conversions/base" +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_partitions" +require "agama/storage/config_conversions/to_json_conversions/with_ptable_type" +require "agama/storage/config_conversions/to_json_conversions/with_search" + +module Agama + module Storage + module ConfigConversions + module ToJSONConversions + # MD RAID conversion to JSON hash according to schema. + class MdRaid < Base + include WithSearch + include WithEncryption + include WithFilesystem + include WithPtableType + include WithPartitions + + # @param config [Configs::MdRaid] + def initialize(config) + super() + @config = config + end + + private + + # @see Base#conversions + def conversions + { + search: convert_search, + alias: config.alias, + name: config.name, + level: config.level&.to_s, + parity: config.parity&.to_s, + chunkSize: config.chunk_size&.to_i, + devices: config.devices, + encryption: convert_encryption, + filesystem: convert_filesystem, + ptableType: convert_ptable_type, + partitions: convert_partitions + } + end + end + end + end + end +end 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 217d88ded0..76ccc7eb00 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 @@ -47,14 +47,6 @@ def conversions } end - # @return [Integer, nil] - def convert_extent_size - extent_size = config.extent_size - return unless extent_size - - extent_size.to_i - end - # @return [Array] def convert_physical_volumes [ diff --git a/service/lib/agama/storage/config_json_solver.rb b/service/lib/agama/storage/config_json_solver.rb index 06e8ba4663..396eb80ed4 100644 --- a/service/lib/agama/storage/config_json_solver.rb +++ b/service/lib/agama/storage/config_json_solver.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -60,6 +60,16 @@ module Storage # # See doc/storage_proposal_from_profile.md for a complete description of how the config is # generated from a profile. + # + # FIXME: Solve all the "generate" sections. + # + # The config is expected to have only a "generate". If there are more than one, then the first + # "generate" is solved, ignoring the rest. Nevertheless, deciding which is the first one + # might be controversial. Right now, the first "generate" is searched in this order: drives, + # mdRaids and VolumeGroups. + # + # All the "generate" sections should be solved, and the config checker would complain if there + # is any repeated mount point. class ConfigJSONSolver # @param default_paths [Array] Default paths of the product. # @param mandatory_paths [Array] Mandatory paths of the product. @@ -172,13 +182,13 @@ def volume_from_generate(config, path) # @return [Array] def configs_with_generate - configs = drive_configs + volume_group_configs + configs = drive_configs + md_raid_configs + volume_group_configs configs.select { |c| with_volume_generate?(c) } end # @return [Array] def configs_with_filesystem - drive_configs + partition_configs + logical_volume_configs + drive_configs + md_raid_configs + partition_configs + logical_volume_configs end # @return [Array] @@ -191,12 +201,16 @@ def volume_group_configs config_json[:volumeGroups] || [] end + # @return [Array] + def md_raid_configs + config_json[:mdRaids] || [] + end + # @return [Array] def partition_configs - drive_configs = config_json[:drives] - return [] unless drive_configs + configs = drive_configs + md_raid_configs - drive_configs + configs .flat_map { |c| c[:partitions] } .compact end diff --git a/service/lib/agama/storage/config_solvers/boot.rb b/service/lib/agama/storage/config_solvers/boot.rb index 9a6d83b81e..9d1608d4b4 100644 --- a/service/lib/agama/storage/config_solvers/boot.rb +++ b/service/lib/agama/storage/config_solvers/boot.rb @@ -45,74 +45,139 @@ def solve(config) # * A disk is directly formated and mounted as root. # * The volume group allocating the root logical volume uses whole drives as physical # volumes. + # * The MD RAID allocating root uses whole drives as member devices. def solve_device_alias return unless config.boot.configure? && config.boot.device.default? - device = root_device + device = boot_device return unless device device.ensure_alias config.boot.device.device_alias = device.alias end - # Config of the drive used for allocating root, directly or inderectly. + # Config of the device used for allocating root, directly or indirectly. + # + # The boot device has to be a partitioned drive. If root is not directly created as a + # partition of a drive (e.g., as logical volume, as partition of a MD RAID, etc), then the + # first partitioned drive used for allocating the device (physical volume or MD member + # device) is considered as boot device. + # + # The boot device is recursively searched until reaching a drive. # # @return [Configs::Drive, nil] nil if the boot device cannot be inferred from the config. - def root_device - root_drive || root_lvm_device + def boot_device + root_device = config.root_drive || config.root_md_raid || config.root_volume_group + return unless root_device + + partitioned_drive_from_device(root_device) end - # Config of the drive used for allocating the root partition. + # Recursively looks for the first partitioned drive from the given list of devices. + # + # @param devices [Array] + # @param is_target [Boolean] Whether the devices are target for automatically creating + # partitions (e.g., for creating physical volumes). # # @return [Configs::Drive, nil] - def root_drive - drive = config.root_drive - return unless drive&.partitions&.any? + def partitioned_drive_from_devices(devices, is_target: false) + devices.each do |device| + drive = partitioned_drive_from_device(device, is_target: is_target) + return drive if drive + end - drive + nil end - # Config of the first drive used for allocating the physical volumes of the root volume - # group. + # Recursively looks for the first partitioned drive from the given device. + # + # @param device [Configs::Drive, Configs::MdRaid, Configs::VolumeGroup] + # @param is_target [Boolean] See {#partitioned_drive_from_devices} # # @return [Configs::Drive, nil] - def root_lvm_device - volume_group = config.root_volume_group - return unless volume_group + def partitioned_drive_from_device(device, is_target: false) + case device + when Configs::Drive + (device.partitions? || is_target) ? device : nil + when Configs::MdRaid + partitioned_drive_from_md_raid(device) + when Configs::VolumeGroup + partitioned_drive_from_volume_group(device) + end + end - first_target_lvm_device(volume_group) || first_physical_volume_device(volume_group) + # Recursively looks for the first partitioned drive from the given MD RAID. + # + # @param md_raid [Configs::MdRaid] + # @return [Configs::Drive, nil] + def partitioned_drive_from_md_raid(md_raid) + devices = find_devices(md_raid.devices) + partitioned_drive_from_devices(devices) end - # Config of the first target device for creating physical volumes. + # Recursively looks for the first partitioned drive from the given volume group. # - # @param config [Configs::VolumeGroup] + # @param volume_group [Configs::VolumeGroup] # @return [Configs::Drive, nil] - def first_target_lvm_device(config) - device_alias = config.physical_volumes_devices.first - return unless device_alias + def partitioned_drive_from_volume_group(volume_group) + pv_devices = find_devices(volume_group.physical_volumes_devices, is_target: true) + pvs = find_devices(volume_group.physical_volumes) + + partitioned_drive_from_devices(pv_devices, is_target: true) || + partitioned_drive_from_devices(pvs) + end + + # Finds the devices with the given aliases or containing the given aliases. + # + # @param aliases [Array] + # @param is_target [Boolean] See {#partitioned_drive_from_devices} + # + # @return [Array] + def find_devices(aliases, is_target: false) + aliases.map { |a| find_device(a, is_target: is_target) }.compact + end - self.config.drives.find { |d| d.alias?(device_alias) } + # Finds the device with the given alias or containing the given alias. + # + # @param device_alias [String] + # @param is_target [Boolean] See {#partitioned_drive_from_devices} + # + # @return [Configs::Drive, Configs::MdRaid, Configs::VolumeGroup, nil] + def find_device(device_alias, is_target: false) + find_drive(device_alias, is_target: is_target) || + find_md_raid(device_alias) || + find_volume_group(device_alias) end - # Config of the device of the first partition used as physical volume. + # Finds the drive with the given alias or containing a partition with the given alias. + # + # @note If the alias points to a drive instead of a partition and the drive is directly used + # (i.e., the drive is not used as target), then the drive is not found. Directly used + # drives (without partitioning) cannot be proposed as boot device. + # + # @param device_alias [String] + # @param is_target [Boolean] See {#partitioned_drive_from_devices} # - # @param config [Configs::VolumeGroup] # @return [Configs::Drive, nil] - def first_physical_volume_device(config) - device_alias = config.physical_volumes.find { |p| partition_alias?(p) } - return unless device_alias + def find_drive(device_alias, is_target: false) + drive = is_target ? config.drive(device_alias) : nil + drive || config.drives.find { |d| d.partition?(device_alias) } + end - self.config.drives.find do |drive| - drive.partitions.any? { |p| p.alias?(device_alias) } - end + # Finds the MD RAID with the given alias or containing a partition with the given alias. + # + # @param device_alias [String] + # @return [Configs::MdRaid, nil] + def find_md_raid(device_alias) + config.md_raid(device_alias) || config.md_raids.find { |d| d.partition?(device_alias) } end - # Whether there is a partition with the given alias. + # Finds the volume group containing a logical volume with the given alias. # # @param device_alias [String] - # @return [Boolean] - def partition_alias?(device_alias) - config.partitions.any? { |p| p.alias?(device_alias) } + # @return [Configs::VolumeGroup, nil] + def find_volume_group(device_alias) + config.volume_groups.find { |v| v.logical_volume?(device_alias) } end end end diff --git a/service/lib/agama/storage/config_solvers/encryption.rb b/service/lib/agama/storage/config_solvers/encryption.rb index 8285030d3a..178a62e48c 100644 --- a/service/lib/agama/storage/config_solvers/encryption.rb +++ b/service/lib/agama/storage/config_solvers/encryption.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -44,7 +44,7 @@ def solve(config) private def solve_encryptions - configs_with_encryption.each { |c| solve_encryption(c) } + config.supporting_encryption.each { |c| solve_encryption(c) } end # @param config [#encryption] @@ -84,11 +84,6 @@ def solve_encryption_values(config) config.key_size ||= default_encryption.key_size end - # @return [Array<#encryption>] - def configs_with_encryption - config.drives + config.partitions + config.logical_volumes - end - # Default encryption defined by the product. # # @return [Configs::Encryption] diff --git a/service/lib/agama/storage/config_solvers/filesystem.rb b/service/lib/agama/storage/config_solvers/filesystem.rb index a6feac7c6c..fb63adb19e 100644 --- a/service/lib/agama/storage/config_solvers/filesystem.rb +++ b/service/lib/agama/storage/config_solvers/filesystem.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -37,7 +37,7 @@ class Filesystem < Base def solve(config) @config = config - configs_with_filesystem.each { |c| solve_filesystem(c) } + config.supporting_filesystem.each { |c| solve_filesystem(c) } end private @@ -66,11 +66,6 @@ def solve_btrfs_values(config) btrfs.default_subvolume ||= (default_btrfs.default_subvolume || "") end - # @return [Array<#filesystem>] - def configs_with_filesystem - config.drives + config.partitions + config.logical_volumes - end - # Default filesystem defined by the product. # # @param path [String, nil] diff --git a/service/lib/agama/storage/config_solvers/search.rb b/service/lib/agama/storage/config_solvers/search.rb index 744cf7e740..9722a4bbb4 100644 --- a/service/lib/agama/storage/config_solvers/search.rb +++ b/service/lib/agama/storage/config_solvers/search.rb @@ -44,6 +44,9 @@ def initialize(product_config, devicegraph, analyzer) def solve(config) @sids = [] config.drives = config.drives.flat_map { |d| solve_drive(d) } + config.md_raids = config.md_raids.flat_map do |raid| + raid.search ? solve_raid(raid) : raid + end end private @@ -65,11 +68,27 @@ def solve(config) # @param original_drive [Configs::Drive] # @return [Configs::Drive, Array] def solve_drive(original_drive) - devices = find_drives(original_drive.search) - return without_device(original_drive) if devices.empty? + solve_partitionable(original_drive, :find_drives) + end + + # @see #solve + # + # @note The given mdRaid object can be modified + # + # @param original_raid [Configs::MdRaid] + # @return [Configs::MdRaid, Array] + def solve_raid(original_raid) + solve_partitionable(original_raid, :find_raids) + end + + # @see #solve_drive + # @see #solve_raid + def solve_partitionable(original_config, find_method) + devices = send(find_method, original_config.search) + return without_device(original_config) if devices.empty? devices.map do |device| - drive_copy(original_drive, device) + partitionable_copy(original_config, device) end end @@ -77,29 +96,29 @@ def solve_drive(original_drive) # # @note The config object is modified. # - # @param config [Configs::Drive, Configs::Partition] - # @return [Configs::Drive, Configs::Partition] + # @param config [Configs::Drive, Configs::MdRaid, Configs::Partition] + # @return [Configs::Drive, Configs::MdRaid, Configs::Partition] def without_device(config) config.search.solve config end - # see #solve_drive - def drive_copy(original_drive, device) - drive_config = original_drive.copy - drive_config.search.solve(device) - add_found(drive_config) + # see #solve_partitionable + def partitionable_copy(original_config, device) + config = original_config.copy + config.search.solve(device) + add_found(config) - return drive_config unless drive_config.partitions? + return config unless config.partitions? - drive_config.partitions = drive_config.partitions.flat_map do |partition_config| + config.partitions = config.partitions.flat_map do |partition_config| solve_partition(partition_config, device) end - drive_config + config end - # see #solve_drive + # see #solve_partitionable # # @note The given partition object can be modified # @@ -153,6 +172,15 @@ def find_partitions(search_config, device) next_unassigned_devices(candidates, search_config) end + # Finds the MD Raids matching the given search config. + # + # @param search_config [Agama::Storage::Configs::Search] + # @return [Y2Storage::Device, nil] + def find_raids(search_config) + candidates = candidate_devices(search_config, default: devicegraph.software_raids) + next_unassigned_devices(candidates, search_config) + end + # Candidate devices for the given search config. # # @param search_config [Agama::Storage::Configs::Search] diff --git a/service/lib/agama/storage/config_solvers/size.rb b/service/lib/agama/storage/config_solvers/size.rb index 8eb372dc00..a2ee7cafb6 100644 --- a/service/lib/agama/storage/config_solvers/size.rb +++ b/service/lib/agama/storage/config_solvers/size.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -104,15 +104,15 @@ def size_from_device(device) end end - # @return [Array] + # @return [Array<#size>] def configs_with_size - configs = config.partitions + config.logical_volumes + configs = config.supporting_size configs.select { |c| valid?(c) } end - # @return [Array] + # @return [Array<#filesystem>] def configs_with_filesystem - configs = config.drives + config.partitions + config.logical_volumes + configs = config.supporting_filesystem configs.select { |c| valid?(c) } end diff --git a/service/lib/agama/storage/configs.rb b/service/lib/agama/storage/configs.rb index f746638b4e..455466b889 100644 --- a/service/lib/agama/storage/configs.rb +++ b/service/lib/agama/storage/configs.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -35,6 +35,7 @@ module Configs require "agama/storage/configs/filesystem" require "agama/storage/configs/filesystem_type" require "agama/storage/configs/logical_volume" +require "agama/storage/configs/md_raid" require "agama/storage/configs/partition" require "agama/storage/configs/search" require "agama/storage/configs/size" diff --git a/service/lib/agama/storage/configs/drive.rb b/service/lib/agama/storage/configs/drive.rb index edb3d2f265..310c193967 100644 --- a/service/lib/agama/storage/configs/drive.rb +++ b/service/lib/agama/storage/configs/drive.rb @@ -22,40 +22,29 @@ require "agama/storage/configs/search" require "agama/storage/configs/with_alias" require "agama/storage/configs/with_filesystem" +require "agama/storage/configs/with_partitions" require "agama/storage/configs/with_search" module Agama module Storage module Configs - # Section of the configuration representing a device that is expected to exist in the target - # system and that can be used as a regular disk. + # Configuration representing a drive. + # + # The device is expected to exist in the target system and can be used as a regular disk. class Drive include WithAlias include WithFilesystem + include WithPartitions include WithSearch # @return [Encryption, nil] attr_accessor :encryption - # @return [Y2Storage::PartitionTables::Type, nil] - attr_accessor :ptable_type - - # @return [Array] - attr_accessor :partitions - - # Constructor def initialize - @partitions = [] + initialize_partitions # All drives are expected to match a real device in the system, so let's ensure a search. @search = Search.new.tap { |s| s.max = 1 } end - - # Whether the drive definition contains partition definitions - # - # @return [Boolean] - def partitions? - partitions.any? - end end end end diff --git a/service/lib/agama/storage/configs/md_raid.rb b/service/lib/agama/storage/configs/md_raid.rb new file mode 100644 index 0000000000..30fd5282a0 --- /dev/null +++ b/service/lib/agama/storage/configs/md_raid.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/configs/with_filesystem" +require "agama/storage/configs/with_partitions" + +module Agama + module Storage + module Configs + # Configuration representing a MD RAID. + class MdRaid + include WithFilesystem + include WithPartitions + include WithAlias + include WithSearch + + # MD RAID base name. + # + # @return [String, nil] e.g., "system". + attr_accessor :name + + # MD RAId level. + # + # @return [Y2Storage::MdLevel, nil] + attr_accessor :level + + # MD RAId parity algorithm. + # + # @return [Y2Storage::MdParity, nil] + attr_accessor :parity + + # @return [Y2Storage::DiskSize, nil] + attr_accessor :chunk_size + + # Aliases of the devices used by the MD RAID. + # + # @return [Array] + attr_accessor :devices + + # @return [Encryption, nil] + attr_accessor :encryption + + def initialize + initialize_partitions + @devices = [] + end + + # Minimum number of member devices required by the MD RAID. + # + # FIXME: The information about the minimum number of devices is provided by the method + # Y2Storage::Md#minimal_number_of_devices, which requires to create a Md instance. + # This information should be available at class level. + # + # @note: Only raid0, raid1, raid5, raid6 and raid10 are meaningful for a MD RAID config. + # + # @return [Integer] + def min_devices + return 0 unless level + + case level.to_sym + when :raid0, :raid1, :raid10 + 2 + when :raid5 + 3 + when :raid6 + 4 + else + 0 + end + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/volume_group.rb b/service/lib/agama/storage/configs/volume_group.rb index c595b6e531..ae225f45aa 100644 --- a/service/lib/agama/storage/configs/volume_group.rb +++ b/service/lib/agama/storage/configs/volume_group.rb @@ -24,7 +24,9 @@ module Storage module Configs # Section of the configuration representing a LVM volume group. class VolumeGroup - # @return [String, nil] + # Base name. + # + # @return [String, nil] e.g., "system". attr_accessor :name # @return [Y2Storage::DiskSize, nil] @@ -53,6 +55,10 @@ def initialize @physical_volumes = [] @logical_volumes = [] end + + def logical_volume?(device_alias) + logical_volumes.find { |l| l.alias?(device_alias) } + end end end end diff --git a/service/lib/agama/storage/configs/with_partitions.rb b/service/lib/agama/storage/configs/with_partitions.rb new file mode 100644 index 0000000000..c488a53518 --- /dev/null +++ b/service/lib/agama/storage/configs/with_partitions.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + # Mixin for configs with partitions. + module WithPartitions + # @return [Y2Storage::PartitionTables::Type, nil] + attr_accessor :ptable_type + + # @return [Array] + attr_accessor :partitions + + # Sets initial value for partitions. + def initialize_partitions + @partitions = [] + end + + # Whether the config contains partition definitions. + # + # @return [Boolean] + def partitions? + partitions.any? + end + + # Whether the config contains a partition with the given alias. + # + # @param device_alias [String] + # @return [Boolean] + def partition?(device_alias) + partitions.any? { |p| p.alias?(device_alias) } + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/with_search.rb b/service/lib/agama/storage/configs/with_search.rb index d1c15da5b5..d4c814462c 100644 --- a/service/lib/agama/storage/configs/with_search.rb +++ b/service/lib/agama/storage/configs/with_search.rb @@ -56,6 +56,15 @@ def device_name search&.name unless search&.create_device? end + + # Whether the device is going to be created. + # + # @return [Boolean] + def create? + return true unless search + + search.create_device? + end end end end diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index cf80af80df..c5aed0a8d1 100644 --- a/service/lib/y2storage/agama_proposal.rb +++ b/service/lib/y2storage/agama_proposal.rb @@ -144,7 +144,7 @@ def calculate_initial_planned(devicegraph) def clean_graph(devicegraph) remove_empty_partition_tables(devicegraph) # {Proposal::SpaceMaker#prepare_devicegraph} returns a copy of the given devicegraph. - space_maker.prepare_devicegraph(devicegraph, disks_for_clean) + space_maker.prepare_devicegraph(devicegraph, devs_for_clean) end # Configures the disk devices on the given devicegraph to prefer the appropriate partition table @@ -208,8 +208,8 @@ def drives_with_empty_partition_table(devicegraph) # Devices for which the mandatory actions must be executed # # @return [Array] names of partitionable devices - def disks_for_clean - (drives_names + [boot_device_name]).compact.uniq + def devs_for_clean + (drives_names + raids_names + [boot_device_name]).compact.uniq end # Creates the planned devices on a given devicegraph @@ -217,7 +217,7 @@ def disks_for_clean # @param devicegraph [Devicegraph] the graph gets modified def create_devices(devicegraph) devices_creator = Proposal::AgamaDevicesCreator.new(devicegraph, issues_list) - result = devices_creator.populated_devicegraph(planned_devices, drives_names, space_maker) + result = devices_creator.populated_devicegraph(planned_devices, space_maker) end # Name of the boot device. @@ -234,6 +234,13 @@ def drives_names @drives_names ||= config.drives.map(&:found_device).compact.map(&:name) end + # Names of all the devices that correspond to an MD RAID from the config + # + # @return [Array] + def raids_names + @raids_names ||= config.md_raids.map(&:found_device).compact.map(&:name) + end + # Equivalent device at the given devicegraph for the given configuration setting (eg. drive) # # @param drive [Agama::Storage::Configs::Drive] diff --git a/service/lib/y2storage/proposal/agama_device_planner.rb b/service/lib/y2storage/proposal/agama_device_planner.rb index 1c48d33975..9f6767a035 100644 --- a/service/lib/y2storage/proposal/agama_device_planner.rb +++ b/service/lib/y2storage/proposal/agama_device_planner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,11 +20,14 @@ # find current contact information at www.suse.com. require "y2storage/planned" +require "y2storage/proposal/agama_md_name" module Y2Storage module Proposal # Base class used by Agama planners. class AgamaDevicePlanner + include AgamaMdName + # @!attribute [r] devicegraph # Devicegraph to be used as starting point. # @return [Devicegraph] @@ -35,13 +38,20 @@ class AgamaDevicePlanner # @return [Array] attr_reader :issues_list + # @!attribute [r] partitionables + # Map to track the planned devices that can contain partitions, indexed by alias + # @return [Hash{String => Array}] + attr_reader :partitionables + # Constructor # # @param devicegraph [Devicegraph] see {#devicegraph} # @param issues_list [Array] see {#issues_list} - def initialize(devicegraph, issues_list) + # @param partitionables [Array] see {#partitionables} + def initialize(devicegraph, issues_list, partitionables) @devicegraph = devicegraph @issues_list = issues_list + @partitionables = partitionables end # Planned devices according to the given config. @@ -146,6 +156,8 @@ def configure_size(planned, config) # @param device_config [Agama::Storage::Configs::Drive] # @param config [Agama::Storage::Config] def configure_partitions(planned, device_config, config) + planned.ptable_type = device_config.ptable_type if planned.respond_to?(:ptable_type=) + partition_configs = device_config.partitions .reject(&:delete?) .reject(&:delete_if_needed?) @@ -163,12 +175,13 @@ def configure_partitions(planned, device_config, config) # @return [Planned::Partition] def planned_partition(partition_config, device_config, config) Planned::Partition.new(nil, nil).tap do |planned| - planned.disk = device_config.found_device.name + planned.disk = device_config.found_device&.name planned.partition_id = partition_config.id configure_reuse(planned, partition_config) configure_block_device(planned, partition_config) configure_size(planned, partition_config.size) configure_pv(planned, partition_config, config) + configure_md_member(planned, partition_config, config) end end @@ -183,6 +196,29 @@ def configure_pv(planned, device_config, config) planned.lvm_volume_group_name = vg.name end + + # @param planned [Planned::Disk, Planned::Partition] + # @param device_config [Agama::Storage::Configs::Drive, Agama::Storage::Configs::Partition] + # @param config [Agama::Storage::Config] + def configure_md_member(planned, device_config, config) + return unless planned.respond_to?(:raid_name) && device_config.alias + + md = config.md_raids.find { |r| r.devices.include?(device_config.alias) } + return unless md + + planned.raid_name = member_md_name(md, device_config, config) + end + + # Stores the given device at the map of partitionables + # + # @param device [Planned::Device] + # @param config [Agama::Storage::Configs::Drive, Agama::Storage::Configs::Md] + def register_partitionable(device, config) + return unless config.alias + + partitionables[config.alias] ||= [] + partitionables[config.alias] << device + end end end end diff --git a/service/lib/y2storage/proposal/agama_devices_creator.rb b/service/lib/y2storage/proposal/agama_devices_creator.rb index e81f8e2d11..75570e9a54 100644 --- a/service/lib/y2storage/proposal/agama_devices_creator.rb +++ b/service/lib/y2storage/proposal/agama_devices_creator.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2017-2020] SUSE LLC +# Copyright (c) [2017-2025] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "y2storage/exceptions" require "y2storage/proposal/lvm_creator" +require "y2storage/proposal/agama_md_creator" require "y2storage/proposal/partition_creator" module Y2Storage @@ -45,20 +46,17 @@ def initialize(original_graph, issues_list) # Devicegraph including all the specified planned devices # # @param planned_devices [Planned::DevicesCollection] Devices to create/reuse - # @param disk_names [Array] Disks to consider # @param space_maker [SpaceMaker] # # @return [CreatorResult] Result with new devicegraph in which all the # planned devices have been allocated - def populated_devicegraph(planned_devices, disk_names, space_maker) + def populated_devicegraph(planned_devices, space_maker) # Process planned partitions log.info "planned devices = #{planned_devices.to_a.inspect}" - log.info "disk names = #{disk_names.inspect}" reset @planned_devices = planned_devices - @disk_names = disk_names @space_maker = space_maker process_devices @@ -72,9 +70,6 @@ def populated_devicegraph(planned_devices, disk_names, space_maker) # @return [Planned::DevicesCollection] Devices to create/reuse attr_reader :planned_devices - # @return [Array] Disks to consider - attr_reader :disk_names - # @return [SpaceMaker] space maker to use during operation attr_reader :space_maker @@ -111,6 +106,8 @@ def reset # planned devices have been allocated def process_devices process_existing_partitionables + mds = process_new_mds + process_new_partitionables(mds) process_volume_groups # This may be unexpected if the storage configuration provided by the user includes @@ -124,12 +121,13 @@ def process_devices # @see #process_devices def process_existing_partitionables partitions = partitions_for_existing(planned_devices) + vgs = resolved_candidate_devices(automatic_vgs_for_existing) begin # Check whether there is any chance of getting an unwanted order for the planned # partitions within a disk space_result = space_maker.provide_space( - original_graph, partitions: partitions, volume_groups: automatic_vgs + original_graph, partitions: partitions, volume_groups: vgs ) rescue Error => e log.info "SpaceMaker was not able to find enough space: #{e}" @@ -143,6 +141,36 @@ def process_existing_partitionables self.creator_result = PartitionCreator.new(devicegraph).create_partitions(distribution) end + # @see #process_devices + def process_new_mds + creator = AgamaMdCreator.new(devicegraph, planned_devices, creator_result) + planned_devices.mds.reject(&:reuse?).map do |planned_md| + creator.create(planned_md) + end + end + + # @see #process_devices + def process_new_partitionables(devices) + planned_devices = creator_result.devices_map.fetch_values(*devices.map(&:name)) + partitions = planned_devices.flat_map(&:partitions) + vgs = resolved_candidate_devices(automatic_vgs - automatic_vgs_for_existing) + + spaces = devices.flat_map(&:free_spaces) + calculator = Proposal::PartitionsDistributionCalculator.new(partitions, vgs) + distribution = calculator.best_distribution(spaces) + + if distribution.nil? + log.error "Partitions cannot be allocated:" + log.error " devices: #{devices}" + log.error " automatic volume groups: #{vgs}" + log.error " partitions: #{partitions}" + raise NoDiskSpaceError, "Partitions cannot be allocated into #{devices.map(&:name)}" + end + + new_result = PartitionCreator.new(devicegraph).create_partitions(distribution) + self.creator_result = creator_result.merge(new_result) + end + # @see #process_devices def process_volume_groups # TODO: Reuse volume groups. @@ -159,6 +187,15 @@ def automatic_vgs end end + # Filters {#automatic_vgs} to include only those that target pre-existing devices + # + # @return [Array] + def automatic_vgs_for_existing + # Use #any? because we know for sure that all candidate devices are of the same type + # (either found or created). + automatic_vgs.select { |vg| vg.pvs_candidate_devices.any? { |i| find_planned(i).reuse? } } + end + # Creates a volume group for the the given planned device. # # @param planned [Planned::LvmVg] @@ -187,6 +224,43 @@ def physical_volumes_for(vg_name) new_pvs + reused_pvs end + # Turns the values of {Planned::LvmVg#pvs_candidate_devices} into real device names + # + # This is needed because Agama uses the field to store ids referencing devices that may (or + # may not) exist at the beginning of the proposal process. + # + # @param vgs [Array] original list of VGs containing references + # @return [Array] a copy of the original list in which references has been + # converted into device names + def resolved_candidate_devices(vgs) + vgs.map do |volume_group| + volume_group.dup.tap do |vg| + vg.pvs_candidate_devices = volume_group.pvs_candidate_devices.map do |pv| + device_name(pv) + end + end + end + end + + # Planned device corresponding to the given planned ID + # + # @param id [String] + # @return [Planned::Device] + def find_planned(id) + planned_devices.find { |d| d.planned_id == id } + end + + # Final device name corresponding to the planned device with the given ID + # + # @param id [String] + # @return [String] + def device_name(id) + planned = find_planned(id) + return devicegraph.find_device(planned.reuse_sid).name if planned.reuse? + + creator_result.created_names { |d| d.planned_id == id }.first + end + # @see #process_existing_partitionables def grow_and_reuse_devices(distribution) planned_devices.select(&:reuse?).each do |planned| @@ -212,9 +286,8 @@ def assigned_space_next_to(planned, distribution) # @see #process_existing_partitionables def partitions_for_existing(planned_devices) - # Maybe in the future this can include partitions on top of existing MDs - # NOTE: simplistic implementation - planned_devices.partitions.reject(&:reuse?) + planned_devices.disk_partitions.reject(&:reuse?) + + planned_devices.mds.select(&:reuse?).flat_map(&:partitions).reject(&:reuse?) end # Planned devices configured to be reused. @@ -222,7 +295,8 @@ def partitions_for_existing(planned_devices) # @return [Array] def reused_planned_devices planned_devices.disks.select(&:reuse?) + - planned_devices.disks.flat_map(&:partitions).select(&:reuse?) + planned_devices.mds.select(&:reuse?) + + planned_devices.partitions.select(&:reuse?) end # Formats and/or mounts the disk-like block devices diff --git a/service/lib/y2storage/proposal/agama_devices_planner.rb b/service/lib/y2storage/proposal/agama_devices_planner.rb index 79b81bb3ff..4e2779a430 100644 --- a/service/lib/y2storage/proposal/agama_devices_planner.rb +++ b/service/lib/y2storage/proposal/agama_devices_planner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -22,6 +22,7 @@ require "y2storage/planned/devices_collection" require "y2storage/proposal/agama_drive_planner" require "y2storage/proposal/agama_vg_planner" +require "y2storage/proposal/agama_md_planner" module Y2Storage module Proposal @@ -44,12 +45,8 @@ def initialize(devicegraph, issues_list) # @param config [Agama::Storage::Config] # @return [Planned::DevicesCollection] def planned_devices(config) - # In the future this will also include planned devices that are equivalent to - # those typically generated by the Guided Proposal. For those, note that: - # - For dedicated VGs it creates a Planned VG containing a Planned LV, but no PVs - # - For LVM volumes it create a Planned LV but associated to no planned VG - # - For partition volumes, it creates a planned partition, of course - planned = planned_drives(config) + planned_vgs(config) + @partitionables = {} + planned = planned_drives(config) + planned_mds(config) + planned_vgs(config) Planned::DevicesCollection.new(planned) end @@ -61,21 +58,33 @@ def planned_devices(config) # @return [Array] List to register any found issue attr_reader :issues_list + # @return [Hash] Map to track the planned devices + attr_reader :partitionables + # @param config [Agama::Storage::Config] # @return [Array] def planned_drives(config) config.drives.flat_map do |drive| - planner = AgamaDrivePlanner.new(devicegraph, issues_list) + planner = AgamaDrivePlanner.new(devicegraph, issues_list, partitionables) planner.planned_devices(drive, config) end end + # @param config [Agama::Storage::Config] + # @return [Array] + def planned_mds(config) + config.md_raids.flat_map do |raid| + planner = AgamaMdPlanner.new(devicegraph, issues_list, partitionables) + planner.planned_devices(raid, config) + end + end + # @param config [Agama::Storage::Config] # @return [Array] def planned_vgs(config) config.volume_groups.flat_map do |vg| - planner = AgamaVgPlanner.new(devicegraph, issues_list) - planner.planned_devices(vg, config) + planner = AgamaVgPlanner.new(devicegraph, issues_list, partitionables) + planner.planned_devices(vg) end end end diff --git a/service/lib/y2storage/proposal/agama_drive_planner.rb b/service/lib/y2storage/proposal/agama_drive_planner.rb index b5b30282b5..55fd9cead5 100644 --- a/service/lib/y2storage/proposal/agama_drive_planner.rb +++ b/service/lib/y2storage/proposal/agama_drive_planner.rb @@ -33,7 +33,9 @@ class AgamaDrivePlanner < AgamaDevicePlanner def planned_devices(drive_config, config) return [] if drive_config.search&.skip_device? - [planned_drive(drive_config, config)] + drive = planned_drive(drive_config, config) + register_partitionable(drive, drive_config) + [drive] end private @@ -60,6 +62,7 @@ def planned_full_drive(drive_config, config) configure_reuse(planned, drive_config) configure_block_device(planned, drive_config) configure_pv(planned, drive_config, config) + configure_md_member(planned, drive_config, config) end end diff --git a/service/lib/y2storage/proposal/agama_md_creator.rb b/service/lib/y2storage/proposal/agama_md_creator.rb new file mode 100644 index 0000000000..15b5f1e7d6 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_md_creator.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/proposal/agama_md_name" + +module Y2Storage + module Proposal + # Auxiliary class to create new MD RAIDs + # + # @see AgamaDevicesCreator + class AgamaMdCreator + include AgamaMdName + + # Constructor + # + # @param devicegraph [Devicegraph] Devicegraph to be processed, will be modified + # @param planned_devices [Planned::DevicesCollection] Full list of devices to create/reuse + # @param creator_result [Proposal::CreatorResult] Current result, will be modified + def initialize(devicegraph, planned_devices, creator_result) + @devicegraph = devicegraph + @planned_devices = planned_devices + @creator_result = creator_result + end + + # Creates an MD RAID (but not its partitions) in the devicegraph + # + # @param planned [Planned::Md] + # @return [Md] + def create(planned) + md = Y2Storage::Md.create(devicegraph, md_device_name(planned, devicegraph)) + md.md_level = planned.md_level if planned.md_level + md.chunk_size = planned.chunk_size if planned.chunk_size + md.md_parity = planned.md_parity if planned.md_parity + devices = md_members(planned) + devices.map(&:remove_descendants) + md.sorted_devices = devices + planned.format!(md) if planned.partitions.empty? + + creator_result.merge!(CreatorResult.new(devicegraph, md.name => planned)) + + md + end + + private + + # @return [Devicegraph] Current devicegraph + attr_accessor :devicegraph + + # @return [Planned::DevicesCollection] Full list of devices to create/reuse + attr_reader :planned_devices + + # @return [Planned::DevicesCollection] Current result + attr_reader :creator_result + + # @see #create_md_raid + # + # @param planned_md [Planned::Md] + # @return [Array] sorted list of members + def md_members(planned_md) + names = planned_devices + .select { |d| d.respond_to?(:raid_name) && md_name_match?(planned_md, d) } + .sort_by { |d| md_member_index(d) } + .map { |d| planned_device_name(d) } + names.map do |dev_name| + device = Y2Storage::BlkDevice.find_by_name(devicegraph, dev_name) + device.encryption || device + end + end + + # Name of the device at the working devicegraph that corresponds to the given planned device + # + # @param planned [Planned::Device] + # @return [String] + def planned_device_name(planned) + return planned.reuse_name if planned.reuse? + + creator_result.created_names { |d| d.planned_id == planned.planned_id }.first + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_md_name.rb b/service/lib/y2storage/proposal/agama_md_name.rb new file mode 100644 index 0000000000..3e03617817 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_md_name.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Y2Storage + module Proposal + # Mixin to calculate the values used at the fields Planned::Md#name and + # Planned::CanBeMdMember#raid_name. + # + # Those fields must always have meaningful values since they are the only existing + # mechanism to relate a planned MD and the devices used as members. But it is a limited + # mechanism. + # + # First of all, specifying an MD name in the configuration is not mandatory at Agama (contrary + # to AutoYaST). On the other hand, there is no way to specify the order of the members. The + # field Planned::Md#devices_order is used by AutoYaST for that purpose, but is not suitable for + # Agama. + # + # This mixin abuses the fields, making it possible to use them beyond their original purposes. + # It opens the door to have RAIDs without a name and to specify the order of the members. + module AgamaMdName + # Value used at Planned::Md.name to identify the given RAID + # + # @param md_config [Agama::Storage::Configs::MdRaid] + # @param config [Agama::Storage::Config] + # @return [String] + def md_name(md_config, config) + return "/dev/md/#{md_config.name}" if md_config.name + + md_index = config.md_raids.index(md_config) + "raid_#{md_index}" + end + + # Value used at Planned::CanBeMdMember#raid_name to identify the target RAID and the + # position of this device in the list of RAID members. + # + # @param md_config [Agama::Storage::Configs::MdRaid] + # @param member_config [Agama::Storage::Configs::Partition, Agama::Storage::Configs::Drive] + # @param config [Agama::Storage::Config] + # @return [String] + def member_md_name(md_config, member_config, config) + member_index = md_config.devices.index(member_config.alias) + "#{member_index}**#{md_name(md_config, config)}" + end + + # Whether the given planned device is a member of the given planned RAID. + # + # @param raid [Planned::Md] + # @param member [Planned::Disk, Planned::Partition] + # @return [Boolean] + def md_name_match?(raid, member) + return false unless member.respond_to?(:md_member?) && member.md_member? + + raid.name == member.raid_name.split("**").last + end + + # Position of the given planned device into the list of members of its RAID. + # + # @param member [Planned::Disk, Planned::Partition] + # @return [Integer] + def md_member_index(member) + member.raid_name.split("**").first.to_i + end + + # Device name to use when creating the RAID in the target devicegraph + # + # @param raid [Planned::Md] + # @param devicegraph [Devicegraph] + def md_device_name(raid, devicegraph) + return raid.name if raid.name.start_with?("/dev/") + + Y2Storage::Md.find_free_numeric_name(devicegraph) + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_md_planner.rb b/service/lib/y2storage/proposal/agama_md_planner.rb new file mode 100644 index 0000000000..71e4fb48eb --- /dev/null +++ b/service/lib/y2storage/proposal/agama_md_planner.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/proposal/agama_device_planner" +require "y2storage/proposal/agama_md_name" + +module Y2Storage + module Proposal + # MD RAID planner for Agama. + class AgamaMdPlanner < AgamaDevicePlanner + include AgamaMdName + + # @param md_config [Agama::Storage::Configs::MdRaid] + # @param config [Agama::Storage::Config] + # @return [Array] + def planned_devices(md_config, config) + md = planned_md(md_config, config) + register_partitionable(md, md_config) + [md] + end + + private + + # @param md_config [Agama::Storage::Configs::MdRaid] + # @param config [Agama::Storage::Config] + # @return [Planned::Md] + def planned_md(md_config, config) + Y2Storage::Planned::Md.new.tap do |planned| + if md_config.partitions? + configure_partitions(planned, md_config, config) + else + configure_block_device(planned, md_config) + configure_pv(planned, md_config, config) + end + + planned.name = md_name(md_config, config) + planned.md_level = md_config.level + planned.md_parity = md_config.parity + planned.chunk_size = md_config.chunk_size + configure_reuse(planned, md_config) + end + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_space_maker.rb b/service/lib/y2storage/proposal/agama_space_maker.rb index 4812940f74..b30d3359ea 100644 --- a/service/lib/y2storage/proposal/agama_space_maker.rb +++ b/service/lib/y2storage/proposal/agama_space_maker.rb @@ -116,7 +116,7 @@ def current_size?(part, attr) # @param config [Agama::Storage::Config] # @return [Array] def partitions(config) - config.drives.flat_map(&:partitions) + config.partitions end # Device names from the given configs. diff --git a/service/lib/y2storage/proposal/agama_vg_planner.rb b/service/lib/y2storage/proposal/agama_vg_planner.rb index 7e16c8b325..497fc78d29 100644 --- a/service/lib/y2storage/proposal/agama_vg_planner.rb +++ b/service/lib/y2storage/proposal/agama_vg_planner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -26,18 +26,21 @@ module Y2Storage module Proposal # Volume group planner for Agama. class AgamaVgPlanner < AgamaDevicePlanner - # @param config [Agama::Storage::Configs::VolumeGroup] + # @param vg_config [Agama::Storage::Configs::VolumeGroup] # @return [Array] - def planned_devices(vg_config, config) - [planned_vg(vg_config, config)] + def planned_devices(vg_config) + [planned_vg(vg_config)] end private + # @return [Hash{String => Planned::Device}] Map with all the new devices that could + # potentially host automatic physical volumes + attr_reader :partitionables + # @param vg_config [Agama::Storage::Configs::VolumeGroup] - # @param config [Agama::Storage::Config] # @return [Planned::LvmVg] - def planned_vg(vg_config, config) + def planned_vg(vg_config) # TODO: A volume group name is expected. Otherwise, the planned physical volumes cannot # be associated to the planned volume group. Should the volume group name be # automatically generated if missing? @@ -47,23 +50,23 @@ def planned_vg(vg_config, config) planned.extent_size = vg_config.extent_size planned.lvs = planned_lvs(vg_config) planned.size_strategy = :use_needed - planned.pvs_candidate_devices = devices_for_pvs(vg_config, config) + planned.pvs_candidate_devices = devices_for_pvs(vg_config) configure_pvs_encryption(planned, vg_config) end end - # Names of the devices that must be used to calculate automatic physical volumes + # References to the devices that must be used to calculate automatic physical volumes # for the given volume group # + # The field Planned::LvmVg#pvs_candidate_devices is designed to use device names, but Agama + # initializes it using Planned::Device indentifiers instead. + # # @param vg_config [Agama::Storage::Configs::VolumeGroup] - # @param config [Agama::Storage::Config] # @return [Array] - def devices_for_pvs(vg_config, config) - drives = vg_config.physical_volumes_devices.flat_map do |dev_alias| - config.drives.select { |d| d.alias?(dev_alias) } - end.compact - - drives.map { |d| d.found_device.name } + def devices_for_pvs(vg_config) + devs = vg_config.physical_volumes_devices.flat_map do |dev_alias| + partitionables[dev_alias].map(&:planned_id) + end end # Configures the encryption-related fields of the given planned volume group diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index b62f96a961..45d10fc973 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed May 7 06:33:28 UTC 2025 - José Iván López González + +- Add support for creating and reusing MD RAID devices + (gh#agama-project/agama#2286). + ------------------------------------------------------------------- Tue May 6 11:33:52 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/test/agama/storage/config_checker_test.rb b/service/test/agama/storage/config_checker_test.rb index 658058df68..3406feffd9 100644 --- a/service/test/agama/storage/config_checker_test.rb +++ b/service/test/agama/storage/config_checker_test.rb @@ -20,741 +20,209 @@ # find current contact information at www.suse.com. require_relative "./storage_helpers" -require "agama/config" -require "agama/storage/config_conversions" +require_relative "./config_checkers/context" require "agama/storage/config_checker" -require "agama/storage/config_solver" -require "y2storage" - -shared_examples "encryption issues" do - let(:filesystem) { nil } - - context "without password" do - let(:encryption) do - { luks1: {} } - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("No passphrase") - end - end - - context "with unavailable method" do - let(:encryption) do - { - pervasiveLuks2: { - password: "12345" - } - } - end - - before do - allow_any_instance_of(Y2Storage::EncryptionMethod::PervasiveLuks2) - .to(receive(:available?)) - .and_return(false) - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("'Pervasive Volume Encryption' is not available") - end - end - - context "if TPM FDE is not possible" do - let(:encryption) do - { - tpmFde: { - password: "12345" - } - } - end - - before do - allow_any_instance_of(Y2Storage::EncryptionMethod::TpmFde) - .to(receive(:possible?)) - .and_return(false) - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("'TPM-Based Full Disk Encrytion' is not available") - end - end - - context "with invalid method" do - let(:encryption) { "protected_swap" } - let(:filesystem) { { path: "/" } } - - before do - allow_any_instance_of(Y2Storage::EncryptionMethod::ProtectedSwap) - .to(receive(:available?)) - .and_return(true) - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description) - .to(match("'Encryption with Volatile Protected Key' is not a suitable")) - end - end - - context "with a valid encryption" do - let(:encryption) do - { - luks1: { - password: "12345" - } - } - end - - let(:filesystem) { { path: "/" } } - - it "does not include an issue" do - expect(subject.issues.size).to eq(0) - end - end -end - -shared_examples "filesystem issues" do |filesystem_proc| - context "with invalid type" do - let(:filesystem) do - { - path: "/", - type: "vfat", - reuseIfPossible: reuse - } - end - - context "and without reusing the filesystem" do - let(:reuse) { false } - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("type 'FAT' is not suitable for '/'") - end - end - - context "and reusing the filesystem" do - let(:reuse) { true } - - it "does not include an issue" do - expect(subject.issues.size).to eq(0) - end - end - end - - context "with valid type" do - let(:filesystem) do - { - path: "/", - type: "btrfs" - } - end - - it "does not include an issue" do - expect(subject.issues.size).to eq(0) - end - end - - context "without a filesystem type" do - let(:filesystem) do - { - path: "/", - reuseIfPossible: reuse - } - end - - before do - # Explicitly remove the filesystem type. Otherwise the JSON conversion assigns a default type. - filesystem_proc.call(config).type = nil - end - - context "and without reusing the filesystem" do - let(:reuse) { false } - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to eq("Missing file system type for '/'") - end - end - - context "and reusing the filesystem" do - let(:reuse) { true } - - it "does not include an issue" do - expect(subject.issues.size).to eq(0) - end - end - end -end describe Agama::Storage::ConfigChecker do - include Agama::RSpec::StorageHelpers + include_context "checker" subject { described_class.new(config, product_config) } - let(:config) do - Agama::Storage::ConfigConversions::FromJSON - .new(config_json) - .convert - end - - let(:config_json) { nil } - - let(:product_config) { Agama::Config.new(product_data) } - - let(:product_data) do + let(:config_json) do { - "storage" => { - "volumes" => volumes, - "volume_templates" => volume_templates - } + boot: boot, + drives: drives, + mdRaids: md_raids, + volumeGroups: volume_groups } end - let(:volumes) { ["/"] } - - let(:volume_templates) do - [ - { - "mount_path" => "/", - "filesystem" => "btrfs", - "outline" => { "filesystems" => ["btrfs", "xfs"] } - } - ] - end - - before do - mock_storage(devicegraph: scenario) - # To speed-up the tests. Use #allow_any_instance because #allow introduces marshaling problems - allow_any_instance_of(Y2Storage::EncryptionMethod::TpmFde) - .to(receive(:possible?)) - .and_return(true) - end + let(:boot) { nil } + let(:drives) { nil } + let(:md_raids) { nil } + let(:volume_groups) { nil } describe "#issues" do - before do - # Solves the config before checking. - devicegraph = Y2Storage::StorageManager.instance.probed - - allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) - - Agama::Storage::ConfigSolver - .new(product_config, devicegraph) - .solve(config) - end - - let(:scenario) { "disks.yaml" } - - context "if the boot configuration is enabled" do - let(:config_json) do - { - boot: { - configure: true, - device: device_alias - }, - drives: [ - { - alias: "disk" - } - ] - } - end - - context "and there is no device alias" do - let(:device_alias) { nil } - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match(/The boot device cannot be automatically selected/) - end - end - - context "and the given alias does not exist" do - let(:device_alias) { "foo" } - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to eq("There is no boot device with alias 'foo'") - end - end - - context "and the given alias exists" do - let(:device_alias) { "disk" } - - it "does not include any issue" do - expect(subject.issues).to be_empty - end - end - end - - context "if the boot configuration is not enabled" do - let(:config_json) do + context "if the boot config is not valid" do + let(:boot) do { - boot: { - configure: false, - device: device_alias - }, - drives: [ - { - alias: "disk" - } - ] + configure: true, + alias: nil } end - context "and there is no device alias" do - let(:device_alias) { nil } - - it "does not include any issue" do - expect(subject.issues).to be_empty - end - end - - context "and the given alias does not exist" do - let(:device_alias) { "foo" } - - it "does not include any issue" do - expect(subject.issues).to be_empty - end - end - - context "and the given alias exists" do - let(:device_alias) { "disk" } - - it "does not include any issue" do - expect(subject.issues).to be_empty - end + it "includes the boot issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_root, + description: "The boot device cannot be automatically selected" + ) end end - context "if a drive has not found device" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - search: { - condition: { name: "/dev/vdd" }, - ifNotFound: if_not_found - } + context "if a drive config is not valid" do + let(:drives) do + [ + { + search: { + condition: { name: "/dev/vda" }, + ifNotFound: "error" } - ] - } - end - - context "and the drive should be skipped" do - let(:if_not_found) { "skip" } - - it "does not include any issue" do - expect(subject.issues).to be_empty - end + } + ] end - context "and the drive should not be skipped" do - let(:if_not_found) { "error" } - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to eq("Mandatory device /dev/vdd not found") - end + it "includes the drive issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :search, + description: "Mandatory device /dev/vda not found" + ) end end - context "if a drive has a found device" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { search: "/dev/vda" } - ] - } + context "if a partition config from a drive is not valid" do + let(:drives) do + [ + { + partitions: [ + { filesystem: { path: "/" } } + ] + } + ] end - it "does not include an issue" do - expect(subject.issues.size).to eq(0) + it "includes the partition issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :filesystem, + description: "Missing file system type for '/'" + ) end end - context "if a drive has encryption" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - encryption: encryption, - filesystem: filesystem - } - ] - } + context "if a MD RAID config is not valid" do + let(:md_raids) do + [ + { devices: ["disk1"] } + ] end - include_examples "encryption issues" - end - - context "if a drive has filesystem" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - filesystem: filesystem - } - ] - } + it "includes the MD RAID issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_such_alias, + description: /no MD RAID member device with alias 'disk1'/ + ) end - - filesystem_proc = proc { |c| c.drives.first.filesystem } - - include_examples "filesystem issues", filesystem_proc end - context "if a drive has partitions" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - partitions: [partition] - } - ] - } - end - - context "and a partition has not found device" do - let(:partition) do + context "if a partition config from a MD RAID is not valid" do + let(:md_raids) do + [ { - search: { - condition: { name: "/dev/vdb1" }, - ifNotFound: if_not_found - } + partitions: [ + { filesystem: { path: "/" } } + ] } - end - - context "and the partition should be skipped" do - let(:if_not_found) { "skip" } - - it "does not include any issue" do - expect(subject.issues).to be_empty - end - end - - context "and the partition should not be skipped" do - let(:if_not_found) { "error" } - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to eq("Mandatory device /dev/vdb1 not found") - end - end - end - - context "and the partition has a found device" do - let(:partition) do - { search: "/dev/vda1" } - end - - it "does not include an issue" do - expect(subject.issues.size).to eq(0) - end - end - - context "if a partition has filesystem" do - let(:partition) do - { filesystem: filesystem } - end - - filesystem_proc = proc { |c| c.drives.first.partitions.first.filesystem } - - include_examples "filesystem issues", filesystem_proc + ] end - context "and a partition has encryption" do - let(:partition) do - { - encryption: encryption, - filesystem: filesystem - } - end - - include_examples "encryption issues" + it "includes the partition issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :filesystem, + description: "Missing file system type for '/'" + ) end end - context "if a volume group has no name" do - let(:config_json) do - { - volumeGroups: [{ name: "test" }, { name: "" }, {}] - } + context "if a volume group config is not valid" do + let(:volume_groups) do + [ + { name: nil } + ] end - it "includes the expected issue" do - issues = subject.issues.select { |i| i.description.match?(/without name/) } - expect(issues.size).to eq(2) - expect(issues.map(&:error?)).to all(eq(true)) + it "includes the volume group issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + description: /without name/ + ) end end - context "if a volume group has logical volumes" do - let(:config_json) do - { - boot: { configure: false }, - volumeGroups: [ - { - name: "test", - logicalVolumes: [ - logical_volume, - { - alias: "pool", - pool: true - } - ] - } - ] - } - end - - context "if a logical volume has filesystem" do - let(:logical_volume) do - { filesystem: filesystem } - end - - filesystem_proc = proc { |c| c.volume_groups.first.logical_volumes.first.filesystem } - - include_examples "filesystem issues", filesystem_proc - end - - context "and a logical volume has encryption" do - let(:logical_volume) do + context "if a logical volume config is not valid" do + let(:volume_groups) do + [ { - encryption: encryption, - filesystem: filesystem + logicalVolumes: [ + { filesystem: { path: "/" } } + ] } - end - - include_examples "encryption issues" - end - - context "and a logical volume has an unknown pool" do - let(:logical_volume) do - { usedPool: "unknown" } - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("no LVM thin pool") - end - end - - context "and a logical volume has a known pool" do - let(:logical_volume) do - { usedPool: "pool" } - end - - it "does not include an issue" do - expect(subject.issues.size).to eq(0) - end - end - end - - context "if a volume group has an unknown physical volume" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - alias: "first-disk" - } - ], - volumeGroups: [ - { - name: "test", - physicalVolumes: ["first-disk", "pv1"] - } - ] - } + ] end - it "includes the expected issue" do + it "includes the logical volume issues" do issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("no LVM physical volume with alias 'pv1'") + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :filesystem, + description: "Missing file system type for '/'" + ) end end - context "if a volume group has an unknown target device for physical volumes" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - alias: "first-disk" - } - ], - volumeGroups: [ - { - name: "test", - physicalVolumes: [ - { - generate: { - targetDevices: ["first-disk", "second-disk"] - } - } - ] - } - ] - } - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) + context "if some mount paths are required" do + let(:volumes) { ["/", "swap"] } - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description) - .to(match("no target device for LVM physical volumes with alias 'second-disk'")) + let(:volume_templates) do + [ + { + "mount_path" => "/", + "filesystem" => "btrfs", + "outline" => { "required" => true } + }, + { + "mount_path" => "swap", + "filesystem" => "swap", + "outline" => { "required" => true } + } + ] end - end - context "if a volume group has encryption for physical volumes" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - alias: "first-disk" - } - ], - volumeGroups: [ + context "and one of them is omitted at the configuration" do + let(:drives) do + [ { - name: "test", - physicalVolumes: [ - { - generate: { - targetDevices: ["first-disk"], - encryption: encryption - } - } + partitions: [ + { filesystem: { path: "swap" } } ] } ] - } - end - - context "without password" do - let(:encryption) do - { luks1: {} } end - it "includes the expected issue" do - issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("No passphrase") - end - end - - context "with unavailable method" do - let(:encryption) do - { - luks2: { - password: "12345" - } - } - end - - before do - allow_any_instance_of(Y2Storage::EncryptionMethod::Luks2) - .to(receive(:available?)) - .and_return(false) - end - - it "includes the expected issue" do + it "includes an issue for the missing mount path" do issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to match("'Regular LUKS2' is not available") + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :required_filesystems, + description: /file system for \/ is/ + ) end - end - context "with invalid method" do - let(:encryption) { "random_swap" } - - it "includes the expected issue" do + it "does not include an issue for the present mount path" do issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description) - .to(match("'Encryption with Volatile Random Key' is not a suitable method")) - end - end - - context "with a valid encryption" do - let(:encryption) do - { - luks1: { - password: "12345" - } - } - end - - it "does not include an issue" do - expect(subject.issues.size).to eq(0) + expect(issues).to_not include an_object_having_attributes( + kind: :required_filesystems, + description: /file system for swap/ + ) end end end @@ -805,61 +273,11 @@ it "includes the expected issues" do issues = subject.issues - expect(issues.size).to eq(1) - - issue = issues.first - expect(issue.error?).to eq(true) - expect(issue.description).to(match("The device 'disk1' is used several times")) - end - end - - context "if some volumes are required" do - let(:volumes) { ["/", "swap"] } - - let(:volume_templates) do - [ - { - "mount_path" => "/", - "filesystem" => "btrfs", - "outline" => { "required" => true } - }, - { - "mount_path" => "swap", - "filesystem" => "swap", - "outline" => { "required" => true } - } - ] - end - - context "and one of them is omitted at the configuration" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { filesystem: { path: "swap" } } - ] - } - ] - } - end - - it "includes an issue for the missing mount path" do - issues = subject.issues - expect(issues).to include an_object_having_attributes( - error?: true, - kind: :required_filesystems, - description: /file system for \/ is/ - ) - end - - it "does not include an issue for the present mount path" do - issues = subject.issues - expect(issues).to_not include an_object_having_attributes( - kind: :required_filesystems, - description: /file system for swap/ - ) - end + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :vg_target_devices, + description: /The device 'disk1' is used several times/ + ) end end @@ -896,5 +314,34 @@ ) end end + + context "if the config is valid" do + let(:config_json) do + { + boot: { configure: false }, + drives: [ + { alias: "vda" }, + { alias: "vdb" } + ], + mdRaids: [ + { + level: "raid0", + devices: ["vda", "vdb"] + } + ], + volumeGroups: [ + { + name: "test" + } + ] + } + end + + before { solve_config } + + it "does not report issues" do + expect(subject.issues).to eq([]) + end + end end end diff --git a/service/test/agama/storage/config_checkers/alias_test.rb b/service/test/agama/storage/config_checkers/alias_test.rb new file mode 100644 index 0000000000..b75a59288b --- /dev/null +++ b/service/test/agama/storage/config_checkers/alias_test.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./context" +require "agama/storage/config_checkers/alias" + +shared_examples "overused alias issue" do + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :overused_alias, + description: /alias '#{device_alias}' is used by more than one/ + ) + end +end + +shared_examples "several MD RAID users" do + context "if it is used by more than one MD RAID" do + let(:md_raids) do + [ + { devices: [device_alias] }, + { devices: [device_alias] } + ] + end + + include_examples "overused alias issue" + end +end + +shared_examples "several volume group users" do + context "if it is used by more than one volume group" do + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] }, + { physicalVolumes: [device_alias] } + ] + end + + include_examples "overused alias issue" + end +end + +shared_examples "several users" do + context "if it is used by a MD RAID and by a volume group" do + let(:md_raids) do + [ + { devices: [device_alias] } + ] + end + + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] } + ] + end + + include_examples "overused alias issue" + end +end + +shared_examples "formatted and used issue" do + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :formatted_with_user, + description: /alias '#{device_alias}' cannot be formatted because it is used/ + ) + end +end + +shared_examples "formatted and MD RAID user" do + context "if it is formatted" do + let(:filesystem) { { path: "/" } } + + context "and it is used by a MD RAID" do + let(:md_raids) do + [ + { devices: [device_alias] } + ] + end + + include_examples "formatted and used issue" + end + end +end + +shared_examples "formatted and volume group user" do + context "if it is formatted" do + let(:filesystem) { { path: "/" } } + + context "and it is used by a volume group" do + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] } + ] + end + + include_examples "formatted and used issue" + end + end +end + +shared_examples "formatted and volume group target user" do + context "if it is formatted" do + let(:filesystem) { { path: "/" } } + + context "and it is used as target by a volume group" do + let(:volume_groups) do + [ + { physicalVolumes: [{ generate: [device_alias] }] } + ] + end + + include_examples "formatted and used issue" + end + end +end + +shared_examples "partitioned and used issue" do + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :partitioned_with_user, + description: /alias '#{device_alias}' cannot be partitioned because it is used/ + ) + end +end + +shared_examples "partitioned and MD RAID user" do + context "if it is partitioned" do + let(:partitions) do + [ + { path: "/" } + ] + end + + context "and it is used by a MD RAID" do + let(:md_raids) do + [ + { devices: [device_alias] } + ] + end + + include_examples "partitioned and used issue" + end + end +end + +shared_examples "partitioned and volume group user" do + context "if it is partitioned" do + let(:partitions) do + [ + { path: "/" } + ] + end + + context "and it is used by a volume group" do + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] } + ] + end + + include_examples "partitioned and used issue" + end + end +end + +describe Agama::Storage::ConfigCheckers::Alias do + include_context "checker" + + subject { described_class.new(device_config, config) } + + describe "#issues" do + context "for a drive" do + let(:config_json) do + { + drives: [ + { + alias: device_alias, + filesystem: filesystem, + partitions: partitions + } + ], + mdRaids: md_raids, + volumeGroups: volume_groups + } + end + + let(:device_alias) { "disk1" } + let(:filesystem) { nil } + let(:partitions) { nil } + let(:md_raids) { nil } + let(:volume_groups) { nil } + + let(:device_config) { config.drives.first } + + include_examples "several MD RAID users" + include_examples "several volume group users" + include_examples "several users" + include_examples "formatted and MD RAID user" + include_examples "formatted and volume group user" + include_examples "formatted and volume group target user" + include_examples "partitioned and MD RAID user" + include_examples "partitioned and volume group user" + end + + context "for a drive partition" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + alias: device_alias, + filesystem: filesystem + } + ] + } + ], + mdRaids: md_raids, + volumeGroups: volume_groups + } + end + + let(:device_alias) { "p1" } + let(:filesystem) { nil } + let(:md_raids) { nil } + let(:volume_groups) { nil } + + let(:device_config) { config.drives.first.partitions.first } + + include_examples "several MD RAID users" + include_examples "several volume group users" + include_examples "several users" + include_examples "formatted and MD RAID user" + include_examples "formatted and volume group user" + end + + context "for a MD RAID" do + let(:config_json) do + { + mdRaids: [ + { + alias: device_alias, + filesystem: filesystem, + partitions: partitions + } + ], + volumeGroups: volume_groups + } + end + + let(:device_alias) { "md1" } + let(:filesystem) { nil } + let(:partitions) { nil } + let(:volume_groups) { nil } + + let(:device_config) { config.md_raids.first } + + include_examples "several volume group users" + include_examples "formatted and volume group user" + include_examples "formatted and volume group target user" + include_examples "partitioned and volume group user" + end + + context "for a MD RAID partition" do + let(:config_json) do + { + mdRaids: [ + { + partitions: [ + { + alias: device_alias, + filesystem: filesystem + } + ] + } + ], + volumeGroups: volume_groups + } + end + + let(:device_alias) { "p1" } + let(:filesystem) { nil } + let(:volume_groups) { nil } + + let(:device_config) { config.md_raids.first.partitions.first } + + include_examples "several volume group users" + include_examples "formatted and volume group user" + end + end +end diff --git a/service/test/agama/storage/config_checkers/boot_test.rb b/service/test/agama/storage/config_checkers/boot_test.rb new file mode 100644 index 0000000000..91f4145cf4 --- /dev/null +++ b/service/test/agama/storage/config_checkers/boot_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./context" +require "agama/storage/config_checkers/boot" + +describe Agama::Storage::ConfigCheckers::Boot do + include_context "checker" + + subject { described_class.new(config) } + + let(:config_json) do + { + boot: { + configure: configure, + device: device_alias + }, + drives: [ + { + alias: "disk", + partitions: [ + { alias: "p1" } + ] + } + ] + } + end + + shared_examples "alias issue" do + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_such_alias, + description: /There is no boot device with alias '.*'/ + ) + end + end + + describe "#issues" do + context "if boot is enabled" do + let(:configure) { true } + + context "and there is no device alias" do + let(:device_alias) { nil } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_root, + description: /The boot device cannot be automatically selected/ + ) + end + end + + context "and the given alias does not exist" do + let(:device_alias) { "foo" } + include_examples "alias issue" + end + + context "and the device with the given alias is not a drive" do + let(:device_alias) { "p1" } + include_examples "alias issue" + end + + context "and the device with the given alias is a drive" do + let(:device_alias) { "disk" } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + end + + context "if boot is not enabled" do + let(:configure) { false } + + context "and there is no device alias" do + let(:device_alias) { nil } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + + context "and the given alias does not exist" do + let(:device_alias) { "foo" } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + + context "and the given alias exists" do + let(:device_alias) { "disk" } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/context.rb b/service/test/agama/storage/config_checkers/context.rb new file mode 100644 index 0000000000..55eb56790f --- /dev/null +++ b/service/test/agama/storage/config_checkers/context.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/config" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_solver" +require "y2storage" +require "y2storage/encryption_method/tpm_fde" + +shared_context "checker" do + # Solves the config. + def solve_config + devicegraph = Y2Storage::StorageManager.instance.probed + + Agama::Storage::ConfigSolver + .new(product_config, devicegraph) + .solve(config) + end + + include Agama::RSpec::StorageHelpers + + let(:product_config) { Agama::Config.new(product_data) } + + let(:product_data) do + { + "storage" => { + "volumes" => volumes, + "volume_templates" => volume_templates + } + } + end + + let(:volumes) { ["/"] } + + let(:volume_templates) do + [ + { + "mount_path" => "/", + "filesystem" => "btrfs", + "outline" => { "filesystems" => ["btrfs", "xfs"] } + } + ] + end + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + let(:config_json) { nil } + + before do + mock_storage(devicegraph: scenario) + + # To speed-up the tests. Use #allow_any_instance because #allow introduces marshaling problems + allow_any_instance_of(Y2Storage::EncryptionMethod::TpmFde) + .to(receive(:possible?)) + .and_return(true) + + allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) + end + + let(:scenario) { "disks.yaml" } +end diff --git a/service/test/agama/storage/config_checkers/drive_test.rb b/service/test/agama/storage/config_checkers/drive_test.rb new file mode 100644 index 0000000000..09d02526c9 --- /dev/null +++ b/service/test/agama/storage/config_checkers/drive_test.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./examples" +require_relative "./context" +require "agama/storage/config_checkers/drive" + +describe Agama::Storage::ConfigCheckers::Drive do + include_context "checker" + + subject { described_class.new(drive_config, config, product_config) } + + let(:config_json) do + { + drives: [ + { + alias: device_alias, + search: search, + filesystem: filesystem, + encryption: encryption, + partitions: partitions + } + ], + volumeGroups: volume_groups + } + end + + let(:device_alias) { nil } + let(:search) { nil } + let(:filesystem) { nil } + let(:encryption) { nil } + let(:partitions) { nil } + let(:volume_groups) { nil } + + let(:drive_config) { config.drives.first } + + describe "#issues" do + include_examples "alias issues" + include_examples "search issues" + include_examples "filesystem issues" + include_examples "encryption issues" + include_examples "partitions issues" + + context "if the drive is valid" do + let(:config_json) do + { + drives: [ + { + alias: "disk1" + } + ], + mdRaids: [ + { + level: "raid0", + devices: ["disk1"] + } + ] + } + end + + before { solve_config } + + it "does not report issues" do + expect(subject.issues).to eq([]) + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/encryption_test.rb b/service/test/agama/storage/config_checkers/encryption_test.rb new file mode 100644 index 0000000000..b1b6d20dac --- /dev/null +++ b/service/test/agama/storage/config_checkers/encryption_test.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./context" +require "agama/storage/config_checkers/encryption" +require "y2storage/encryption_method/pervasive_luks2" +require "y2storage/encryption_method/protected_swap" +require "y2storage/encryption_method/tpm_fde" + +describe Agama::Storage::ConfigCheckers::Encryption do + include_context "checker" + + subject { described_class.new(drive_config) } + + let(:config_json) do + { + drives: [ + { + filesystem: filesystem, + encryption: encryption + } + ] + } + end + + let(:drive_config) { config.drives.first } + + let(:filesystem) { nil } + + describe "#issues" do + context "without password" do + let(:encryption) do + { luks1: {} } + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /No passphrase/ + ) + end + end + + context "with unavailable method" do + let(:encryption) do + { + pervasiveLuks2: { + password: "12345" + } + } + end + + before do + allow_any_instance_of(Y2Storage::EncryptionMethod::PervasiveLuks2) + .to(receive(:available?)) + .and_return(false) + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /'Pervasive Volume Encryption' is not available/ + ) + end + end + + context "if TPM FDE is not possible" do + let(:encryption) do + { + tpmFde: { + password: "12345" + } + } + end + + before do + allow_any_instance_of(Y2Storage::EncryptionMethod::TpmFde) + .to(receive(:possible?)) + .and_return(false) + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /'TPM-Based Full Disk Encrytion' is not available/ + ) + end + end + + context "with invalid method" do + let(:encryption) { "protected_swap" } + let(:filesystem) { { path: "/" } } + + before do + allow_any_instance_of(Y2Storage::EncryptionMethod::ProtectedSwap) + .to(receive(:available?)) + .and_return(true) + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /'Encryption with Volatile Protected Key' is not a suitable/ + ) + end + end + + context "with a valid encryption" do + let(:encryption) do + { + luks1: { + password: "12345" + } + } + end + + let(:filesystem) { { path: "/" } } + + it "does not include an issue" do + expect(subject.issues.size).to eq(0) + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/examples.rb b/service/test/agama/storage/config_checkers/examples.rb new file mode 100644 index 0000000000..a5bf1d6ab8 --- /dev/null +++ b/service/test/agama/storage/config_checkers/examples.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +shared_examples "search issues" do + context "if the search is not valid" do + let(:search) do + { + condition: { name: "/test" }, + ifNotFound: "error" + } + end + + it "includes the search issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :search, + description: "Mandatory device /test not found" + ) + end + end +end + +shared_examples "filesystem issues" do + context "if the filesystem is not valid" do + let(:filesystem) do + { path: "/" } + end + + it "includes the filesystem issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :filesystem, + description: "Missing file system type for '/'" + ) + end + end +end + +shared_examples "encryption issues" do + context "if the encryption is not valid" do + let(:encryption) do + { luks1: {} } + end + + it "includes the encryption issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /No passphrase .*/ + ) + end + end +end + +shared_examples "partitions issues" do + context "if any partition is not valid" do + let(:partitions) do + [ + { + search: { + ifNotFound: "error" + } + } + ] + end + + it "includes the partition issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :search, + description: "Mandatory partition not found" + ) + end + end +end + +shared_examples "alias issues" do + context "if the alias has issues" do + let(:device_alias) { "device1" } + + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] }, + { physicalVolumes: [device_alias] } + ] + end + + it "includes the alias issues" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :overused_alias, + description: /alias '#{device_alias}' is used by more than one/ + ) + end + end +end diff --git a/service/test/agama/storage/config_checkers/filesystem_test.rb b/service/test/agama/storage/config_checkers/filesystem_test.rb new file mode 100644 index 0000000000..c0103d34f6 --- /dev/null +++ b/service/test/agama/storage/config_checkers/filesystem_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./context" +require "agama/storage/config_checkers/filesystem" + +describe Agama::Storage::ConfigCheckers::Filesystem do + include_context "checker" + + subject { described_class.new(drive_config, product_config) } + + let(:config_json) do + { + drives: [ + { filesystem: filesystem } + ] + } + end + + let(:drive_config) { config.drives.first } + + describe "#issues" do + context "with invalid type" do + let(:filesystem) do + { + path: "/", + type: "vfat", + reuseIfPossible: reuse + } + end + + context "and without reusing the filesystem" do + let(:reuse) { false } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :filesystem, + description: /type 'FAT' is not suitable for '\/'/ + ) + end + end + + context "and reusing the filesystem" do + let(:reuse) { true } + + it "does not include an issue" do + expect(subject.issues.size).to eq(0) + end + end + end + + context "with valid type" do + let(:filesystem) do + { + path: "/", + type: "btrfs" + } + end + + it "does not include an issue" do + expect(subject.issues.size).to eq(0) + end + end + + context "without a filesystem type" do + let(:filesystem) do + { + path: "/", + reuseIfPossible: reuse + } + end + + context "and without reusing the filesystem" do + let(:reuse) { false } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :filesystem, + description: /Missing file system type for '\/'/ + ) + end + end + + context "and reusing the filesystem" do + let(:reuse) { true } + + it "does not include an issue" do + expect(subject.issues.size).to eq(0) + end + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/logical_volume_test.rb b/service/test/agama/storage/config_checkers/logical_volume_test.rb new file mode 100644 index 0000000000..1736da4bb5 --- /dev/null +++ b/service/test/agama/storage/config_checkers/logical_volume_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./examples" +require_relative "./context" +require "agama/storage/config_checkers/logical_volume" + +describe Agama::Storage::ConfigCheckers::LogicalVolume do + include_context "checker" + + subject { described_class.new(lv_config, config, product_config) } + + let(:config_json) do + { + volumeGroups: [ + { + logicalVolumes: [ + { + filesystem: filesystem, + encryption: encryption, + usedPool: pool + }, + { + alias: "pool", + pool: true + } + ] + } + ] + } + end + + let(:filesystem) { nil } + let(:encryption) { nil } + let(:pool) { nil } + + let(:lv_config) { config.volume_groups.first.logical_volumes.first } + + describe "#issues" do + include_examples "filesystem issues" + include_examples "encryption issues" + + context "if the logical volume has an unknown pool" do + let(:pool) { "unknown" } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_such_alias, + description: /no LVM thin pool/ + ) + end + end + + context "if the logical volume has a known pool" do + let(:pool) { "pool" } + + it "does not include an issue" do + issues = subject.issues + expect(issues).to_not include an_object_having_attributes( + error?: true, + kind: :no_such_alias, + description: /no LVM thin pool/ + ) + end + end + + context "if the logical volume is valid" do + let(:filesystem) { { path: "/" } } + + before { solve_config } + + it "does not report issues" do + expect(subject.issues).to eq([]) + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/md_raid_test.rb b/service/test/agama/storage/config_checkers/md_raid_test.rb new file mode 100644 index 0000000000..4173bf2b5e --- /dev/null +++ b/service/test/agama/storage/config_checkers/md_raid_test.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./examples" +require_relative "./context" +require "agama/storage/config_checkers/md_raid" + +describe Agama::Storage::ConfigCheckers::MdRaid do + include_context "checker" + + subject { described_class.new(md_config, config, product_config) } + + let(:config_json) do + { + drives: [ + { alias: "disk1" } + ], + mdRaids: [ + { + alias: device_alias, + search: search, + level: level, + filesystem: filesystem, + encryption: encryption, + partitions: partitions, + devices: devices + } + ], + volumeGroups: volume_groups + } + end + + let(:device_alias) { nil } + let(:search) { nil } + let(:level) { nil } + let(:filesystem) { nil } + let(:encryption) { nil } + let(:partitions) { nil } + let(:devices) { nil } + let(:volume_groups) { nil } + + let(:md_config) { config.md_raids.first } + + describe "#issues" do + include_examples "alias issues" + include_examples "search issues" + include_examples "filesystem issues" + include_examples "encryption issues" + include_examples "partitions issues" + + context "if the MD RAID has no level" do + let(:level) { nil } + + before { solve_config } + + context "and the device is going to be created" do + let(:search) { nil } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :md_raid, + description: /MD RAID without level/ + ) + end + end + + context "and the device is not going to be created" do + let(:search) do + { + condition: { name: "/dev/md1" }, + ifNotFound: "error" + } + end + + it "does not include the issue" do + issues = subject.issues + expect(issues).to_not include an_object_having_attributes(kind: :md_raid) + end + end + end + + context "if the MD RAID has not enough member devices" do + let(:level) { "raid0" } + let(:devices) { ["disk1", "disk1"] } + + before { solve_config } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :md_raid, + description: "At least 2 devices are required for raid0" + ) + end + end + + context "if the MD RAID has an unknown member device" do + let(:devices) { ["disk1", "disk2"] } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_such_alias, + description: /no MD RAID member device with alias 'disk2'/ + ) + end + end + + context "if the MD RAID is valid" do + let(:config_json) do + { + drives: [ + { alias: "md-disk" }, + { alias: "md-disk" } + ], + mdRaids: [ + { + alias: "md", + level: "raid0", + devices: ["md-disk"] + } + ], + volumeGroups: [ + { + name: "vg", + physicalVolumes: ["md"] + } + ] + } + end + + before { solve_config } + + it "does not report issues" do + expect(subject.issues).to eq([]) + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/partition_test.rb b/service/test/agama/storage/config_checkers/partition_test.rb new file mode 100644 index 0000000000..1e77cf34aa --- /dev/null +++ b/service/test/agama/storage/config_checkers/partition_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./examples" +require_relative "./context" +require "agama/storage/config_checkers/partition" + +describe Agama::Storage::ConfigCheckers::Partition do + include_context "checker" + + subject { described_class.new(partition_config, config, product_config) } + + let(:config_json) do + { + drives: [ + { + partitions: [ + { + alias: device_alias, + search: search, + filesystem: filesystem, + encryption: encryption + } + ] + } + ], + volumeGroups: volume_groups + } + end + + let(:device_alias) { nil } + let(:search) { nil } + let(:filesystem) { nil } + let(:encryption) { nil } + let(:volume_groups) { nil } + + let(:partition_config) { config.drives.first.partitions.first } + + describe "#issues" do + include_examples "alias issues" + include_examples "search issues" + include_examples "filesystem issues" + include_examples "encryption issues" + + context "if the partition is valid" do + let(:filesystem) { { path: "/" } } + + before { solve_config } + + it "does not report issues" do + expect(subject.issues).to eq([]) + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/search_test.rb b/service/test/agama/storage/config_checkers/search_test.rb new file mode 100644 index 0000000000..25e5182d4f --- /dev/null +++ b/service/test/agama/storage/config_checkers/search_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/storage/config_checkers/search" +require "y2storage/disk" + +describe Agama::Storage::ConfigCheckers::Search do + subject { described_class.new(config) } + + let(:config) { Agama::Storage::Configs::Drive.new } + + describe "#issues" do + context "if the device is not found" do + before do + config.search.solve + end + + context "and the device can be skipped" do + before do + config.search.if_not_found = :skip + end + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + + context "and the device cannot be skipped" do + before do + config.search.if_not_found = :error + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :search, + description: "Mandatory drive not found" + ) + end + end + end + + context "if the device is found" do + before do + config.search.solve(disk) + end + + let(:disk) { instance_double(Y2Storage::Disk) } + + it "does not include an issue" do + expect(subject.issues.size).to eq(0) + end + end + end +end diff --git a/service/test/agama/storage/config_checkers/volume_group_test.rb b/service/test/agama/storage/config_checkers/volume_group_test.rb new file mode 100644 index 0000000000..90d929637e --- /dev/null +++ b/service/test/agama/storage/config_checkers/volume_group_test.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "./context" +require "agama/storage/config_checkers/volume_group" + +describe Agama::Storage::ConfigCheckers::VolumeGroup do + include_context "checker" + + subject { described_class.new(vg_config, config, product_config) } + + let(:config_json) do + { + drives: [ + { alias: "first-disk" } + ], + volumeGroups: [ + { + name: name, + physicalVolumes: physical_volumes + } + ] + } + end + + let(:name) { nil } + let(:physical_volumes) { nil } + + let(:vg_config) { config.volume_groups.first } + + describe "#issues" do + context "if the volume group has no name" do + let(:name) { nil } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + description: /without name/ + ) + end + end + + context "if the volume group has an unknown physical volume" do + let(:physical_volumes) { ["first-disk", "pv1"] } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_such_alias, + description: /no LVM physical volume with alias 'pv1'/ + ) + end + end + + context "if the volume group has an unknown target device for physical volumes" do + let(:physical_volumes) do + [ + { + generate: { + targetDevices: ["first-disk", "second-disk"] + } + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :no_such_alias, + description: /no target device for LVM physical volumes with alias 'second-disk'/ + ) + end + end + + context "if the volume group has encryption for physical volumes" do + let(:physical_volumes) do + [ + { + generate: { + targetDevices: ["first-disk"], + encryption: encryption + } + } + ] + end + + context "without password" do + let(:encryption) do + { luks1: {} } + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /No passphrase/ + ) + end + end + + context "with unavailable method" do + let(:encryption) do + { + luks2: { + password: "12345" + } + } + end + + before do + allow_any_instance_of(Y2Storage::EncryptionMethod::Luks2) + .to(receive(:available?)) + .and_return(false) + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /'Regular LUKS2' is not available/ + ) + end + end + + context "with invalid method" do + let(:encryption) { "random_swap" } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :encryption, + description: /'Encryption with Volatile Random Key' is not a suitable method/ + ) + end + end + + context "with a valid encryption" do + let(:encryption) do + { + luks1: { + password: "12345" + } + } + end + + it "does not include an encryption issue" do + issues = subject.issues + expect(issues).to_not include an_object_having_attributes(kind: :encryption) + end + end + end + + context "if the volume group has several target devices for physical volumes" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + alias: "vda", + search: "/dev/vda" + }, + { + alias: "vdb", + search: "/dev/vdb" + } + ], + mdRaids: [ + { + alias: "md1", + search: { + condition: { name: "/dev/md1" }, + ifNotFound: "error" + } + }, + { + alias: "md2", + search: { + condition: { name: "/dev/md2" }, + ifNotFound: "create" + } + }, + { alias: "md3" } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [ + { generate: target_devices } + ] + } + ] + } + end + + before { solve_config } + + context "and mixes new and reused devices" do + let(:target_devices) { ["vda", "md2"] } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :incompatible_pv_targets, + description: /'system' is mixing reused devices and new devices/ + ) + end + end + + context "and all the devices are new" do + let(:target_devices) { ["md2", "md3"] } + + it "does not include an incompatible targets issue" do + issues = subject.issues + expect(issues).to_not include an_object_having_attributes(kind: :incompatible_pv_targets) + end + end + + context "and all the devices are reused" do + let(:target_devices) { ["vda", "vdb", "md1"] } + + it "does not include an incompatible targets issue" do + issues = subject.issues + expect(issues).to_not include an_object_having_attributes(kind: :incompatible_pv_targets) + end + end + end + + context "if the volume group is valid" do + let(:name) { "vg0" } + + let(:physical_volumes) { ["first-disk"] } + + before { solve_config } + + it "does not report issues" do + expect(subject.issues).to eq([]) + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/config_test.rb b/service/test/agama/storage/config_conversions/from_json_conversions/config_test.rb new file mode 100644 index 0000000000..082c0c0f81 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/config_test.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions/from_json_conversions/config" +require "agama/storage/config" +require "agama/storage/configs/boot" +require "agama/storage/configs/boot_device" +require "agama/storage/configs/drive" +require "agama/storage/configs/md_raid" +require "agama/storage/configs/volume_group" + +describe Agama::Storage::ConfigConversions::FromJSONConversions::Config do + subject do + described_class.new(config_json) + end + + describe "#convert" do + let(:config_json) do + { + boot: boot, + drives: drives, + volumeGroups: volume_groups, + mdRaids: md_raids + } + end + + let(:boot) { nil } + let(:drives) { nil } + let(:volume_groups) { nil } + let(:md_raids) { nil } + + it "returns a storage config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Config) + end + + context "if 'boot' is not specified" do + let(:boot) { nil } + + it "sets #boot to the expected value" do + config = subject.convert + expect(config.boot).to be_a(Agama::Storage::Configs::Boot) + expect(config.boot.configure).to eq(true) + expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) + expect(config.boot.device.default).to eq(true) + expect(config.boot.device.device_alias).to be_nil + end + end + + context "if 'drives' is not specified" do + let(:drives) { nil } + + it "sets #drives to the expected value" do + config = subject.convert + expect(config.drives).to be_empty + end + end + + context "if 'volumeGroups' is not specified" do + let(:volume_groups) { nil } + + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.drives).to be_empty + expect(config.volume_groups).to be_empty + end + end + + context "if 'boot' is specified" do + let(:boot) do + { + configure: true, + device: device + } + end + + let(:device) { "sdb" } + + it "sets #boot to the expected value" do + config = subject.convert + expect(config.boot).to be_a(Agama::Storage::Configs::Boot) + expect(config.boot.configure).to eq(true) + expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) + expect(config.boot.device.default).to eq(false) + expect(config.boot.device.device_alias).to eq("sdb") + end + + context "if boot does not specify 'device'" do + let(:device) { nil } + + it "sets #boot to the expected value" do + config = subject.convert + expect(config.boot).to be_a(Agama::Storage::Configs::Boot) + expect(config.boot.configure).to eq(true) + expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) + expect(config.boot.device.default).to eq(true) + expect(config.boot.device.device_alias).to be_nil + end + end + end + + context "if 'drives' is specified" do + 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 + let(:drives) do + [ + { alias: "first-disk" }, + { alias: "second-disk" } + ] + end + + 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.alias).to eq("first-disk") + expect(drive1.partitions).to eq([]) + expect(drive2.alias).to eq("second-disk") + expect(drive2.partitions).to eq([]) + end + end + end + + context "if 'volumeGroups' is specified" do + context "with an empty list" do + let(:volume_groups) { [] } + + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups).to eq([]) + end + end + + context "with a list of volume groups" do + let(:volume_groups) do + [ + { name: "vg1" }, + { name: "vg2" } + ] + end + + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups.size).to eq(2) + expect(config.volume_groups).to all(be_a(Agama::Storage::Configs::VolumeGroup)) + + volume_group1, volume_group2 = config.volume_groups + expect(volume_group1.name).to eq("vg1") + expect(volume_group1.logical_volumes).to eq([]) + expect(volume_group2.name).to eq("vg2") + expect(volume_group2.logical_volumes).to eq([]) + end + end + end + + context "if 'mdRaids' is specified" do + context "with an empty list" do + let(:md_raids) { [] } + + it "sets #md_raids to the expected value" do + config = subject.convert + expect(config.md_raids).to eq([]) + end + end + + context "with a list of MD RAIDs" do + let(:md_raids) do + [ + { name: "system" }, + { name: "home" } + ] + end + + it "sets #md_raids to the expected value" do + config = subject.convert + expect(config.md_raids.size).to eq(2) + expect(config.md_raids).to all(be_a(Agama::Storage::Configs::MdRaid)) + + md1, md2 = config.md_raids + expect(md1.name).to eq("system") + expect(md1.partitions).to eq([]) + expect(md2.name).to eq("home") + expect(md2.partitions).to eq([]) + end + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/drive_test.rb b/service/test/agama/storage/config_conversions/from_json_conversions/drive_test.rb new file mode 100644 index 0000000000..a138906a75 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/drive_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/drive" +require "agama/storage/configs/drive" +require "agama/storage/configs/search" + +describe Agama::Storage::ConfigConversions::FromJSONConversions::Drive do + subject do + described_class.new(drive_json) + end + + describe "#convert" do + let(:drive_json) do + { + search: search, + alias: device_alias, + encryption: encryption, + filesystem: filesystem, + ptableType: ptable_type, + partitions: partitions + } + end + + let(:search) { nil } + let(:device_alias) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + let(:ptable_type) { nil } + let(:partitions) { nil } + + it "returns a drive config" do + drive = subject.convert + expect(drive).to be_a(Agama::Storage::Configs::Drive) + end + + context "if 'search' is not specified" do + let(:search) { nil } + + it "sets #search to the expected value" do + drive = 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 + + include_examples "without alias" + include_examples "without encryption" + include_examples "without filesystem" + include_examples "without ptableType" + include_examples "without partitions" + include_examples "with search" + include_examples "with alias" + include_examples "with encryption" + include_examples "with filesystem" + include_examples "with ptableType" + include_examples "with partitions" + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/examples.rb b/service/test/agama/storage/config_conversions/from_json_conversions/examples.rb new file mode 100644 index 0000000000..fb039ddfa1 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/examples.rb @@ -0,0 +1,611 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/configs/encryption" +require "agama/storage/configs/filesystem" +require "agama/storage/configs/partition" +require "agama/storage/configs/search" +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 search" do + context "if 'search' is not specified" do + let(:search) { nil } + + it "does not set #search" do + config = subject.convert + expect(config.search).to be_nil + end + end +end + +shared_examples "without alias" do + context "if 'alias' is not specified" do + let(:alias) { nil } + + it "does not set #alias" do + config = subject.convert + expect(config.alias).to be_nil + end + end +end + +shared_examples "without encryption" do + context "if 'encryption' is not specified" do + let(:encryption) { nil } + + it "does not set #encryption" do + config = subject.convert + expect(config.encryption).to be_nil + end + end +end + +shared_examples "without filesystem" do + context "if 'filesystem' is not specified" do + let(:filesystem) { nil } + + it "does not set #filesystem" do + config = subject.convert + expect(config.filesystem).to be_nil + end + end +end + +shared_examples "without ptableType" do + context "if 'ptableType' is not specified" do + let(:ptable_type) { nil } + + it "does not set #ptable_type" do + config = subject.convert + expect(config.ptable_type).to be_nil + end + end +end + +shared_examples "without partitions" do + context "if 'partitions' is not specified" do + let(:partitions) { nil } + + it "sets #partitions to the expected value" do + config = subject.convert + expect(config.partitions).to eq([]) + end + end +end + +shared_examples "without size" do + context "if 'size' is not specified" do + let(:size) { nil } + + it "sets #size to default size" do + config = 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 +end + +shared_examples "without delete" do + context "if 'delete' is not specified" do + let(:delete) { nil } + + it "sets #delete to false" do + config = subject.convert + expect(config.delete?).to eq(false) + end + end +end + +shared_examples "without deleteIfNeeded" do + context "if 'deleteIfNeeded' is not specified" do + let(:delete_if_needed) { nil } + + it "sets #delete_if_needed to false" do + config = subject.convert + expect(config.delete_if_needed?).to eq(false) + end + end +end + +shared_examples "with search" do + context "if 'search' is specified" do + context "with a device name" do + let(:search) { "/dev/vda1" } + + it "sets #search to the expected value" do + config = subject.convert + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to eq("/dev/vda1") + expect(config.search.if_not_found).to eq(:error) + end + end + + context "with an asterisk" do + let(:search) { "*" } + + it "sets #search to the expected value" do + config = subject.convert + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to be_nil + expect(config.search.if_not_found).to eq(:skip) + expect(config.search.max).to be_nil + end + end + + context "with a search section" do + let(:search) do + { + condition: { name: "/dev/vda1" }, + ifNotFound: "skip" + } + end + + it "sets #search to the expected value" do + config = subject.convert + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to eq("/dev/vda1") + expect(config.search.if_not_found).to eq(:skip) + expect(config.search.max).to be_nil + end + end + + context "with a search section including a max" do + let(:search) do + { + ifNotFound: "error", + max: 3 + } + end + + it "sets #search to the expected value" do + config = subject.convert + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to be_nil + expect(config.search.if_not_found).to eq(:error) + expect(config.search.max).to eq 3 + end + end + end +end + +shared_examples "with alias" do + context "if 'alias' is specified" do + let(:device_alias) { "test" } + + it "sets #alias to the expected value" do + config = subject.convert + expect(config.alias).to eq("test") + end + end +end + +shared_examples "with encryption" do + context "if 'encryption' is specified" do + let(:encryption) do + { + luks2: { + password: "12345", + keySize: 256, + pbkdFunction: "argon2i", + cipher: "twofish", + label: "test" + } + } + end + + it "sets #encryption to the expected value" do + config = subject.convert + encryption = config.encryption + expect(encryption).to be_a(Agama::Storage::Configs::Encryption) + expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) + expect(encryption.password).to eq("12345") + expect(encryption.key_size).to eq(256) + expect(encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2I) + expect(encryption.cipher).to eq("twofish") + expect(encryption.label).to eq("test") + end + + context "if 'encryption' only specifies 'password'" do + let(:encryption) do + { + luks2: { + password: "12345" + } + } + end + + it "sets #encryption to the expected value" do + config = subject.convert + encryption = config.encryption + expect(encryption).to be_a(Agama::Storage::Configs::Encryption) + expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) + expect(encryption.password).to eq("12345") + expect(encryption.key_size).to be_nil + expect(encryption.pbkd_function).to be_nil + expect(encryption.cipher).to be_nil + expect(encryption.label).to be_nil + end + end + + context "if 'encryption' is 'pervasiveLuks2'" do + let(:encryption) do + { + pervasiveLuks2: { + password: "12345" + } + } + end + + it "sets #encryption to the expected value" do + config = subject.convert + encryption = config.encryption + expect(encryption).to be_a(Agama::Storage::Configs::Encryption) + expect(encryption.method).to eq(Y2Storage::EncryptionMethod::PERVASIVE_LUKS2) + expect(encryption.password).to eq("12345") + expect(encryption.key_size).to be_nil + expect(encryption.pbkd_function).to be_nil + expect(encryption.cipher).to be_nil + expect(encryption.label).to be_nil + end + end + + context "if 'encryption' is 'tmpFde'" do + let(:encryption) do + { + tpmFde: { + password: "12345" + } + } + end + + it "sets #encryption to the expected value" do + config = subject.convert + encryption = config.encryption + expect(encryption).to be_a(Agama::Storage::Configs::Encryption) + expect(encryption.method).to eq(Y2Storage::EncryptionMethod::TPM_FDE) + expect(encryption.password).to eq("12345") + expect(encryption.key_size).to be_nil + expect(encryption.pbkd_function).to be_nil + expect(encryption.cipher).to be_nil + expect(encryption.label).to be_nil + end + end + end +end + +shared_examples "with filesystem" do + context "if 'filesystem' is specified" do + let(:filesystem) do + { + reuseIfPossible: true, + type: "xfs", + label: "test", + path: "/test", + mountBy: "device", + mkfsOptions: ["version=2"], + mountOptions: ["rw"] + } + end + + it "sets #filesystem to the expected value" do + config = subject.convert + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(true) + 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 eq("test") + expect(filesystem.path).to eq("/test") + expect(filesystem.mount_by).to eq(Y2Storage::Filesystems::MountByType::DEVICE) + expect(filesystem.mkfs_options).to contain_exactly("version=2") + expect(filesystem.mount_options).to contain_exactly("rw") + end + + context "if 'filesystem' specifies a 'type' with a btrfs section" do + let(:filesystem) do + { + type: { + btrfs: { + snapshots: true + } + } + } + end + + it "sets #filesystem to the expected value" do + config = 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.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 eq([]) + expect(filesystem.mount_options).to eq([]) + end + end + + context "if 'filesystem' is an empty section" do + let(:filesystem) { {} } + + it "sets #filesystem to the expected value" do + config = 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 be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to eq([]) + expect(filesystem.mount_options).to eq([]) + end + end + end +end + +shared_examples "with ptableType" do + context "if 'ptableType' is specified" do + let(:ptable_type) { "gpt" } + + it "sets #ptable_type to the expected value" do + config = subject.convert + expect(config.ptable_type).to eq(Y2Storage::PartitionTables::Type::GPT) + end + end +end + +shared_examples "with size" do + context "if 'size' is specified" do + context "if 'size' is a string" do + let(:size) { "10 GiB" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(10.GiB) + expect(config.size.max).to eq(10.GiB) + end + end + + context "if 'size' is a number" do + let(:size) { 3221225472 } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(3.GiB) + expect(config.size.max).to eq(3.GiB) + end + end + + shared_examples "min size" do + context "and the value is a string" do + let(:min_size) { "10 GiB" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(10.GiB) + expect(config.size.max).to eq(Y2Storage::DiskSize.unlimited) + end + end + + context "and the value is a number" do + let(:min_size) { 3221225472 } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(3.GiB) + expect(config.size.max).to eq(Y2Storage::DiskSize.unlimited) + end + end + + context "and the value is 'current'" do + let(:min_size) { "current" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to be_nil + expect(config.size.max).to eq(Y2Storage::DiskSize.unlimited) + end + end + end + + shared_examples "min and max sizes" do + context "and the values are strings" do + let(:min_size) { "10 GiB" } + let(:max_size) { "20 GiB" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(10.GiB) + expect(config.size.max).to eq(20.GiB) + end + end + + context "and the values are numbers" do + let(:min_size) { 3221225472 } + let(:max_size) { 10737418240 } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(3.GiB) + expect(config.size.max).to eq(10.GiB) + end + end + + context "and the values mixes string and number" do + let(:min_size) { 3221225472 } + let(:max_size) { "10 Gib" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(3.GiB) + expect(config.size.max).to eq(10.GiB) + end + end + + context "and the min value is 'current'" do + let(:min_size) { "current" } + let(:max_size) { "10 GiB" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to be_nil + expect(config.size.max).to eq(10.GiB) + end + end + + context "and the max value is 'current'" do + let(:min_size) { "10 GiB" } + let(:max_size) { "current" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(10.GiB) + expect(config.size.max).to be_nil + end + end + + context "and both values are 'current'" do + let(:min_size) { "current" } + let(:max_size) { "current" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size.default?).to eq(false) + expect(config.size.min).to be_nil + expect(config.size.max).to be_nil + end + end + end + + context "if 'size' is an array" do + context "and only contains one value" do + let(:size) { [min_size] } + include_examples "min size" + end + + context "and contains two values" do + let(:size) { [min_size, max_size] } + include_examples "min and max sizes" + end + end + + context "if 'size' is a hash" do + context "and only specifies 'min'" do + let(:size) { { min: min_size } } + include_examples "min size" + end + + context "and specifies 'min' and 'max'" do + let(:size) do + { + min: min_size, + max: max_size + } + end + + include_examples "min and max sizes" + end + end + end +end + +shared_examples "with delete" do + context "if 'delete' is specified" do + let(:delete) { true } + + it "sets #delete to true" do + config = subject.convert + expect(config.delete?).to eq(true) + end + end +end + +shared_examples "with deleteIfNeeded" do + context "if 'delete' is specified" do + let(:delete_if_needed) { true } + + it "sets #delete_if_needed to true" do + config = subject.convert + expect(config.delete_if_needed?).to eq(true) + end + end +end + +shared_examples "with partitions" do + context "if 'partitions' is specified" do + context "with an empty list" do + let(:partitions) { [] } + + it "sets #partitions to empty" do + config = subject.convert + expect(config.partitions).to eq([]) + end + end + + context "with a list of partitions" do + let(:partitions) do + [ + { + filesystem: { path: "/" } + }, + { + filesystem: { path: "/test" } + } + ] + end + + it "sets #partitions to the expected value" do + config = 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 + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/logical_volume_test.rb b/service/test/agama/storage/config_conversions/from_json_conversions/logical_volume_test.rb new file mode 100644 index 0000000000..3a5f28d779 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/logical_volume_test.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/logical_volume" +require "agama/storage/configs/logical_volume" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::FromJSONConversions::LogicalVolume do + subject do + described_class.new(logical_volume_json) + end + + describe "#convert" do + let(:logical_volume_json) do + { + name: name, + stripes: stripes, + stripeSize: stripe_size, + pool: pool, + usedPool: used_pool, + alias: device_alias, + size: size, + encryption: encryption, + filesystem: filesystem + } + end + + let(:name) { nil } + let(:stripes) { nil } + let(:stripe_size) { nil } + let(:pool) { nil } + let(:used_pool) { nil } + let(:device_alias) { nil } + let(:size) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + + it "returns a logical volume config" do + logical_volum = subject.convert + expect(logical_volum).to be_a(Agama::Storage::Configs::LogicalVolume) + end + + context "if 'name' is not specified" do + let(:name) { nil } + + it "does not set #name" do + logical_volume = subject.convert + expect(logical_volume.name).to be_nil + end + end + + context "if 'stripes' is not specified" do + let(:stripes) { nil } + + it "does not set #stripes" do + logical_volume = subject.convert + expect(logical_volume.stripes).to be_nil + end + end + + context "if 'stripeSize' is not specified" do + let(:stripe_size) { nil } + + it "does not set #stripe_size" do + logical_volume = subject.convert + expect(logical_volume.stripe_size).to be_nil + end + end + + context "if 'pool' is not specified" do + let(:pool) { nil } + + it "sets #pool? to false" do + logical_volume = subject.convert + expect(logical_volume.pool?).to eq(false) + end + end + + context "if 'usedPool' is not specified" do + let(:used_pool) { nil } + + it "does not set #used_pool" do + logical_volume = subject.convert + expect(logical_volume.used_pool).to be_nil + end + end + + include_examples "without alias" + include_examples "without size" + include_examples "without encryption" + include_examples "without filesystem" + + context "if 'name' is specified" do + let(:name) { "test" } + + it "sets #name to the expected value" do + logical_volume = subject.convert + expect(logical_volume.name).to eq("test") + end + end + + context "if 'stripes' is specified" do + let(:stripes) { 10 } + + it "sets #stripes to the expected value" do + logical_volume = subject.convert + expect(logical_volume.stripes).to eq(10) + end + end + + context "if 'stripeSize' is specified" do + context "if 'stripeSize' is a string" do + let(:stripe_size) { "4 KiB" } + + it "sets #stripe_size to the expected value" do + logical_volume = subject.convert + expect(logical_volume.stripe_size).to eq(4.KiB) + end + end + + context "if 'stripeSize' is a number" do + let(:stripe_size) { 4096 } + + it "sets #stripe_size to the expected value" do + logical_volume = subject.convert + expect(logical_volume.stripe_size).to eq(4.KiB) + end + end + end + + context "if 'pool' is specified" do + let(:pool) { true } + + it "sets #pool? to the expected value" do + logical_volume = subject.convert + expect(logical_volume.pool?).to eq(true) + end + end + + context "if 'usedPool' is specified" do + let(:used_pool) { "pool" } + + it "sets #used_pool to the expected value" do + logical_volume = subject.convert + expect(logical_volume.used_pool).to eq("pool") + end + end + + include_examples "with alias" + include_examples "with size" + include_examples "with encryption" + include_examples "with filesystem" + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/md_raid_test.rb b/service/test/agama/storage/config_conversions/from_json_conversions/md_raid_test.rb new file mode 100644 index 0000000000..e4752e4819 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/md_raid_test.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/md_raid" +require "agama/storage/configs/md_raid" +require "y2storage/md_level" +require "y2storage/md_parity" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::FromJSONConversions::MdRaid do + subject do + described_class.new(md_raid_json) + end + + describe "#convert" do + let(:md_raid_json) do + { + alias: device_alias, + name: name, + level: level, + parity: parity, + chunkSize: chunk_size, + devices: devices, + encryption: encryption, + filesystem: filesystem, + ptableType: ptable_type, + partitions: partitions + } + end + + let(:device_alias) { nil } + let(:name) { nil } + let(:level) { nil } + let(:parity) { nil } + let(:chunk_size) { nil } + let(:devices) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + let(:ptable_type) { nil } + let(:partitions) { nil } + + it "returns a MD RAID config" do + md_raid = subject.convert + expect(md_raid).to be_a(Agama::Storage::Configs::MdRaid) + end + + context "if 'name' is not specified" do + let(:name) { nil } + + it "does not set #name" do + md_raid = subject.convert + expect(md_raid.name).to be_nil + end + end + + context "if 'level' is not specified" do + let(:level) { nil } + + it "does not set #level" do + md_raid = subject.convert + expect(md_raid.level).to be_nil + end + end + + context "if 'parity' is not specified" do + let(:parity) { nil } + + it "does not set #parity" do + md_raid = subject.convert + expect(md_raid.parity).to be_nil + end + end + + context "if 'chunkSize' is not specified" do + let(:chunk_size) { nil } + + it "does not set #chunk_size" do + md_raid = subject.convert + expect(md_raid.chunk_size).to be_nil + end + end + + context "if 'devices' is not specified" do + let(:devices) { nil } + + it "sets #devices to the expected value" do + md_raid = subject.convert + expect(md_raid.devices).to eq([]) + end + end + + include_examples "without alias" + include_examples "without encryption" + include_examples "without filesystem" + include_examples "without ptableType" + include_examples "without partitions" + + context "if 'name' is specified" do + let(:name) { "test" } + + it "sets #name to the expected value" do + md_raid = subject.convert + expect(md_raid.name).to eq("test") + end + end + + context "if 'level' is specified" do + let(:level) { "raid1" } + + it "sets #level to the expected value" do + md_raid = subject.convert + expect(md_raid.level).to eq(Y2Storage::MdLevel::RAID1) + end + end + + context "if 'parity' is specified" do + let(:parity) { "left_asymmetric" } + + it "sets #parity to the expected value" do + md_raid = subject.convert + expect(md_raid.parity).to eq(Y2Storage::MdParity::LEFT_ASYMMETRIC) + end + end + + context "if 'chunkSize' is specified" do + context "if 'chunkSize' is a string" do + let(:chunk_size) { "4 KiB" } + + it "sets #chunk_size to the expected value" do + md_raid = subject.convert + expect(md_raid.chunk_size).to eq(4.KiB) + end + end + + context "if 'chunkSize' is a number" do + let(:chunk_size) { 4096 } + + it "sets #chunk_size to the expected value" do + chunk_size = subject.convert + expect(chunk_size.chunk_size).to eq(4.KiB) + end + end + end + + context "if 'devices' is specified" do + context "with an empty list" do + let(:devices) { [] } + + it "sets #devices to the expected value" do + md_raid = subject.convert + expect(md_raid.devices).to eq([]) + end + end + + context "with a list of aliases" do + let(:devices) { ["system", "home"] } + + it "sets #devices to the expected value" do + md_raid = subject.convert + expect(md_raid.devices).to contain_exactly("system", "home") + end + end + end + + include_examples "with alias" + include_examples "with encryption" + include_examples "with filesystem" + include_examples "with ptableType" + include_examples "with partitions" + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/partition_test.rb b/service/test/agama/storage/config_conversions/from_json_conversions/partition_test.rb new file mode 100644 index 0000000000..33cdef2c61 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/partition_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/partition" +require "agama/storage/configs/partition" +require "y2storage/partition_id" + +describe Agama::Storage::ConfigConversions::FromJSONConversions::Partition do + subject do + described_class.new(partition_json) + end + + describe "#convert" do + let(:partition_json) do + { + id: id, + search: search, + alias: device_alias, + size: size, + encryption: encryption, + filesystem: filesystem, + delete: delete, + deleteIfNeeded: delete_if_needed + } + end + + let(:id) { nil } + let(:search) { nil } + let(:device_alias) { nil } + let(:size) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + let(:delete) { nil } + let(:delete_if_needed) { nil } + + it "returns a partition config" do + partition = subject.convert + expect(partition).to be_a(Agama::Storage::Configs::Partition) + end + + context "if 'id' is not specified" do + let(:id) { nil } + + it "does not set #id" do + partition = subject.convert + expect(partition.id).to be_nil + end + end + + include_examples "without search" + include_examples "without alias" + include_examples "without size" + include_examples "without encryption" + include_examples "without filesystem" + include_examples "without delete" + include_examples "without deleteIfNeeded" + + context "if 'id' is specified" do + let(:id) { "esp" } + + it "sets #id to the expected value" do + partition = subject.convert + expect(partition.id).to eq(Y2Storage::PartitionId::ESP) + end + end + + include_examples "with search" + include_examples "with alias" + include_examples "with size" + include_examples "with encryption" + include_examples "with filesystem" + include_examples "with delete" + include_examples "with deleteIfNeeded" + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/volume_group_test.rb b/service/test/agama/storage/config_conversions/from_json_conversions/volume_group_test.rb new file mode 100644 index 0000000000..1faa3abacd --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/volume_group_test.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions/from_json_conversions/volume_group" +require "agama/storage/configs/encryption" +require "agama/storage/configs/logical_volume" +require "agama/storage/configs/volume_group" +require "y2storage/encryption_method" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::FromJSONConversions::VolumeGroup do + subject do + described_class.new(volume_group_json) + end + + describe "#convert" do + let(:volume_group_json) do + { + name: name, + extentSize: extent_size, + physicalVolumes: physical_volumes, + logicalVolumes: logical_volumes + } + end + + let(:name) { nil } + let(:extent_size) { nil } + let(:physical_volumes) { nil } + let(:logical_volumes) { nil } + + it "returns a volume group config" do + volume_group = subject.convert + expect(volume_group).to be_a(Agama::Storage::Configs::VolumeGroup) + end + + context "if 'name' is not specified" do + let(:name) { nil } + + it "does not set #name" do + volume_group = subject.convert + expect(volume_group.name).to be_nil + end + end + + context "if 'extentSize' is not specified" do + let(:extent_size) { nil } + + it "does not set #extent_size" do + volume_group = subject.convert + expect(volume_group.extent_size).to be_nil + end + end + + context "if 'physicalVolumes' is not specified" do + let(:physical_volumes) { nil } + + it "sets #physical_volumes to the expected vale" do + volume_group = subject.convert + expect(volume_group.physical_volumes).to eq([]) + end + end + + context "if 'logicalVolumes' is not specified" do + let(:logical_volumes) { nil } + + it "sets #logical_volumes to the expected vale" do + volume_group = subject.convert + expect(volume_group.logical_volumes).to eq([]) + end + end + + context "if 'name' is specified" do + let(:name) { "test" } + + it "sets #name to the expected value" do + volume_group = subject.convert + expect(volume_group.name).to eq("test") + end + end + + context "if 'extentSize' is specified" do + context "if 'extentSize' is a string" do + let(:extent_size) { "4 KiB" } + + it "sets #extent_size to the expected value" do + volume_group = subject.convert + expect(volume_group.extent_size).to eq(4.KiB) + end + end + + context "if 'extentSize' is a number" do + let(:extent_size) { 4096 } + + it "sets #extent_size to the expected value" do + volume_group = subject.convert + expect(volume_group.extent_size).to eq(4.KiB) + end + end + end + + context "if 'physicalVolumes' is specified" do + context "with an empty list" do + let(:physical_volumes) { [] } + + it "sets #physical_volumes to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes).to eq([]) + end + + it "sets #physical_volumes_devices to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes_devices).to eq([]) + end + + it "sets #physical_volumes_encryption to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes_encryption).to be_nil + end + end + + context "with a list of aliases" do + let(:physical_volumes) { ["pv1", "pv2"] } + + it "sets #physical_volumes to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes).to contain_exactly("pv1", "pv2") + end + + it "sets #physical_volumes_devices to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes_devices).to eq([]) + end + + it "sets #physical_volumes_encryption to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes_encryption).to be_nil + end + end + + context "with a list including a physical volume with 'generate' array" do + let(:physical_volumes) do + [ + "pv1", + { generate: ["disk1", "disk2"] }, + "pv2" + ] + end + + it "sets #physical_volumes to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes).to contain_exactly("pv1", "pv2") + end + + it "sets #physical_volumes_devices to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes_devices).to contain_exactly("disk1", "disk2") + end + + it "does not set #physical_volumes_encryption" do + volume_group = subject.convert + expect(volume_group.physical_volumes_encryption).to be_nil + end + end + + context "with a list including a physical volume with 'generate' section" do + let(:physical_volumes) do + [ + "pv1", + { + generate: { + targetDevices: target_devices, + encryption: encryption + } + }, + "pv2" + ] + end + + let(:target_devices) { nil } + + let(:encryption) { nil } + + it "sets #physical_volumes to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes).to contain_exactly("pv1", "pv2") + end + + context "if the physical volume does not specify 'targetDevices'" do + let(:target_devices) { nil } + + it "sets #physical_volumes_devices to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes_devices).to eq([]) + end + end + + context "if the physical volume does not specify 'encryption'" do + let(:target_devices) { nil } + + it "does not set #physical_volumes_encryption" do + volume_group = subject.convert + expect(volume_group.physical_volumes_encryption).to be_nil + end + end + + context "if the physical volume specifies 'targetDevices'" do + let(:target_devices) { ["disk1"] } + + it "sets #physical_volumes_devices to the expected value" do + volume_group = subject.convert + expect(volume_group.physical_volumes_devices).to contain_exactly("disk1") + end + end + + context "if the physical volume specifies 'encryption'" do + let(:encryption) do + { + luks1: { password: "12345" } + } + end + + it "sets #physical_volumes_encryption to the expected value" do + volume_group = subject.convert + encryption = volume_group.physical_volumes_encryption + expect(encryption).to be_a(Agama::Storage::Configs::Encryption) + expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) + expect(encryption.password).to eq("12345") + expect(encryption.pbkd_function).to be_nil + expect(encryption.label).to be_nil + expect(encryption.cipher).to be_nil + expect(encryption.key_size).to be_nil + end + end + end + end + + context "if 'logicalVolumes' is specified" do + context "with an empty list" do + let(:logical_volumes) { [] } + + it "sets #logical_volumes to empty" do + volume_group = subject.convert + expect(volume_group.logical_volumes).to eq([]) + end + end + + context "with a list of logical volumes" do + let(:logical_volumes) do + [ + { name: "root" }, + { name: "test" } + ] + end + + it "sets #logical_volumes to the expected value" do + volume_group = subject.convert + + lvs = volume_group.logical_volumes + expect(lvs.size).to eq(2) + + lv1, lv2 = lvs + expect(lv1).to be_a(Agama::Storage::Configs::LogicalVolume) + expect(lv1.name).to eq("root") + expect(lv2).to be_a(Agama::Storage::Configs::LogicalVolume) + expect(lv2.name).to eq("test") + end + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_json_test.rb b/service/test/agama/storage/config_conversions/from_json_test.rb index af2e7d64c1..174b97a0e2 100644 --- a/service/test/agama/storage/config_conversions/from_json_test.rb +++ b/service/test/agama/storage/config_conversions/from_json_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,1678 +20,52 @@ # 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 search" do |config_proc| - it "does not set #search" do - config = config_proc.call(subject.convert) - expect(config.search).to be_nil - end -end - -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 encryption" do |config_proc| - it "does not set #encryption" do - config = config_proc.call(subject.convert) - expect(config.encryption).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 search" do |config_proc| - context "with a device name" do - let(:search) { "/dev/vda1" } - - 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/vda1") - expect(config.search.if_not_found).to eq(:error) - end - end - - context "with an asterisk" do - let(:search) { "*" } - - 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 be_nil - expect(config.search.if_not_found).to eq(:skip) - expect(config.search.max).to be_nil - end - end - - context "with a search section" do - let(:search) do - { - condition: { name: "/dev/vda1" }, - ifNotFound: "skip" - } - end - - 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/vda1") - expect(config.search.if_not_found).to eq(:skip) - expect(config.search.max).to be_nil - end - end - - context "with a search section including a max" do - let(:search) do - { - ifNotFound: "error", - max: 3 - } - end - - 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 be_nil - expect(config.search.if_not_found).to eq(:error) - expect(config.search.max).to eq 3 - end - 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 encryption" do |config_proc| - let(:encryption) do - { - luks2: { - password: "12345", - keySize: 256, - pbkdFunction: "argon2i", - cipher: "twofish", - label: "test" - } - } - end - - it "sets #encryption to the expected value" do - config = config_proc.call(subject.convert) - encryption = config.encryption - expect(encryption).to be_a(Agama::Storage::Configs::Encryption) - expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(encryption.password).to eq("12345") - expect(encryption.key_size).to eq(256) - expect(encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2I) - expect(encryption.cipher).to eq("twofish") - expect(encryption.label).to eq("test") - end - - context "if 'encryption' only specifies 'password'" do - let(:encryption) do - { - luks2: { - password: "12345" - } - } - end - - it "sets #encryption to the expected value" do - config = config_proc.call(subject.convert) - encryption = config.encryption - expect(encryption).to be_a(Agama::Storage::Configs::Encryption) - expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(encryption.password).to eq("12345") - expect(encryption.key_size).to be_nil - expect(encryption.pbkd_function).to be_nil - expect(encryption.cipher).to be_nil - expect(encryption.label).to be_nil - end - end - - context "if 'encryption' is 'pervasiveLuks2'" do - let(:encryption) do - { - pervasiveLuks2: { - password: "12345" - } - } - end - - it "sets #encryption to the expected value" do - config = config_proc.call(subject.convert) - encryption = config.encryption - expect(encryption).to be_a(Agama::Storage::Configs::Encryption) - expect(encryption.method).to eq(Y2Storage::EncryptionMethod::PERVASIVE_LUKS2) - expect(encryption.password).to eq("12345") - expect(encryption.key_size).to be_nil - expect(encryption.pbkd_function).to be_nil - expect(encryption.cipher).to be_nil - expect(encryption.label).to be_nil - end - end - - context "if 'encryption' is 'tmpFde'" do - let(:encryption) do - { - tpmFde: { - password: "12345" - } - } - end - - it "sets #encryption to the expected value" do - config = config_proc.call(subject.convert) - encryption = config.encryption - expect(encryption).to be_a(Agama::Storage::Configs::Encryption) - expect(encryption.method).to eq(Y2Storage::EncryptionMethod::TPM_FDE) - expect(encryption.password).to eq("12345") - expect(encryption.key_size).to be_nil - expect(encryption.pbkd_function).to be_nil - expect(encryption.cipher).to be_nil - expect(encryption.label).to be_nil - end - end -end - -shared_examples "with filesystem" do |config_proc| - let(:filesystem) do - { - reuseIfPossible: true, - type: "xfs", - label: "test", - path: "/test", - mountBy: "device", - mkfsOptions: ["version=2"], - mountOptions: ["rw"] - } - 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(true) - 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 eq("test") - expect(filesystem.path).to eq("/test") - expect(filesystem.mount_by).to eq(Y2Storage::Filesystems::MountByType::DEVICE) - expect(filesystem.mkfs_options).to contain_exactly("version=2") - expect(filesystem.mount_options).to contain_exactly("rw") - end - - context "if 'filesystem' specifies a 'type' with a btrfs section" do - let(:filesystem) do - { - 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.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 eq([]) - expect(filesystem.mount_options).to eq([]) - end - end - - context "if 'filesystem' is an empty section" do - let(:filesystem) { {} } - - 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 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 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 'size' is a string" do - let(:size) { "10 GiB" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(10.GiB) - expect(config.size.max).to eq(10.GiB) - end - end - - context "if 'size' is a number" do - let(:size) { 3221225472 } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(3.GiB) - expect(config.size.max).to eq(3.GiB) - end - end - - shared_examples "min size" do - context "and the value is a string" do - let(:min_size) { "10 GiB" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(10.GiB) - expect(config.size.max).to eq(Y2Storage::DiskSize.unlimited) - end - end - - context "and the value is a number" do - let(:min_size) { 3221225472 } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(3.GiB) - expect(config.size.max).to eq(Y2Storage::DiskSize.unlimited) - end - end - - context "and the value is 'current'" do - let(:min_size) { "current" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to be_nil - expect(config.size.max).to eq(Y2Storage::DiskSize.unlimited) - end - end - end - - shared_examples "min and max sizes" do - context "and the values are strings" do - let(:min_size) { "10 GiB" } - let(:max_size) { "20 GiB" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(10.GiB) - expect(config.size.max).to eq(20.GiB) - end - end - - context "and the values are numbers" do - let(:min_size) { 3221225472 } - let(:max_size) { 10737418240 } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(3.GiB) - expect(config.size.max).to eq(10.GiB) - end - end - - context "and the values mixes string and number" do - let(:min_size) { 3221225472 } - let(:max_size) { "10 Gib" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(3.GiB) - expect(config.size.max).to eq(10.GiB) - end - end - - context "and the min value is 'current'" do - let(:min_size) { "current" } - let(:max_size) { "10 GiB" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to be_nil - expect(config.size.max).to eq(10.GiB) - end - end - - context "and the max value is 'current'" do - let(:min_size) { "10 GiB" } - let(:max_size) { "current" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to eq(10.GiB) - expect(config.size.max).to be_nil - end - end - - context "and both values are 'current'" do - let(:min_size) { "current" } - let(:max_size) { "current" } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(false) - expect(config.size.min).to be_nil - expect(config.size.max).to be_nil - end - end - end - - context "if 'size' is an array" do - context "and only contains one value" do - let(:size) { [min_size] } - include_examples "min size" - end - - context "and contains two values" do - let(:size) { [min_size, max_size] } - include_examples "min and max sizes" - end - end - - context "if 'size' is a hash" do - context "and only specifies 'min'" do - let(:size) { { min: min_size } } - include_examples "min size" - end - - context "and specifies 'min' and 'max'" do - let(:size) do - { - min: min_size, - max: max_size - } - end - - include_examples "min and max sizes" - 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, - { - filesystem: { path: "/test" } - } - ] - end - - let(:partition) do - { - filesystem: { path: "/" } - } - end - - 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 spicify 'search'" do - let(:partition) { {} } - include_examples "without search", partition_proc - 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 'encryption'" do - let(:partition) { {} } - include_examples "without encryption", partition_proc - end - - context "if a partition does not spicify '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 'search'" do - let(:partition) { { search: search } } - include_examples "with search", 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 spicifies 'size'" do - let(:partition) { { size: size } } - include_examples "with size", partition_proc - end - - context "if a partition specifies 'encryption'" do - let(:partition) { { encryption: encryption } } - include_examples "with encryption", 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 '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 - - context "if a partition specifies 'generate'" do - let(:partition) { { generate: generate } } - - partitions_proc = proc { |c| config_proc.call(c).partitions } - include_examples "with generate", partitions_proc - - context "with a generate section" do - let(:generate) do - { - partitions: "default", - encryption: { - luks2: { password: "12345" } - } - } - end - - let(:default_paths) { ["/", "swap"] } - - it "adds the expected partitions" do - partitions = config_proc.call(subject.convert).partitions - expect(partitions.size).to eq(3) - - root_part = partitions.find { |p| p.filesystem.path == "/" } - swap_part = partitions.find { |p| p.filesystem.path == "swap" } - test_part = partitions.find { |p| p.filesystem.path == "/test" } - - expect(root_part).to_not be_nil - expect(root_part.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(root_part.encryption.password).to eq("12345") - - expect(swap_part).to_not be_nil - expect(swap_part.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(swap_part.encryption.password).to eq("12345") - - expect(test_part).to_not be_nil - expect(test_part.encryption).to be_nil - end - end - end -end - -shared_examples "with generate" do |configs_proc| - context "with 'default' value" do - let(:generate) { "default" } - - let(:default_paths) { ["/default1", "/default2"] } - - it "adds volumes for the default paths" do - configs = configs_proc.call(subject.convert) - - default1 = configs.find { |c| c.filesystem.path == "/default1" } - expect(default1).to_not be_nil - expect(default1.encryption).to be_nil - - default2 = configs.find { |c| c.filesystem.path == "/default2" } - expect(default2).to_not be_nil - expect(default2.encryption).to be_nil - end - end - - context "with 'mandatory' value" do - let(:generate) { "mandatory" } - - let(:mandatory_paths) { ["/mandatory1"] } - - it "adds volumes for the mandatory paths" do - configs = configs_proc.call(subject.convert) - - mandatory1 = configs.find { |c| c.filesystem.path == "/mandatory1" } - expect(mandatory1).to_not be_nil - expect(mandatory1.encryption).to be_nil - end - end -end +require "agama/storage/config" +require "agama/storage/config_conversions/from_json" describe Agama::Storage::ConfigConversions::FromJSON do subject do described_class.new(config_json, default_paths: default_paths, mandatory_paths: mandatory_paths) end - let(:default_paths) { [] } + let(:default_paths) { ["/", "swap"] } - let(:mandatory_paths) { [] } - - before do - # Speed up tests by avoding real check of TPM presence. - allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) - end + let(:mandatory_paths) { ["/"] } describe "#convert" do - let(:config_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(:config_json) { {} } - - it "sets #boot to the expected value" do - config = subject.convert - expect(config.boot).to be_a(Agama::Storage::Configs::Boot) - expect(config.boot.configure).to eq(true) - expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) - expect(config.boot.device.default).to eq(true) - expect(config.boot.device.device_alias).to be_nil - end - - it "sets #drives to the expected value" do - config = subject.convert - expect(config.drives).to be_empty - end - - it "sets #volume_groups to the expected value" do - config = subject.convert - expect(config.drives).to be_empty - expect(config.volume_groups).to be_empty - end - end - - context "with a JSON specifying 'boot'" do - let(:config_json) do - { - boot: { - configure: true, - device: device - } - } - end - - let(:device) { "sdb" } - - it "sets #boot to the expected value" do - config = subject.convert - expect(config.boot).to be_a(Agama::Storage::Configs::Boot) - expect(config.boot.configure).to eq(true) - expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) - expect(config.boot.device.default).to eq(false) - expect(config.boot.device.device_alias).to eq("sdb") - end - - context "if boot does not specify 'device'" do - let(:device) { nil } - - it "sets #boot to the expected value" do - config = subject.convert - expect(config.boot).to be_a(Agama::Storage::Configs::Boot) - expect(config.boot.configure).to eq(true) - expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) - expect(config.boot.device.default).to eq(true) - expect(config.boot.device.device_alias).to be_nil - end - end - end - - context "with a JSON specifying 'drives'" do - let(:config_json) do - { drives: drives } - end - - let(:drives) do - [ - drive, - { alias: "second-disk" } - ] - end - - let(:drive) do - { alias: "first-disk" } - 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.alias).to eq("first-disk") - expect(drive1.partitions).to eq([]) - expect(drive2.alias).to eq("second-disk") - expect(drive2.partitions).to eq([]) - end - end - - drive_proc = proc { |c| c.drives.first } - - context "if a drive does not specify 'search'" 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 'encryption'" do - let(:drive) { {} } - include_examples "without encryption", drive_proc - end - - context "if a drive does not spicify '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 'partitions'" do - let(:drive) { {} } - include_examples "without partitions", drive_proc - end - - context "if a drive specifies 'search'" do - let(:drive) { { search: search } } - include_examples "with search", 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 'encryption'" do - let(:drive) { { encryption: encryption } } - include_examples "with encryption", 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 '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 - end - - context "with a JSON specifying 'volumeGroups'" do - let(:config_json) do - { volumeGroups: volume_groups } - end - - let(:volume_groups) do - [ - volume_group, - { name: "vg2" } - ] - end - - let(:volume_group) { { name: "vg1" } } - - context "with an empty list" do - let(:volume_groups) { [] } - - it "sets #volume_groups to the expected value" do - config = subject.convert - expect(config.volume_groups).to eq([]) - end - end - - context "with a list of volume groups" do - it "sets #volume_groups to the expected value" do - config = subject.convert - expect(config.volume_groups.size).to eq(2) - expect(config.volume_groups).to all(be_a(Agama::Storage::Configs::VolumeGroup)) - - volume_group1, volume_group2 = config.volume_groups - expect(volume_group1.name).to eq("vg1") - expect(volume_group1.logical_volumes).to eq([]) - expect(volume_group2.name).to eq("vg2") - expect(volume_group2.logical_volumes).to eq([]) - end - end - - vg_proc = proc { |c| c.volume_groups.first } - - context "if a volume group does not spicify 'name'" do - let(:volume_group) { {} } - - it "does not set #name" do - vg = vg_proc.call(subject.convert) - expect(vg.name).to be_nil - end - end - - context "if a volume group does not spicify 'extentSize'" do - let(:volume_group) { {} } - - it "does not set #extent_size" do - vg = vg_proc.call(subject.convert) - expect(vg.extent_size).to be_nil - end - end - - context "if a volume group does not spicify 'physicalVolumes'" do - let(:volume_group) { {} } - - it "sets #physical_volumes to the expected vale" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes).to eq([]) - end - end - - context "if a volume group does not spicify 'logicalVolumes'" do - let(:volume_group) { {} } - - it "sets #logical_volumes to the expected vale" do - vg = vg_proc.call(subject.convert) - expect(vg.logical_volumes).to eq([]) - end - end - - context "if a volume group spicifies 'name'" do - let(:volume_group) { { name: "test" } } - - it "sets #name to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.name).to eq("test") - end - end - - context "if a volume group spicifies 'extentSize'" do - let(:volume_group) { { extentSize: size } } - - context "if 'extentSize' is a string" do - let(:size) { "4 KiB" } - - it "sets #extent_size to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.extent_size).to eq(4.KiB) - end - end - - context "if 'extentSize' is a number" do - let(:size) { 4096 } - - it "sets #extent_size to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.extent_size).to eq(4.KiB) - end - end - end - - context "if a volume group spicifies 'physicalVolumes'" do - let(:volume_group) { { physicalVolumes: physical_volumes } } - - context "with an empty list" do - let(:physical_volumes) { [] } - - it "sets #physical_volumes to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes).to eq([]) - end - - it "sets #physical_volumes_devices to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_devices).to eq([]) - end - - it "sets #physical_volumes_encryption to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_encryption).to be_nil - end - end - - context "with a list of aliases" do - let(:physical_volumes) { ["pv1", "pv2"] } - - it "sets #physical_volumes to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes).to contain_exactly("pv1", "pv2") - end - - it "sets #physical_volumes_devices to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_devices).to eq([]) - end - - it "sets #physical_volumes_encryption to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_encryption).to be_nil - end - end - - context "with a list including a physical volume with 'generate' array" do - let(:physical_volumes) do - [ - "pv1", - { generate: ["disk1", "disk2"] }, - "pv2" + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" } ] - end - - it "sets #physical_volumes to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes).to contain_exactly("pv1", "pv2") - end - - it "sets #physical_volumes_devices to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_devices).to contain_exactly("disk1", "disk2") - end - - it "does not set #physical_volumes_encryption" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_encryption).to be_nil - end - end - - context "with a list including a physical volume with 'generate' section" do - let(:physical_volumes) do - [ - "pv1", - { - generate: { - targetDevices: target_devices, - encryption: encryption - } - }, - "pv2" + } + ], + volumeGroups: [ + { + name: "vg0", + logicalVolumes: [ + { filesystem: { path: "/home" } } ] - end - - let(:target_devices) { nil } - - let(:encryption) { nil } - - it "sets #physical_volumes to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes).to contain_exactly("pv1", "pv2") - end - - context "if the physical volume does not specify 'targetDevices'" do - let(:target_devices) { nil } - - it "sets #physical_volumes_devices to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_devices).to eq([]) - end - end - - context "if the physical volume does not specify 'encryption'" do - let(:target_devices) { nil } - - it "does not set #physical_volumes_encryption" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_encryption).to be_nil - end - end - - context "if the physical volume specifies 'targetDevices'" do - let(:target_devices) { ["disk1"] } - - it "sets #physical_volumes_devices to the expected value" do - vg = vg_proc.call(subject.convert) - expect(vg.physical_volumes_devices).to contain_exactly("disk1") - end - end - - context "if the physical volume specifies 'encryption'" do - let(:encryption) do - { - luks1: { password: "12345" } - } - end - - it "sets #physical_volumes_encryption to the expected value" do - vg = vg_proc.call(subject.convert) - encryption = vg.physical_volumes_encryption - expect(encryption).to be_a(Agama::Storage::Configs::Encryption) - expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) - expect(encryption.password).to eq("12345") - expect(encryption.pbkd_function).to be_nil - expect(encryption.label).to be_nil - expect(encryption.cipher).to be_nil - expect(encryption.key_size).to be_nil - end - end - end - end - - context "if a volume group spicifies 'logicalVolumes'" do - let(:volume_group) { { logicalVolumes: logical_volumes } } - - let(:logical_volumes) do - [ - logical_volume, - { name: "test" } - ] - end - - let(:logical_volume) { { name: "root" } } - - context "with an empty list" do - let(:logical_volumes) { [] } - - it "sets #logical_volumes to empty" do - vg = vg_proc.call(subject.convert) - expect(vg.logical_volumes).to eq([]) - end - end - - context "with a list of logical volumes" do - it "sets #logical_volumes to the expected value" do - vg = vg_proc.call(subject.convert) - lvs = vg.logical_volumes - expect(lvs.size).to eq(2) - - lv1, lv2 = lvs - expect(lv1).to be_a(Agama::Storage::Configs::LogicalVolume) - expect(lv1.name).to eq("root") - expect(lv2).to be_a(Agama::Storage::Configs::LogicalVolume) - expect(lv2.name).to eq("test") - end - end - - lv_proc = proc { |c| c.volume_groups.first.logical_volumes.first } - - context "if a logical volume does not specify 'name'" do - let(:logical_volume) { {} } - - it "does not set #name" do - lv = lv_proc.call(subject.convert) - expect(lv.name).to be_nil - end - end - - context "if a logical volume does not specify 'stripes'" do - let(:logical_volume) { {} } - - it "does not set #stripes" do - lv = lv_proc.call(subject.convert) - expect(lv.stripes).to be_nil - end - end - - context "if a logical volume does not specify 'stripeSize'" do - let(:logical_volume) { {} } - - it "does not set #stripe_size" do - lv = lv_proc.call(subject.convert) - expect(lv.stripe_size).to be_nil - end - end - - context "if a logical volume does not specify 'pool'" do - let(:logical_volume) { {} } - - it "sets #pool? to false" do - lv = lv_proc.call(subject.convert) - expect(lv.pool?).to eq(false) - end - end - - context "if a logical volume does not specify 'usedPool'" do - let(:logical_volume) { {} } - - it "does not set #used_pool" do - lv = lv_proc.call(subject.convert) - expect(lv.used_pool).to be_nil - end - end - - context "if a logical volume does not specify 'alias'" do - let(:logical_volume) { {} } - include_examples "without alias", lv_proc - end - - context "if a logical volume does not specify 'size'" do - let(:logical_volume) { {} } - include_examples "without size", lv_proc - end - - context "if a logical volume does not specify 'encryption'" do - let(:logical_volume) { {} } - include_examples "without encryption", lv_proc - end - - context "if a logical volume does not specify 'filesystem'" do - let(:logical_volume) { {} } - include_examples "without filesystem", lv_proc - end - - context "if a logical volume specifies 'stripes'" do - let(:logical_volume) { { stripes: 10 } } - - it "sets #stripes to the expected value" do - lv = lv_proc.call(subject.convert) - expect(lv.stripes).to eq(10) - end - end - - context "if a logical volume specifies 'stripeSize'" do - let(:logical_volume) { { stripeSize: size } } - - context "if 'stripeSize' is a string" do - let(:size) { "4 KiB" } - - it "sets #stripe_size to the expected value" do - lv = lv_proc.call(subject.convert) - expect(lv.stripe_size).to eq(4.KiB) - end - end - - context "if 'stripeSize' is a number" do - let(:size) { 4096 } - - it "sets #stripe_size to the expected value" do - lv = lv_proc.call(subject.convert) - expect(lv.stripe_size).to eq(4.KiB) - end - end - end - - context "if a logical volume specifies 'pool'" do - let(:logical_volume) { { pool: true } } - - it "sets #pool? to the expected value" do - lv = lv_proc.call(subject.convert) - expect(lv.pool?).to eq(true) - end - end - - context "if a logical volume specifies 'usedPool'" do - let(:logical_volume) { { usedPool: "pool" } } - - it "sets #used_pool to the expected value" do - lv = lv_proc.call(subject.convert) - expect(lv.used_pool).to eq("pool") - end - end - - context "if a logical volume specifies 'alias'" do - let(:logical_volume) { { alias: device_alias } } - include_examples "with alias", lv_proc - end - - context "if a logical volume specifies 'size'" do - let(:logical_volume) { { size: size } } - include_examples "with size", lv_proc - end - - context "if a logical volume specifies 'encryption'" do - let(:logical_volume) { { encryption: encryption } } - include_examples "with encryption", lv_proc - end - - context "if a logical volume specifies 'filesystem'" do - let(:logical_volume) { { filesystem: filesystem } } - include_examples "with filesystem", lv_proc - end - - context "if a logical volume specifies 'generate'" do - let(:logical_volume) { { generate: generate } } - - logical_volumes_proc = proc { |c| c.volume_groups.first.logical_volumes } - include_examples "with generate", logical_volumes_proc - - context "with a generate section" do - let(:generate) do - { - logicalVolumes: "default", - encryption: { - luks2: { password: "12345" } - }, - stripes: 8, - stripeSize: "16 KiB" - } - end - - let(:default_paths) { ["/", "swap"] } - - it "adds the expected logical volumes" do - lvs = subject.convert.volume_groups.first.logical_volumes - expect(lvs.size).to eq(3) - - root_lv = lvs.find { |v| v.filesystem.path == "/" } - swap_lv = lvs.find { |v| v.filesystem.path == "swap" } - test_lv = lvs.find { |v| v.name == "test" } - - expect(root_lv).to_not be_nil - expect(root_lv.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(root_lv.encryption.password).to eq("12345") - - expect(swap_lv).to_not be_nil - expect(swap_lv.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(swap_lv.encryption.password).to eq("12345") - - expect(test_lv).to_not be_nil - expect(test_lv.encryption).to be_nil - end - end - end - end - end - - context "generating partitions" do - let(:config_json) do - { - drives: drives, - volumeGroups: volume_groups - } - end - - let(:drives) { [] } - - let(:volume_groups) { [] } - - let(:default_paths) { ["/", "swap", "/home"] } - - let(:mandatory_paths) { ["/", "swap"] } - - context "if the device already specifies any of the partitions" do - let(:drives) do - [ - { - partitions: [ - { generate: "default" }, - { filesystem: { path: "/home" } } - ] - } - ] - end - - it "only adds partitions for the the missing paths" do - config = subject.convert - partitions = config.drives.first.partitions - expect(partitions.size).to eq(3) - - root_part = partitions.find { |p| p.filesystem.path == "/" } - swap_part = partitions.find { |p| p.filesystem.path == "swap" } - home_part = partitions.find { |p| p.filesystem.path == "/home" } - expect(root_part).to_not be_nil - expect(swap_part).to_not be_nil - expect(home_part).to_not be_nil - end - end - - context "if other device already specifies any of the partitions" do - let(:drives) do - [ - { - partitions: [ - { generate: "default" } - ] - }, - { - partitions: [ - { filesystem: { path: "/home" } } - ] - } - ] - end - - it "only adds partitions for the the missing paths" do - config = subject.convert - partitions = config.drives.first.partitions - expect(partitions.size).to eq(2) - - root_part = partitions.find { |p| p.filesystem.path == "/" } - swap_part = partitions.find { |p| p.filesystem.path == "swap" } - expect(root_part).to_not be_nil - expect(swap_part).to_not be_nil - end - end - - context "if a volume group already specifies any of the paths" do - let(:drives) do - [ - { - partitions: [ - { generate: "mandatory" } - ] - } - ] - end - - let(:volume_groups) do - [ - { - logicalVolumes: [ - { filesystem: { path: "swap" } } - ] - } - ] - end - - it "only adds partitions for the the missing paths" do - config = subject.convert - partitions = config.drives.first.partitions - expect(partitions.size).to eq(1) - - root_part = partitions.find { |p| p.filesystem.path == "/" } - expect(root_part).to_not be_nil - end - end - - context "if the device specifies several partitions with 'generate'" do - let(:drives) do - [ - { - partitions: [ - { generate: "mandatory" }, - { generate: "default" } - ] - } - ] - end - - it "only adds partitions for the first 'generate'" do - config = subject.convert - partitions = config.drives.first.partitions - expect(partitions.size).to eq(2) - - root_part = partitions.find { |p| p.filesystem.path == "/" } - swap_part = partitions.find { |p| p.filesystem.path == "swap" } - expect(root_part).to_not be_nil - expect(swap_part).to_not be_nil - end - end - - context "if several devices specify partitions with 'generate'" do - let(:drives) do - [ - { - partitions: [ - { generate: "mandatory" } - ] - }, - { - partitions: [ - { generate: "default" } - ] - } - ] - end - - it "only adds partitions to the first device with a 'generate'" do - config = subject.convert - drive1, drive2 = config.drives - expect(drive1.partitions.size).to eq(2) - expect(drive2.partitions.size).to eq(0) - end - end + } + ] + } end - context "generating logical volumes" do - let(:config_json) do - { - drives: drives, - volumeGroups: volume_groups - } - end - - let(:drives) { [] } - - let(:volume_groups) { [] } - - let(:default_paths) { ["/", "swap", "/home"] } - - let(:mandatory_paths) { ["/", "swap"] } - - context "if the volume group already specifies any of the logical volumes" do - let(:volume_groups) do - [ - { - logicalVolumes: [ - { generate: "default" }, - { filesystem: { path: "/home" } } - ] - } - ] - end - - it "only adds logical volumes for the the missing paths" do - config = subject.convert - lvs = config.volume_groups.first.logical_volumes - expect(lvs.size).to eq(3) - - root_lv = lvs.find { |v| v.filesystem.path == "/" } - swap_lv = lvs.find { |v| v.filesystem.path == "swap" } - home_lv = lvs.find { |v| v.filesystem.path == "/home" } - expect(root_lv).to_not be_nil - expect(swap_lv).to_not be_nil - expect(home_lv).to_not be_nil - end - end - - context "if other volume group already specifies any of the logical volumes" do - let(:volume_groups) do - [ - { - logicalVolumes: [ - { generate: "default" } - ] - }, - { - logicalVolumes: [ - { filesystem: { path: "/home" } } - ] - } - ] - end - - it "only adds logical volumes for the the missing paths" do - config = subject.convert - lvs = config.volume_groups.first.logical_volumes - expect(lvs.size).to eq(2) - - root_lv = lvs.find { |v| v.filesystem.path == "/" } - swap_lv = lvs.find { |v| v.filesystem.path == "swap" } - expect(root_lv).to_not be_nil - expect(swap_lv).to_not be_nil - end - end - - context "if a device already specifies a partition for any of the paths" do - let(:drives) do - [ - { - partitions: [ - { filesystem: { path: "swap" } } - ] - } - ] - end - - let(:volume_groups) do - [ - { - logicalVolumes: [ - { generate: "mandatory" } - ] - } - ] - end - - it "only adds logical volumes for the the missing paths" do - config = subject.convert - lvs = config.volume_groups.first.logical_volumes - expect(lvs.size).to eq(1) - - root_lv = lvs.find { |v| v.filesystem.path == "/" } - expect(root_lv).to_not be_nil - end - end - - context "if the volume group specifies several logical volumes with 'generate'" do - let(:volume_groups) do - [ - { - logicalVolumes: [ - { generate: "mandatory" }, - { generate: "default" } - ] - } - ] - end - - it "only adds logical volumes for the first 'generate'" do - config = subject.convert - lvs = config.volume_groups.first.logical_volumes - expect(lvs.size).to eq(2) - - root_lv = lvs.find { |v| v.filesystem.path == "/" } - swap_lv = lvs.find { |v| v.filesystem.path == "swap" } - expect(root_lv).to_not be_nil - expect(swap_lv).to_not be_nil - end - end - - context "if several volume groups specify logical volumes with 'generate'" do - let(:volume_groups) do - [ - { - logicalVolumes: [ - { generate: "mandatory" } - ] - }, - { - logicalVolumes: [ - { generate: "default" } - ] - } - ] - end - - it "only adds logical volumes to the first volume group with a 'generate'" do - config = subject.convert - vg1, vg2 = config.volume_groups - expect(vg1.logical_volumes.size).to eq(2) - expect(vg2.logical_volumes.size).to eq(0) - end - end - - context "if a drive specifies a partition with 'generate'" do - let(:drives) do - [ - { - partitions: [ - { generate: "mandatory" } - ] - } - ] - end - - let(:volume_groups) do - [ - { - logicalVolumes: [ - { generate: "mandatory" } - ] - } - ] - end + it "returns a storage config" do + config = subject.convert + boot = config.boot + drive = config.drives.first + volume_group = config.volume_groups.first - it "does not add logical volumes to the volume group" do - config = subject.convert - vg = config.volume_groups.first - expect(vg.logical_volumes.size).to eq(0) - end - end + expect(config).to be_a(Agama::Storage::Config) + expect(boot.configure).to eq(true) + expect(boot.device.default).to eq(true) + expect(boot.device.device_alias).to be_nil + expect(drive.partitions.map { |p| p.filesystem.path }).to contain_exactly("/", "swap") + expect(volume_group.name).to eq("vg0") + expect(volume_group.logical_volumes.map { |l| l.filesystem.path }).to contain_exactly("/home") end end end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/boot_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/boot_test.rb new file mode 100644 index 0000000000..e0cb3d1c5a --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/boot_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions/from_json_conversions/boot" +require "agama/storage/config_conversions/to_json_conversions/boot" + +describe Agama::Storage::ConfigConversions::ToJSONConversions::Boot do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::Boot + .new(config_json) + .convert + end + + let(:config_json) do + { + configure: configure, + device: device + } + end + + let(:configure) { nil } + let(:device) { nil } + + describe "#convert" do + context "if nothing is configured" do + let(:configure) { nil } + let(:devices) { nil } + + it "generates the expected JSON" do + expect(subject.convert).to eq({ configure: true }) + end + end + + context "if #configure is false" do + let(:configure) { false } + + it "generates the expected JSON" do + expect(subject.convert).to eq({ configure: false }) + end + end + + context "if #configure is true" do + let(:configure) { true } + + it "generates the expected JSON" do + expect(subject.convert).to eq({ configure: true }) + end + + context "and #device is configured" do + let(:device) { "vda" } + + it "generates the expected JSON" do + expect(subject.convert).to eq( + { + configure: true, + device: "vda" + } + ) + end + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/config_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/config_test.rb new file mode 100644 index 0000000000..3c2a3c18ae --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/config_test.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions/from_json_conversions/config" +require "agama/storage/config_conversions/to_json_conversions/config" + +describe Agama::Storage::ConfigConversions::ToJSONConversions::Config do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::Config + .new(config_json) + .convert + end + + let(:config_json) do + { + boot: boot, + drives: drives, + mdRaids: md_raids, + volumeGroups: volume_groups + } + end + + let(:boot) { nil } + let(:drives) { nil } + let(:md_raids) { nil } + let(:volume_groups) { nil } + + describe "#convert" do + context "if nothing is configured" do + it "generates the expected JSON" do + expect(subject.convert).to eq( + { + boot: { + configure: true + }, + drives: [], + mdRaids: [], + volumeGroups: [] + } + ) + end + end + + context "if #boot is configured" do + let(:boot) do + { + configure: true, + device: "vda" + } + end + + it "generates the expected JSON" do + config_json = subject.convert + + expect(config_json[:boot]).to eq( + { + configure: true, + device: "vda" + } + ) + end + end + + context "if #drives is configured" do + let(:drives) do + [ + { search: "/dev/vda" }, + { + filesystem: { path: "/" } + } + ] + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:drives]).to eq( + [ + { + search: { + condition: { name: "/dev/vda" }, + ifNotFound: "error" + }, + partitions: [] + }, + { + search: { + ifNotFound: "error", + max: 1 + }, + filesystem: { + mkfsOptions: [], + mountOptions: [], + path: "/", + reuseIfPossible: false + }, + partitions: [] + } + ] + ) + end + end + + context "if #md_raids is configured" do + let(:md_raids) do + [ + { + level: "raid1", + devices: ["disk1", "disk2"] + } + ] + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:mdRaids]).to eq( + [ + { + level: "raid1", + devices: ["disk1", "disk2"], + partitions: [] + } + ] + ) + end + end + + context "if #volume_groups is configured" do + let(:volume_groups) do + [ + { name: "vg1" }, + { name: "vg2" } + ] + end + + it "generates the expected JSON" do + config_json = subject.convert + + expect(config_json[:volumeGroups]).to eq( + [ + { + name: "vg1", + physicalVolumes: [], + logicalVolumes: [] + }, + { + name: "vg2", + physicalVolumes: [], + logicalVolumes: [] + } + ] + ) + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/drive_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/drive_test.rb new file mode 100644 index 0000000000..cf56850252 --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/drive_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/drive" +require "agama/storage/config_conversions/to_json_conversions/drive" + +describe Agama::Storage::ConfigConversions::ToJSONConversions::Drive do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::Drive + .new(config_json) + .convert + end + + let(:config_json) do + { + search: search, + alias: device_alias, + encryption: encryption, + filesystem: filesystem, + ptableType: ptable_type, + partitions: partitions + } + end + + let(:search) { nil } + let(:device_alias) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + let(:ptable_type) { nil } + let(:partitions) { nil } + + describe "#convert" do + it "returns a Hash" do + expect(subject.convert).to be_a(Hash) + end + + context "if #search is not configured" do + let(:search) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + search_json = config_json[:search] + + expect(search_json).to eq( + { ifNotFound: "error", max: 1 } + ) + end + end + + include_examples "without alias" + include_examples "without encryption" + include_examples "without filesystem" + include_examples "without ptable_type" + include_examples "without partitions" + include_examples "with search" + include_examples "with alias" + include_examples "with encryption" + include_examples "with filesystem" + include_examples "with ptable_type" + include_examples "with partitions" + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb b/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb new file mode 100644 index 0000000000..eb4d966c74 --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb @@ -0,0 +1,483 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +shared_examples "without search" do + context "if #search is not configured" do + let(:search) { nil } + + it "generates the expected JSON for 'search'" do + config_json = subject.convert + expect(config_json.keys).to_not include(:search) + end + end +end + +shared_examples "without alias" do + context "if #alias is not configured" do + let(:device_alias) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:alias) + end + end +end + +shared_examples "without encryption" do + context "if #encryption is not configured" do + let(:encryption) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:encryption) + end + end +end + +shared_examples "without filesystem" do + context "if #filesystem is not configured" do + let(:filesystem) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:filesystem) + end + end +end + +shared_examples "without ptable_type" do + context "if #ptable_type is not configured" do + let(:ptable_type) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:ptableType) + end + end +end + +shared_examples "without partitions" do + context "if #partitions is not configured" do + let(:partitions) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:partitions]).to eq([]) + end + end +end + +shared_examples "without size" do + context "if #size is not configured" do + let(:size) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:size) + end + end +end + +shared_examples "with search" do + context "if #search is configured" do + let(:search) do + { + condition: { name: "/dev/vda1" }, + ifNotFound: "skip", + max: 2 + } + end + + it "generates the expected JSON" do + config_json = subject.convert + search_json = config_json[:search] + + expect(search_json).to eq( + { + condition: { name: "/dev/vda1" }, + ifNotFound: "skip", + max: 2 + } + ) + end + + context "if the device name is not provided" do + let(:search) { {} } + + it "generates the expected JSON" do + config_json = subject.convert + search_json = config_json[:search] + + expect(search_json).to eq( + { + ifNotFound: "error" + } + ) + end + + context "and a device was assigned" do + before do + allow_any_instance_of(Agama::Storage::Configs::Search) + .to(receive(:device)) + .and_return(device) + end + + let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/vda") } + + it "generates the expected JSON" do + config_json = subject.convert + search_json = config_json[:search] + + expect(search_json).to eq( + { + condition: { name: "/dev/vda" }, + ifNotFound: "error" + } + ) + end + end + end + + context "if there are no conditions or limits and errors should be skipped" do + let(:search) { { ifNotFound: "skip" } } + + it "generates the expected JSON" do + config_json = subject.convert + search_json = config_json[:search] + + expect(search_json).to eq( + { + ifNotFound: "skip" + } + ) + end + end + end +end + +shared_examples "with alias" do + context "if #alias is configured" do + let(:device_alias) { "test" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:alias]).to eq("test") + end + end +end + +shared_examples "with encryption" do + context "if #encryption is configured" do + let(:encryption) do + { + luks2: { + password: "12345", + keySize: 256, + pbkdFunction: "argon2i", + cipher: "twofish", + label: "test" + } + } + end + + it "generates the expected JSON" do + config_json = subject.convert + encryption_json = config_json[:encryption] + + expect(encryption_json).to eq( + { + luks2: { + password: "12345", + keySize: 256, + pbkdFunction: "argon2i", + cipher: "twofish", + label: "test" + } + } + ) + end + + context "if encryption only configures #password" do + let(:encryption) do + { + luks2: { + password: "12345" + } + } + end + + it "generates the expected JSON" do + config_json = subject.convert + encryption_json = config_json[:encryption] + + expect(encryption_json).to eq( + { + luks2: { + password: "12345" + } + } + ) + end + end + + context "if encryption method is pervasive LUKS2" do + let(:encryption) do + { + pervasiveLuks2: { + password: "12345" + } + } + end + + it "generates the expected JSON" do + config_json = subject.convert + encryption_json = config_json[:encryption] + + expect(encryption_json).to eq( + { + pervasiveLuks2: { + password: "12345" + } + } + ) + end + end + + context "if encryption method is TMP FDE" do + let(:encryption) do + { + tpmFde: { + password: "12345" + } + } + end + + it "generates the expected JSON" do + config_json = subject.convert + encryption_json = config_json[:encryption] + + expect(encryption_json).to eq( + { + tpmFde: { + password: "12345" + } + } + ) + end + end + + context "if encryption method is protected swap" do + let(:encryption) { "protected_swap" } + + it "generates the expected JSON" do + config_json = subject.convert + encryption_json = config_json[:encryption] + + expect(encryption_json).to eq("protected_swap") + end + end + + context "if encryption method is not configured" do + let(:encryption) { {} } + + it "generates the expected JSON" do + config_json = subject.convert + encryption_json = config_json[:encryption] + expect(encryption_json).to be_nil + end + end + end +end + +shared_examples "with filesystem" do + context "if #encryption is configured" do + let(:filesystem) do + { + reuseIfPossible: true, + type: "xfs", + label: "test", + path: "/test", + mountBy: "device", + mkfsOptions: ["version=2"], + mountOptions: ["rw"] + } + end + + it "generates the expected JSON" do + config_json = subject.convert + filesystem_json = config_json[:filesystem] + + expect(filesystem_json).to eq( + { + reuseIfPossible: true, + type: "xfs", + label: "test", + path: "/test", + mountBy: "device", + mkfsOptions: ["version=2"], + mountOptions: ["rw"] + } + ) + end + + context "if filesystem configures #btrfs" do + let(:filesystem) do + { + type: { + btrfs: { + snapshots: true + } + } + } + end + + it "generates the expected JSON" do + config_json = subject.convert + filesystem_json = config_json[:filesystem] + + expect(filesystem_json).to eq( + { + reuseIfPossible: false, + type: { + btrfs: { snapshots: true } + }, + mkfsOptions: [], + mountOptions: [] + } + ) + end + end + + context "if filesystem does not configure #type" do + let(:filesystem) { {} } + + it "generates the expected JSON" do + config_json = subject.convert + filesystem_json = config_json[:filesystem] + + expect(filesystem_json).to eq( + { + reuseIfPossible: false, + mkfsOptions: [], + mountOptions: [] + } + ) + end + end + end +end + +shared_examples "with ptable_type" do + context "if #ptable_type is configured" do + let(:ptable_type) { "gpt" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:ptableType]).to eq("gpt") + end + end +end + +shared_examples "with size" do + context "if #size is configured" do + let(:size) do + { + min: "1 GiB", + max: "10 GiB" + } + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:size]).to eq( + { + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + ) + end + + context "if max size is unlimited" do + let(:size) do + { + min: "1 GiB" + } + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:size]).to eq( + { + min: 1.GiB.to_i + } + ) + end + end + + context "if size was solved" do + before do + size_config = config.size + size_config.default = true + size_config.min = 5.GiB + size_config.max = 25.GiB + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:size]).to eq( + { + min: 5.GiB.to_i, + max: 25.GiB.to_i + } + ) + end + end + end +end + +shared_examples "with partitions" do + context "if #partitions is configured" do + let(:partitions) do + [ + { alias: "p1" }, + { alias: "p2" } + ] + end + + it "generates the expected JSON" do + config_json = subject.convert + partitions_json = config_json[:partitions] + + expect(partitions_json).to eq( + [ + { alias: "p1" }, + { alias: "p2" } + ] + ) + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/logical_volume_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/logical_volume_test.rb new file mode 100644 index 0000000000..105ed67233 --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/logical_volume_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/logical_volume" +require "agama/storage/config_conversions/to_json_conversions/logical_volume" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::ToJSONConversions::LogicalVolume do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::LogicalVolume + .new(config_json) + .convert + end + + let(:config_json) do + { + alias: device_alias, + name: name, + stripes: stripes, + stripeSize: stripe_size, + pool: pool, + usedPool: used_pool, + size: size, + encryption: encryption, + filesystem: filesystem + } + end + + let(:device_alias) { "test" } + let(:name) { "lv1" } + let(:stripes) { nil } + let(:stripe_size) { nil } + let(:pool) { nil } + let(:used_pool) { nil } + let(:size) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + + describe "#convert" do + context "if nothing is configured" do + let(:device_alias) { nil } + let(:name) { nil } + + it "returns nil" do + expect(subject.convert).to be_nil + end + end + + include_examples "without alias" + include_examples "without size" + include_examples "without encryption" + include_examples "without filesystem" + + context "if #name is not configured" do + let(:name) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:name) + end + end + + context "if #stripes is not configured" do + let(:stripes) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:stripes) + end + end + + context "if #stripe_size is not configured" do + let(:stripe_size) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:stripeSize) + end + end + + context "if #pool is not configured" do + let(:pool) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:pool) + end + end + + context "if #used_pool is not configured" do + let(:used_pool) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:usedPool) + end + end + + include_examples "with alias" + include_examples "with size" + include_examples "with encryption" + include_examples "with filesystem" + + context "if #stripes is configured" do + let(:stripes) { 10 } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:stripes]).to eq(10) + end + end + + context "if #stripe_size is configured" do + let(:stripe_size) { "4 KiB" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:stripeSize]).to eq(4.KiB.to_i) + end + end + + context "if #pool is true" do + let(:pool) { true } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:pool]).to eq(true) + end + end + + context "if #used_pool is configured" do + let(:used_pool) { "pool" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:usedPool]).to eq("pool") + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/md_raid_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/md_raid_test.rb new file mode 100644 index 0000000000..6dd524c921 --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/md_raid_test.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/md_raid" +require "agama/storage/config_conversions/to_json_conversions/md_raid" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::ToJSONConversions::MdRaid do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::MdRaid + .new(config_json) + .convert + end + + let(:config_json) do + { + search: search, + alias: device_alias, + name: name, + level: level, + parity: parity, + chunkSize: chunk_size, + devices: devices, + encryption: encryption, + filesystem: filesystem, + ptableType: ptable_type, + partitions: partitions + } + end + + let(:search) { nil } + let(:device_alias) { nil } + let(:name) { nil } + let(:level) { nil } + let(:parity) { nil } + let(:chunk_size) { nil } + let(:devices) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + let(:ptable_type) { nil } + let(:partitions) { nil } + + describe "#convert" do + it "returns a Hash" do + expect(subject.convert).to be_a(Hash) + end + + include_examples "without search" + include_examples "without alias" + include_examples "without encryption" + include_examples "without filesystem" + include_examples "without ptable_type" + include_examples "without partitions" + + context "if #name is not configured" do + let(:name) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:name) + end + end + + context "if #level is not configured" do + let(:level) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:level) + end + end + + context "if #parity is not configured" do + let(:parity) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:parity) + end + end + + context "if #chunk_size is not configured" do + let(:chunk_size) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:chunk_size) + end + end + + context "if #devices is not configured" do + let(:devices) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:devices]).to eq([]) + end + end + + include_examples "with search" + include_examples "with alias" + include_examples "with encryption" + include_examples "with filesystem" + include_examples "with ptable_type" + include_examples "with partitions" + + context "if #name is configured" do + let(:name) { "system" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:name]).to eq("system") + end + end + + context "if #level is configured" do + let(:level) { "raid0" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:level]).to eq("raid0") + end + end + + context "if #parity is configured" do + let(:parity) { "left_asymmetric" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:parity]).to eq("left_asymmetric") + end + end + + context "if #chunk_size is configured" do + let(:chunk_size) { "4 KiB" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:chunkSize]).to eq(4.KiB.to_i) + end + end + + context "if #devices is configured" do + let(:devices) { ["disk1", "disk2"] } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:devices]).to eq(["disk1", "disk2"]) + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/partition_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/partition_test.rb new file mode 100644 index 0000000000..e3cd4430aa --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/partition_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require_relative "./examples" +require "agama/storage/config_conversions/from_json_conversions/partition" +require "agama/storage/config_conversions/to_json_conversions/partition" + +describe Agama::Storage::ConfigConversions::ToJSONConversions::Partition do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::Partition + .new(config_json) + .convert + end + + let(:config_json) do + { + search: search, + alias: device_alias, + id: id, + size: size, + encryption: encryption, + filesystem: filesystem, + delete: delete, + deleteIfNeeded: delete_if_needed + } + end + + let(:search) { nil } + let(:device_alias) { "p1" } + let(:id) { "linux" } + let(:size) { nil } + let(:encryption) { nil } + let(:filesystem) { nil } + let(:delete) { nil } + let(:delete_if_needed) { nil } + + describe "#convert" do + context "if nothing is configured" do + let(:device_alias) { nil } + let(:id) { nil } + + it "returns nil" do + expect(subject.convert).to be_nil + end + end + + include_examples "without search" + include_examples "without alias" + include_examples "without size" + include_examples "without encryption" + include_examples "without filesystem" + + context "if #id is not configured" do + let(:id) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:id) + end + end + + context "if #delete is not configured" do + let(:delete) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:delete) + end + end + + context "if #delete_if_needed is not configured" do + let(:delete_if_needed) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:deleteIfNeeded) + end + end + + include_examples "with search" + include_examples "with alias" + include_examples "with size" + include_examples "with encryption" + include_examples "with filesystem" + + context "if #id is configured" do + let(:id) { "esp" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:id]).to eq("esp") + end + end + + context "if #delete is true" do + let(:delete) { true } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:delete]).to eq(true) + end + end + + context "if #delete_if_needed is true" do + let(:delete_if_needed) { true } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:deleteIfNeeded]).to eq(true) + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/volume_group_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/volume_group_test.rb new file mode 100644 index 0000000000..25114dfeae --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/volume_group_test.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions/from_json_conversions/volume_group" +require "agama/storage/config_conversions/to_json_conversions/volume_group" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::ToJSONConversions::VolumeGroup do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::VolumeGroup + .new(config_json) + .convert + end + + let(:config_json) do + { + name: name, + extentSize: extent_size, + physicalVolumes: physical_volumes, + logicalVolumes: logical_volumes + } + end + + let(:name) { nil } + let(:extent_size) { nil } + let(:physical_volumes) { nil } + let(:logical_volumes) { nil } + + describe "#convert" do + it "returns a Hash" do + expect(subject.convert).to be_a(Hash) + end + + context "if #name is not configured" do + let(:name) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:name) + end + end + + context "if #extent_size is not configured" do + let(:extent_size) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json.keys).to_not include(:extentSize) + end + end + + context "if #physical_volumes is not configured" do + let(:physical_volumes) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:physicalVolumes]).to eq([]) + end + end + + context "if #logical_volumes is not configured" do + let(:logical_volumes) { nil } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:logicalVolumes]).to eq([]) + end + end + + context "if #name is configured" do + let(:name) { "test" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:name]).to eq("test") + end + end + + context "if #extent_size is configured" do + let(:extent_size) { "4 KiB" } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:extentSize]).to eq(4.KiB.to_i) + end + end + + context "if #physical_volumes is configured" do + let(:physical_volumes) { ["pv1", "pv2"] } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:physicalVolumes]).to eq(["pv1", "pv2"]) + end + + context "and #physical_volumes_devices is configured" do + let(:physical_volumes) do + [ + "pv1", + "pv2", + { + generate: { + targetDevices: ["disk1"] + } + } + ] + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:physicalVolumes]).to eq( + [ + "pv1", + "pv2", + { generate: ["disk1"] } + ] + ) + end + + context "and #physical_volumes_encryption is configured" do + let(:physical_volumes) do + [ + "pv1", + "pv2", + { + generate: { + targetDevices: ["disk1"], + encryption: { + luks1: { password: "12345" } + } + } + } + ] + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:physicalVolumes]).to eq( + [ + "pv1", + "pv2", + { + generate: { + targetDevices: ["disk1"], + encryption: { + luks1: { password: "12345" } + } + } + } + ] + ) + end + end + end + end + + context "if #logical_volumes is configured" do + let(:logical_volumes) do + [ + { name: "lv1" }, + { name: "lv2" } + ] + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:logicalVolumes]).to eq( + [ + { name: "lv1" }, + { name: "lv2" } + ] + ) + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_json_test.rb b/service/test/agama/storage/config_conversions/to_json_test.rb index b67a519927..533fcf2f6a 100644 --- a/service/test/agama/storage/config_conversions/to_json_test.rb +++ b/service/test/agama/storage/config_conversions/to_json_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,565 +20,13 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" -require "agama/storage/config_conversions" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_conversions/to_json" require "y2storage/refinements" using Y2Storage::Refinements::SizeCasts -shared_examples "without search" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:search) - end -end - -shared_examples "without alias" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:alias) - end -end - -shared_examples "without encryption" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:encryption) - end -end - -shared_examples "without filesystem" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:filesystem) - end -end - -shared_examples "without ptable_type" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:ptableType) - end -end - -shared_examples "without partitions" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:partitions]).to eq([]) - end -end - -shared_examples "without size" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:size) - end -end - -shared_examples "without delete" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:delete) - end -end - -shared_examples "without delete_if_needed" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:deleteIfNeeded) - end -end - -shared_examples "with search" do |result_scope| - let(:search) do - { - condition: { name: "/dev/vda1" }, - ifNotFound: "skip", - max: 2 - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - search_json = config_json[:search] - - expect(search_json).to eq( - { - condition: { name: "/dev/vda1" }, - ifNotFound: "skip", - max: 2 - } - ) - end - - context "if the device name is not provided" do - let(:search) { {} } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - search_json = config_json[:search] - - expect(search_json).to eq( - { - ifNotFound: "error" - } - ) - end - - context "and a device was assigned" do - before do - allow_any_instance_of(Agama::Storage::Configs::Search) - .to(receive(:device)) - .and_return(device) - end - - let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/vda") } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - search_json = config_json[:search] - - expect(search_json).to eq( - { - condition: { name: "/dev/vda" }, - ifNotFound: "error" - } - ) - end - end - end - - context "if there are no conditions or limits and errors should be skipped" do - let(:search) { { ifNotFound: "skip" } } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - search_json = config_json[:search] - - expect(search_json).to eq( - { - ifNotFound: "skip" - } - ) - end - end -end - -shared_examples "with alias" do |result_scope| - let(:device_alias) { "test" } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:alias]).to eq("test") - end -end - -shared_examples "with encryption" do |result_scope| - let(:encryption) do - { - luks2: { - password: "12345", - keySize: 256, - pbkdFunction: "argon2i", - cipher: "twofish", - label: "test" - } - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - encryption_json = config_json[:encryption] - - expect(encryption_json).to eq( - { - luks2: { - password: "12345", - keySize: 256, - pbkdFunction: "argon2i", - cipher: "twofish", - label: "test" - } - } - ) - end - - context "if encryption only configures #password" do - let(:encryption) do - { - luks2: { - password: "12345" - } - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - encryption_json = config_json[:encryption] - - expect(encryption_json).to eq( - { - luks2: { - password: "12345" - } - } - ) - end - end - - context "if encryption method is pervasive LUKS2" do - let(:encryption) do - { - pervasiveLuks2: { - password: "12345" - } - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - encryption_json = config_json[:encryption] - - expect(encryption_json).to eq( - { - pervasiveLuks2: { - password: "12345" - } - } - ) - end - end - - context "if encryption method is TMP FDE" do - let(:encryption) do - { - tpmFde: { - password: "12345" - } - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - encryption_json = config_json[:encryption] - - expect(encryption_json).to eq( - { - tpmFde: { - password: "12345" - } - } - ) - end - end - - context "if encryption method is protected swap" do - let(:encryption) { "protected_swap" } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - encryption_json = config_json[:encryption] - - expect(encryption_json).to eq("protected_swap") - end - end - - context "if encryption method is not configured" do - let(:encryption) { {} } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - encryption_json = config_json[:encryption] - expect(encryption_json).to be_nil - end - end -end - -shared_examples "with filesystem" do |result_scope| - let(:filesystem) do - { - reuseIfPossible: true, - type: "xfs", - label: "test", - path: "/test", - mountBy: "device", - mkfsOptions: ["version=2"], - mountOptions: ["rw"] - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - filesystem_json = config_json[:filesystem] - - expect(filesystem_json).to eq( - { - reuseIfPossible: true, - type: "xfs", - label: "test", - path: "/test", - mountBy: "device", - mkfsOptions: ["version=2"], - mountOptions: ["rw"] - } - ) - end - - context "if filesystem configures #btrfs" do - let(:filesystem) do - { - type: { - btrfs: { - snapshots: true - } - } - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - filesystem_json = config_json[:filesystem] - - expect(filesystem_json).to eq( - { - reuseIfPossible: false, - type: { - btrfs: { snapshots: true } - }, - mkfsOptions: [], - mountOptions: [] - } - ) - end - end - - context "if filesystem does not configure #type" do - let(:filesystem) { {} } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - filesystem_json = config_json[:filesystem] - - expect(filesystem_json).to eq( - { - reuseIfPossible: false, - mkfsOptions: [], - mountOptions: [] - } - ) - end - end -end - -shared_examples "with ptable_type" do |result_scope| - let(:ptableType) { "gpt" } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:ptableType]).to eq("gpt") - end -end - -shared_examples "with size" do |result_scope, config_scope| - let(:size) do - { - min: "1 GiB", - max: "10 GiB" - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:size]).to eq( - { - min: 1.GiB.to_i, - max: 10.GiB.to_i - } - ) - end - - context "if max size is unlimited" do - let(:size) do - { - min: "1 GiB" - } - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:size]).to eq( - { - min: 1.GiB.to_i - } - ) - end - end - - context "if size was solved" do - before do - size_config = config_scope.call(config).size - size_config.default = true - size_config.min = 5.GiB - size_config.max = 25.GiB - end - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:size]).to eq( - { - min: 5.GiB.to_i, - max: 25.GiB.to_i - } - ) - end - end -end - -shared_examples "with delete" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:delete]).to eq(true) - end -end - -shared_examples "with delete_if_needed" do |result_scope| - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - expect(config_json[:deleteIfNeeded]).to eq(true) - end -end - -shared_examples "with partitions" do |result_scope, config_scope| - let(:partitions) do - [ - partition, - { search: "/dev/vda2", alias: "vda2" } - ] - end - - let(:partition) { { search: "/dev/vda1", alias: "vda1" } } - - it "generates the expected JSON" do - config_json = result_scope.call(subject.convert) - partitions_json = config_json[:partitions] - - expect(partitions_json).to eq( - [ - { - search: { - condition: { name: "/dev/vda1" }, - ifNotFound: "error" - }, - alias: "vda1" - }, - { - search: { - condition: { name: "/dev/vda2" }, - ifNotFound: "error" - }, - alias: "vda2" - } - ] - ) - end - - partition_result_scope = proc { |c| result_scope.call(c)[:partitions].first } - partition_scope = proc { |c| config_scope.call(c).partitions.first } - - context "if #search is not configured for a partition" do - let(:partition) { { alias: "vda1" } } - include_examples "without search", partition_result_scope - end - - context "if #alias is not configured for a partition" do - let(:partition) { { search: "/dev/vda1" } } - include_examples "without alias", partition_result_scope - end - - context "if #id is not configured for a partition" do - let(:partition) { { search: "/dev/vda1" } } - - it "generates the expected JSON" do - config_json = partition_result_scope.call(subject.convert) - expect(config_json.keys).to_not include(:id) - end - end - - context "if #size is not configured for a partition" do - let(:partition) { { search: "/dev/vda1" } } - include_examples "without size", partition_result_scope - end - - context "if #encryption is not configured for a partition" do - let(:partition) { { search: "/dev/vda1" } } - include_examples "without encryption", partition_result_scope - end - - context "if #filesystem is not configured for a partition" do - let(:partition) { { search: "/dev/vda1" } } - include_examples "without filesystem", partition_result_scope - end - - context "if #delete is not configured for a partition" do - let(:partition) { { search: "/dev/vda1" } } - include_examples "without delete", partition_result_scope - end - - context "if #delete_if_needed is not configured for a partition" do - let(:partition) { { search: "/dev/vda1" } } - include_examples "without delete_if_needed", partition_result_scope - end - - context "if #search is configured for a partition" do - let(:partition) { { search: search } } - include_examples "with search", partition_result_scope - end - - context "if #alias is configured for a partition" do - let(:partition) { { alias: device_alias } } - include_examples "with alias", partition_result_scope - end - - context "if #id is configured for a partition" do - let(:partition) { { id: "esp" } } - - it "generates the expected JSON" do - config_json = partition_result_scope.call(subject.convert) - expect(config_json[:id]).to eq("esp") - end - end - - context "if #size is configured for a partition" do - let(:partition) { { size: size } } - include_examples "with size", partition_result_scope, partition_scope - end - - context "if #encryption is configured for a partition" do - let(:partition) { { encryption: encryption } } - include_examples "with encryption", partition_result_scope - end - - context "if #filesystem is configured for a partition" do - let(:partition) { { filesystem: filesystem } } - include_examples "with filesystem", partition_result_scope - end - - context "if #delete is configured for a partition" do - let(:partition) { { delete: true } } - include_examples "with delete", partition_result_scope - end - - context "if #delete_if_needed is configured for a partition" do - let(:partition) { { deleteIfNeeded: true } } - include_examples "with delete_if_needed", partition_result_scope - end -end - describe Agama::Storage::ConfigConversions::ToJSON do - before do - # Speed up tests by avoding real check of TPM presence. - allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) - end - subject { described_class.new(config) } let(:config) do @@ -588,465 +36,116 @@ end describe "#convert" do - let(:config_json) { {} } - - it "returns a Hash" do - expect(subject.convert).to be_a(Hash) - end - - context "with the default config" do - let(:config_json) { {} } - - it "generates the expected JSON" do - expect(subject.convert).to eq( + let(:config_json) do + { + boot: { + configure: true, + device: "disk1" + }, + drives: [ { - boot: { - configure: true - }, - drives: [], - volumeGroups: [] + alias: "disk1", + partitions: [ + { + alias: "p1", + size: "5 GiB" + }, + { + alias: "p2", + size: "10 GiB" + }, + { + alias: "p3", + size: "10 GiB" + } + ] } - ) - end - end - - context "if #boot is configured" do - let(:config_json) do - { - boot: { - configure: true, - device: "vdb" + ], + mdRaids: [ + { + level: "raid0", + devices: ["p2", "p3"] } - } - end - - it "generates the expected JSON for 'boot'" do - boot_json = subject.convert[:boot] - expect(boot_json).to eq( + ], + volumeGroups: [ { - configure: true, - device: "vdb" + name: "test", + physicalVolumes: ["p1"], + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] } - ) - end - end - - context "if #drives is configured" do - let(:config_json) do - { drives: drives } - end - - let(:drives) do - [ - drive, - {} ] - end - - let(:drive) { {} } - - it "generates the expected JSON for 'drives'" do - drives_json = subject.convert[:drives] - - default_drive_json = { - search: { ifNotFound: "error", max: 1 }, - partitions: [] - } - - expect(drives_json).to eq( - [ - default_drive_json, - default_drive_json - ] - ) - end - - drive_result_scope = proc { |c| c[:drives].first } - drive_scope = proc { |c| c.drives.first } - - context "if #search is not configured for a drive" do - let(:drive) { {} } - - it "generates the expected JSON for 'search'" do - drive_json = drive_result_scope.call(subject.convert) - search_json = drive_json[:search] - - expect(search_json).to eq( - { ifNotFound: "error", max: 1 } - ) - end - end - - context "if #alias is not configured for a drive" do - let(:drive) { {} } - include_examples "without alias", drive_result_scope - end - - context "if #encryption is not configured for a drive" do - let(:drive) { {} } - include_examples "without encryption", drive_result_scope - end - - context "if #filesystem is not configured for a drive" do - let(:drive) { {} } - include_examples "without filesystem", drive_result_scope - end - - context "if #ptable_type is not configured for a drive" do - let(:drive) { {} } - include_examples "without ptable_type", drive_result_scope - end - - context "if #partitions is not configured for a drive" do - let(:drive) { {} } - include_examples "without partitions", drive_result_scope - end - - context "if #search is configured for a drive" do - let(:drive) { { search: search } } - include_examples "with search", drive_result_scope - end - - context "if #alias is configured for a drive" do - let(:drive) { { alias: device_alias } } - include_examples "with alias", drive_result_scope - end - - context "if #encryption is configured for a drive" do - let(:drive) { { encryption: encryption } } - include_examples "with encryption", drive_result_scope - end - - context "if #filesystem is configured for a drive" do - let(:drive) { { filesystem: filesystem } } - include_examples "with filesystem", drive_result_scope - end - - context "if #ptable_type is configured for a drive" do - let(:drive) { { ptableType: ptableType } } - include_examples "with ptable_type", drive_result_scope - end - - context "if #partitions is configured for a drive" do - let(:drive) { { partitions: partitions } } - include_examples "with partitions", drive_result_scope, drive_scope - end + } end - context "if #volume_groups is configured" do - let(:config_json) do - { volumeGroups: volume_groups } - end - - let(:volume_groups) do - [ - volume_group, - { name: "vg2" } - ] - end - - let(:volume_group) { { name: "vg1" } } - - it "generates the expected JSON" do - volume_groups_json = subject.convert[:volumeGroups] - expect(volume_groups_json).to eq( - [ + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json).to eq( + { + boot: { + configure: true, + device: "disk1" + }, + drives: [ { - name: "vg1", - physicalVolumes: [], - logicalVolumes: [] - }, + search: { + ifNotFound: "error", + max: 1 + }, + alias: "disk1", + partitions: [ + { + alias: "p1", + size: { + min: 5.GiB.to_i, + max: 5.GiB.to_i + } + }, + { + alias: "p2", + size: { + min: 10.GiB.to_i, + max: 10.GiB.to_i + } + }, + { + alias: "p3", + size: { + min: 10.GiB.to_i, + max: 10.GiB.to_i + } + } + ] + } + ], + mdRaids: [ { - name: "vg2", - physicalVolumes: [], - logicalVolumes: [] + level: "raid0", + devices: ["p2", "p3"], + partitions: [] } - ] - ) - end - - vg_result_scope = proc { |c| c[:volumeGroups].first } - vg_scope = proc { |c| c.volume_groups.first } - - context "if #name is not configured for a volume group" do - let(:volume_group) { {} } - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json.keys).to_not include(:name) - end - end - - context "if #extent_size is not configured for a volume group" do - let(:volume_group) { {} } - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json.keys).to_not include(:extentSize) - end - end - - context "if #physical_volumes is not configured for a volume group" do - let(:volume_group) { {} } - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json[:physicalVolumes]).to eq([]) - end - end - - context "if #logical_volumes is not configured for a volume group" do - let(:volume_group) { {} } - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json[:logicalVolumes]).to eq([]) - end - end - - context "if #name is configured for a volume group" do - let(:volume_group) { { name: "test" } } - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json[:name]).to eq("test") - end - end - - context "if #extent_size is configured for a volume group" do - let(:volume_group) { { extentSize: "4 KiB" } } - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json[:extentSize]).to eq(4.KiB.to_i) - end - end - - context "if #physical_volumes is configured for a volume group" do - let(:volume_group) { { physicalVolumes: ["pv1", "pv2"] } } - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json[:physicalVolumes]).to eq(["pv1", "pv2"]) - end - - context "and #physical_volumes_devices is configured" do - let(:volume_group) do + ], + volumeGroups: [ { - physicalVolumes: [ - "pv1", - "pv2", + name: "test", + physicalVolumes: ["p1"], + logicalVolumes: [ { - generate: { - targetDevices: ["disk1"] + filesystem: { + mkfsOptions: [], + mountOptions: [], + path: "/", + reuseIfPossible: false } } ] } - end - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json[:physicalVolumes]).to eq( - [ - "pv1", - "pv2", - { generate: ["disk1"] } - ] - ) - end - - context "and #physical_volumes_encryption is configured" do - let(:volume_group) do - { - physicalVolumes: [ - "pv1", - "pv2", - { - generate: { - targetDevices: ["disk1"], - encryption: { - luks1: { password: "12345" } - } - } - } - ] - } - end - - it "generates the expected JSON" do - vg_json = vg_result_scope.call(subject.convert) - expect(vg_json[:physicalVolumes]).to eq( - [ - "pv1", - "pv2", - { - generate: { - targetDevices: ["disk1"], - encryption: { - luks1: { password: "12345" } - } - } - } - ] - ) - end - end - end - end - - context "if #logical_volumes is configured for a volume group" do - let(:volume_group) { { logicalVolumes: logical_volumes } } - - let(:logical_volumes) do - [ - logical_volume, - { name: "lv2" } ] - end - - let(:logical_volume) { { name: "lv1" } } - - it "generates the expected JSON" do - config_json = vg_result_scope.call(subject.convert) - expect(config_json[:logicalVolumes]).to eq( - [ - { - name: "lv1", - pool: false - }, - { - name: "lv2", - pool: false - } - ] - ) - end - - lv_result_scope = proc { |c| vg_result_scope.call(c)[:logicalVolumes].first } - lv_scope = proc { |c| vg_scope.call(c).logical_volumes.first } - - context "if #name is not configured for a logical volume" do - let(:logical_volume) { {} } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json.keys).to_not include(:name) - end - end - - context "if #stripes is not configured for a logical volume" do - let(:logical_volume) { {} } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json.keys).to_not include(:stripes) - end - end - - context "if #stripe_size is not configured for a logical volume" do - let(:logical_volume) { {} } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json.keys).to_not include(:stripeSize) - end - end - - context "if #pool is not configured for a logical volume" do - let(:logical_volume) { {} } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json[:pool]).to eq(false) - end - end - - context "if #used_pool is not configured for a logical volume" do - let(:logical_volume) { {} } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json.keys).to_not include(:usedPool) - end - end - - context "if #alias is not configured for a logical volume" do - let(:logical_volume) { {} } - include_examples "without alias", lv_result_scope - end - - context "if #size is not configured for a logical volume" do - let(:logical_volume) { {} } - include_examples "without size", lv_result_scope - end - - context "if #encryption is not configured for a logical volume" do - let(:logical_volume) { {} } - include_examples "without encryption", lv_result_scope - end - - context "if #filesystem is not configured for a logical volume" do - let(:logical_volume) { {} } - include_examples "without filesystem", lv_result_scope - end - - context "if #stripes is configured for a logical volume" do - let(:logical_volume) { { stripes: 10 } } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json[:stripes]).to eq(10) - end - end - - context "if #stripe_size is configured for a logical volume" do - let(:logical_volume) { { stripeSize: "4 KiB" } } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json[:stripeSize]).to eq(4.KiB.to_i) - end - end - - context "if #pool is configured for a logical volume" do - let(:logical_volume) { { pool: true } } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json[:pool]).to eq(true) - end - end - - context "if #used_pool is configured for a logical volume" do - let(:logical_volume) { { usedPool: "pool" } } - - it "generates the expected JSON" do - lv_json = lv_result_scope.call(subject.convert) - expect(lv_json[:usedPool]).to eq("pool") - end - end - - context "if #alias is configured for a logical volume" do - let(:logical_volume) { { alias: device_alias } } - include_examples "with alias", lv_result_scope - end - - context "if #size is configured for a logical volume" do - let(:logical_volume) { { size: size } } - include_examples "with size", lv_result_scope, lv_scope - end - - context "if #encryption is configured for a logical volume" do - let(:logical_volume) { { encryption: encryption } } - include_examples "with encryption", lv_result_scope - end - - context "if #filesystem is configured for a logical volume" do - let(:logical_volume) { { filesystem: filesystem } } - include_examples "with filesystem", lv_result_scope - end - end + } + ) end end end diff --git a/service/test/agama/storage/config_json_solver_test.rb b/service/test/agama/storage/config_json_solver_test.rb new file mode 100644 index 0000000000..a17f5b0e7d --- /dev/null +++ b/service/test/agama/storage/config_json_solver_test.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../test_helper" +require "agama/storage/config_json_solver" + +describe Agama::Storage::ConfigJSONSolver do + subject do + described_class.new(default_paths: default_paths, mandatory_paths: mandatory_paths) + end + + let(:default_paths) { [] } + let(:mandatory_paths) { [] } + + describe "#solve" do + shared_examples "generate" do |devices_key, volumes_key| + let(:default_paths) { ["/", "swap", "/home"] } + let(:mandatory_paths) { ["/", "swap"] } + + let(:config_json) do + { devices_key => devices } + end + + context "if '#{volumes_key}' contains a config with 'generate'" do + let(:devices) do + [ + { + volumes_key => [ + { generate: generate } + ] + } + ] + end + + context "with 'default' value" do + let(:generate) { "default" } + + it "adds '#{volumes_key}' for the default paths" do + subject.solve(config_json) + volumes_json = config_json[devices_key][0][volumes_key] + + expect(volumes_json).to contain_exactly( + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } }, + { filesystem: { path: "/home" } } + ) + end + end + + context "with 'mandatory' value" do + let(:generate) { "mandatory" } + + it "adds '#{volumes_key}' for the mandatory paths" do + subject.solve(config_json) + volumes_json = config_json[devices_key][0][volumes_key] + + expect(volumes_json).to contain_exactly( + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } } + ) + end + end + + context "with a 'generate' section" do + let(:generate) do + { + volumes_key => "mandatory", + encryption: { + luks2: { password: "12345" } + } + } + end + + it "adds '#{volumes_key}' with the specified properties" do + subject.solve(config_json) + volumes_json = config_json[devices_key][0][volumes_key] + + expect(volumes_json).to contain_exactly( + { + filesystem: { path: "/" }, + encryption: { + luks2: { password: "12345" } + } + }, + { + filesystem: { path: "swap" }, + encryption: { + luks2: { password: "12345" } + } + } + ) + end + end + end + + context "if '#{volumes_key}' contains several configs with 'generate'" do + let(:devices) do + [ + { + volumes_key => [ + { generate: "mandatory" }, + { generate: "default" } + ] + } + ] + end + + it "only adds '#{volumes_key}' for the first 'generate'" do + subject.solve(config_json) + volumes_json = config_json[devices_key][0][volumes_key] + + expect(volumes_json).to contain_exactly( + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } } + ) + end + end + + context "if several '#{devices_key}' contain '#{volumes_key}' with 'generate'" do + let(:devices) do + [ + { + volumes_key => [ + { generate: "mandatory" } + ] + }, + { + volumes_key => [ + { generate: "default" } + ] + } + ] + end + + it "only adds '#{volumes_key}' to the first '#{devices_key}' with a 'generate'" do + subject.solve(config_json) + devices_json = config_json[devices_key] + + expect(devices_json).to contain_exactly( + { + volumes_key => [ + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } } + ] + }, + { + volumes_key => [] + } + ) + end + end + + context "if '#{volumes_key}' already contains a config for any of the paths" do + let(:devices) do + [ + { + volumes_key => [ + { generate: "default" }, + { filesystem: { path: "/home" } } + ] + } + ] + end + + it "only adds '#{volumes_key}' for the the missing paths" do + subject.solve(config_json) + volumes_json = config_json[devices_key][0][volumes_key] + + expect(volumes_json).to contain_exactly( + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } }, + { filesystem: { path: "/home" } } + ) + end + end + + context "if other '#{devices_key}' config already contains any of the paths" do + let(:devices) do + [ + { + volumes_key => [ + { generate: "default" } + ] + }, + { + volumes_key => [ + { filesystem: { path: "/home" } } + ] + } + ] + end + + it "only adds '#{volumes_key}' for the the missing paths" do + subject.solve(config_json) + devices_json = config_json[devices_key] + + expect(devices_json).to contain_exactly( + { + volumes_key => [ + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } } + ] + }, + { + volumes_key => [ + { filesystem: { path: "/home" } } + ] + } + ) + end + end + end + + shared_examples "generate missing" do + |devices_key, volumes_key, other_devices_key, other_volumes_key| + let(:default_paths) { ["/", "swap", "/home"] } + let(:mandatory_paths) { ["/", "swap"] } + + context "if a '#{other_devices_key}' config already specifies any of the paths" do + let(:config_json) do + { + devices_key => [ + { + volumes_key => [ + { generate: "default" } + ] + } + ], + other_devices_key => [ + { + other_volumes_key => [ + { filesystem: { path: "swap" } } + ] + } + ] + } + end + + it "only adds '#{volumes_key}' for the the missing paths" do + subject.solve(config_json) + volumes_json = config_json[devices_key][0][volumes_key] + + expect(volumes_json).to contain_exactly( + { filesystem: { path: "/" } }, + { filesystem: { path: "/home" } } + ) + end + end + end + + context "if a drive generates partitions" do + include_examples "generate", :drives, :partitions + include_examples "generate missing", :drives, :partitions, :volumeGroups, :logicalVolumes + include_examples "generate missing", :drives, :partitions, :mdRaids, :partitions + end + + context "if a MD RAID generates partitions" do + include_examples "generate", :mdRaids, :partitions + include_examples "generate missing", :mdRaids, :partitions, :volumeGroups, :logicalVolumes + include_examples "generate missing", :mdRaids, :partitions, :drives, :partitions + end + + context "if a volume group generates logical volumes" do + shared_examples "do not generate logical volumes" do |devices_key, volumes_key| + let(:default_paths) { ["/", "swap", "/home"] } + let(:mandatory_paths) { ["/", "swap"] } + + context "if a '#{devices_key}' config specifies a 'generate'" do + let(:config_json) do + { + devices_key => [ + { + volumes_key => [ + { generate: "mandatory" } + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + { generate: "mandatory" } + ] + } + ] + } + end + + it "does not add 'logicalVolumes'" do + subject.solve(config_json) + logical_volumes_json = config_json[:volumeGroups][0][:logicalVolumes] + + expect(logical_volumes_json).to eq([]) + end + end + end + + include_examples "generate", :volumeGroups, :logicalVolumes + include_examples "generate missing", :volumeGroups, :logicalVolumes, :drives, :partitions + include_examples "generate missing", :volumeGroups, :logicalVolumes, :mdRaids, :partitions + include_examples "do not generate logical volumes", :drives, :partitions + include_examples "do not generate logical volumes", :mdRaids, :partitions + end + end +end diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index 3c5094898c..04a1efff69 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -118,904 +118,673 @@ context "if a config does not specify the boot device alias" do let(:config_json) do { - boot: { configure: configure_boot }, - drives: drives, - volumeGroups: volume_groups + boot: { configure: true }, + drives: [ + { + alias: "root", + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ] } end - let(:drives) { [] } - let(:volume_groups) { [] } + it "solves the boot device" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("root") + end + end - context "and boot is not set to be configured" do - let(:configure_boot) { false } + shared_examples "encryption" do |encryption_proc| + context "if some encryption properties are missing" do + let(:encryption) do + { + luks2: { password: "12345" } + } + end - it "does not set a boot device alias" do + it "completes the encryption config according to the product info" do subject.solve(config) - expect(config.boot.device.device_alias).to be_nil + + encryption = encryption_proc.call(config) + expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) + expect(encryption.password).to eq("12345") + expect(encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2I) end end + end - context "and boot is set to be configured" do - let(:configure_boot) { true } + shared_examples "filesystem" do |filesystem_proc| + context "if some filesystem properties are missing" do + let(:filesystem) { { path: "/" } } - context "and the boot device is not set to default" do - before do - config.boot.device.default = false - end + it "completes the filesystem config according to the product info" do + subject.solve(config) - it "does not set a boot device alias" do - subject.solve(config) - expect(config.boot.device.device_alias).to be_nil - end + filesystem = filesystem_proc.call(config) + expect(filesystem.type).to be_a(Agama::Storage::Configs::FilesystemType) + 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.type.btrfs.read_only?).to eq(true) + expect(filesystem.type.btrfs.default_subvolume).to eq("@") + expect(filesystem.type.btrfs.subvolumes).to all(be_a(Y2Storage::SubvolSpecification)) end + end - context "and the boot device is set to default" do - before do - config.boot.device.default = true - end - - context "and there is a root partition" do - let(:drives) do - [ - { - alias: device_alias, - partitions: [ - { - filesystem: { path: "/" } - } - ] - } - ] - end - - let(:device_alias) { "root" } - - it "sets the alias of the root device as boot device alias" do - subject.solve(config) - expect(config.boot.device.device_alias).to eq("root") - end - - context "and the root device has no alias" do - let(:device_alias) { nil } - - it "sets an alias to the root device" do - subject.solve(config) - drive = config.drives.first - expect(drive.alias).to_not be_nil - end - - it "sets the alias of the root device as boot device alias" do - subject.solve(config) - drive = config.drives.first - expect(config.boot.device.device_alias).to eq(drive.alias) - end - end - end + context "if some btrfs properties are missing" do + let(:filesystem) do + { + path: "/", + type: { + btrfs: { + snapshots: false + } + } + } + end - context "and there is a root logical volume" do - let(:volume_groups) do - [ - { - name: "system", - physicalVolumes: physical_volumes, - logicalVolumes: [ - { - filesystem: { path: "/" } - } - ] - } - ] - end + it "completes the btrfs config according to the product info" do + subject.solve(config) - context "and there are target devices for physical volumes" do - let(:drives) do - [ - { alias: "disk1" }, - { alias: "disk2" } - ] - end + filesystem = filesystem_proc.call(config) + btrfs = filesystem.type.btrfs + expect(btrfs.snapshots?).to eq(false) + expect(btrfs.read_only?).to eq(true) + expect(btrfs.default_subvolume).to eq("@") + expect(btrfs.subvolumes).to all(be_a(Y2Storage::SubvolSpecification)) + end + end + end - let(:physical_volumes) { [{ generate: ["disk2", "disk1"] }] } + shared_examples "block device" do |device_proc| + encryption_proc = proc { |c| device_proc.call(c).encryption } + include_examples "encryption", encryption_proc - it "sets the alias of the first target device as boot device alias" do - subject.solve(config) - expect(config.boot.device.device_alias).to eq("disk2") - end - end + filesystem_proc = proc { |c| device_proc.call(c).filesystem } + include_examples "filesystem", filesystem_proc + end - context "and there is no target device for physical volumes" do - let(:drives) do - [ - { - alias: "disk1", - partitions: [ - { alias: "p1" } - ] - }, - { - alias: device_alias, - partitions: [ - { alias: "p2" } - ] - }, - { alias: "disk3" } - ] - end + shared_examples "new volume size" do |volumes_proc| + let(:scenario) { "disks.yaml" } - let(:device_alias) { "disk2" } + let(:min_fallbacks) { ["/home"] } + let(:max_fallbacks) { ["/home"] } + let(:snapshots_increment) { "300%" } - context "and there is any partition as physical volume" do - let(:physical_volumes) { ["disk3", "p2", "p1"] } + let(:volumes) { [volume] } - it "sets the alias of the device of the first partition as boot device alias" do - subject.solve(config) - expect(config.boot.device.device_alias).to eq("disk2") - end + let(:volume) do + { + filesystem: { + type: volume_filesystem, + path: "/" + }, + size: size + } + end - context "and the device of the first partition has no alias" do - let(:device_alias) { nil } + let(:volume_filesystem) { "xfs" } - it "sets an alias to the device" do - subject.solve(config) - drive = config.drives[1] - expect(drive.alias).to_not be_nil - end + context "if size is missing" do + let(:size) { nil } - it "sets the alias of the device as boot device alias" do - subject.solve(config) - drive = config.drives[1] - expect(config.boot.device.device_alias).to eq(drive.alias) - end - end - end + context "and some paths are missing" do + it "sets a size adding the fallback sizes" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(true) + expect(volume.size.min).to eq(10.GiB) + expect(volume.size.max).to eq(Y2Storage::DiskSize.unlimited) + end - context "and there is no partition as physical volume" do - let(:physical_volumes) { ["disk1", "disk2"] } + context "and snapshots are enabled" do + let(:volume_filesystem) { "btrfs" } - it "does not set a boot device alias" do - subject.solve(config) - expect(config.boot.device.device_alias).to be_nil - end - end + it "sets a size adding the fallback and snapshots sizes" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(true) + expect(volume.size.min).to eq(40.GiB) + expect(volume.size.max).to eq(Y2Storage::DiskSize.unlimited) end end - context "and there is neither a partition nor a logical volume for root" do - let(:drives) do + context "and no paths are missing" do + let(:volumes) do [ + volume, { - alias: "disk1", - partitions: [ - { - filesystem: { path: "/test1" } - } - ] + filesystem: { path: "/home" } } ] end - let(:volume_groups) do - [ - { - name: "system", - physicalVolumes: [{ generate: ["disk1"] }], - logicalVolumes: [ - { - filesystem: { path: "/test2" } - } - ] - } - ] + it "sets a size ignoring the fallback sizes" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(true) + expect(volume.size.min).to eq(5.GiB) + expect(volume.size.max).to eq(10.GiB) end - it "does not set a boot device alias" do - subject.solve(config) - expect(config.boot.device.device_alias).to be_nil + context "and snapshots are enabled" do + let(:volume_filesystem) { "btrfs" } + + it "sets a size adding the snapshots size" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(true) + expect(volume.size.min).to eq(20.GiB) + expect(volume.size.max).to eq(40.GiB) + end end end end - end - end - context "if a config does not specify all the encryption properties" do - let(:config_json) do - { - drives: [ - { - encryption: { - luks2: { password: "12345" } - } - } - ] - } - end + context "and has to be enlarged according to RAM size" do + before do + allow_any_instance_of(Y2Storage::Arch).to receive(:ram_size).and_return(8.GiB) + end - it "completes the encryption config according to the product info" do - subject.solve(config) + let(:volume) do + { filesystem: { path: "swap" } } + end - drive = config.drives.first - encryption = drive.encryption - expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(encryption.password).to eq("12345") - expect(encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2I) + it "sets the RAM size" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(true) + expect(volume.size.min).to eq(8.GiB) + expect(volume.size.max).to eq(8.GiB) + end + end end - end - context "if a volume group does not specify all the pv encryption properties" do - let(:config_json) do - { - volumeGroups: [ - { - physicalVolumes: [ - { - generate: { - encryption: { - luks2: { password: "12345" } - } - } - } - ] - } - ] - } + context "if size is not missing" do + let(:size) { { min: "10 GiB", max: "15 GiB" } } + + it "sets the given size" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(false) + expect(volume.size.min).to eq(10.GiB) + expect(volume.size.max).to eq(15.GiB) + end end - it "completes the encryption config according to the product info" do - subject.solve(config) + context "if min size is 'current'" do + let(:size) { { min: "current", max: "15 GiB" } } - volume_group = config.volume_groups.first - encryption = volume_group.physical_volumes_encryption - expect(encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(encryption.password).to eq("12345") - expect(encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2I) - end - end + let(:min_fallbacks) { [] } + let(:max_fallbacks) { [] } - context "if a config does not specify all the filesystem properties" do - let(:config_json) do - { - drives: [ - { - filesystem: { path: "/" } - } - ] - } + it "sets a size according to the product info" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(true) + expect(volume.size.min).to eq(5.GiB) + expect(volume.size.max).to eq(10.GiB) + end end - it "completes the filesystem config according to the product info" do - subject.solve(config) + context "if max size is 'current" do + let(:size) { { min: "10 GiB", max: "current" } } - drive = config.drives.first - filesystem = drive.filesystem - expect(filesystem.type).to be_a(Agama::Storage::Configs::FilesystemType) - 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.type.btrfs.read_only?).to eq(true) - expect(filesystem.type.btrfs.default_subvolume).to eq("@") - expect(filesystem.type.btrfs.subvolumes).to all(be_a(Y2Storage::SubvolSpecification)) - end - end + let(:min_fallbacks) { [] } + let(:max_fallbacks) { [] } - context "if a config does not specify all the btrfs properties" do - let(:config_json) do - { - drives: [ - { - filesystem: { - path: "/", - type: { - btrfs: { - snapshots: false - } - } - } - } - ] - } + it "sets a size according to the product info" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(true) + expect(volume.size.min).to eq(5.GiB) + expect(volume.size.max).to eq(10.GiB) + end end - it "completes the btrfs config according to the product info" do - subject.solve(config) + context "if max size is missing" do + let(:size) { { min: "10 GiB" } } - drive = config.drives.first - btrfs = drive.filesystem.type.btrfs - expect(btrfs.snapshots?).to eq(false) - expect(btrfs.read_only?).to eq(true) - expect(btrfs.default_subvolume).to eq("@") - expect(btrfs.subvolumes).to all(be_a(Y2Storage::SubvolSpecification)) + it "sets max size to unlimited" do + subject.solve(config) + volume = volumes_proc.call(config).first + expect(volume.size.default?).to eq(false) + expect(volume.size.min).to eq(10.GiB) + expect(volume.size.max).to eq(Y2Storage::DiskSize.unlimited) + end end end - partition_proc = proc { |c| c.drives.first.partitions.first } - - context "if a config does not specify size" do - let(:config_json) do - { - drives: [ + shared_examples "partition" do |partitions_proc| + context "for a partition" do + let(:partitions) do + [ { - partitions: partitions + encryption: encryption, + filesystem: filesystem } ] - } - end - - let(:partitions) do - [ - { - filesystem: { path: "/" } - }, - { - filesystem: { path: "/home" } - }, - {} - ] - end - - let(:scenario) { "disks.yaml" } + end - it "sets a size according to the product info" do - subject.solve(config) + let(:encryption) { nil } + let(:filesystem) { nil } - drive = config.drives.first - p1, p2, p3 = drive.partitions + partition_proc = proc { |c| partitions_proc.call(c).first } + include_examples "block device", partition_proc + end + end - expect(p1.size.default?).to eq(true) - expect(p1.size.min).to eq(5.GiB) - expect(p1.size.max).to eq(10.GiB) + shared_examples "new partition size" do |partitions_proc| + context "for a new partition" do + let(:partitions) { volumes } + include_examples "new volume size", partitions_proc + end + end - expect(p2.size.default?).to eq(true) - expect(p2.size.min).to eq(5.GiB) - expect(p2.size.max).to eq(Y2Storage::DiskSize.unlimited) + shared_examples "reused partition size" do |partitions_proc| + context "for a reused partition" do + let(:scenario) { "disks.yaml" } - expect(p3.size.default?).to eq(true) - expect(p3.size.min).to eq(100.MiB) - expect(p3.size.max).to eq(Y2Storage::DiskSize.unlimited) - end + # Enable fallbacks and snapshots to check they don't affect in this case. + let(:min_fallbacks) { ["/home"] } + let(:max_fallbacks) { ["/home"] } + let(:snapshots_increment) { "300%" } - context "and there is a device assigned" do let(:partitions) do [ { search: "/dev/vda2", - filesystem: { path: "/" } + filesystem: { path: "/" }, + size: size } ] end - # Enable fallbacks and snapshots to check they don't affect in this case. - let(:min_fallbacks) { ["/home"] } - let(:max_fallbacks) { ["/home"] } - let(:snapshots_increment) { "300%" } + context "if size is missing" do + let(:size) { nil } - it "sets the device size" do - subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) - expect(partition.size.min).to eq(20.GiB) - expect(partition.size.max).to eq(20.GiB) + it "sets the device size" do + subject.solve(config) + partition = partitions_proc.call(config).first + expect(partition.size.default?).to eq(true) + expect(partition.size.min).to eq(20.GiB) + expect(partition.size.max).to eq(20.GiB) + end end - end - - context "and the product defines size fallbacks" do - let(:min_fallbacks) { ["/home"] } - let(:max_fallbacks) { ["/home"] } - let(:snapshots_increment) { "300%" } - context "and the config does not specify some of the paths" do - let(:partitions) do - [ - { - filesystem: { - type: "xfs", - path: "/" - } - } - ] - end + context "if size is not missing" do + let(:size) { { min: "10 GiB", max: "15 GiB" } } - it "sets a size adding the fallback sizes" do + it "sets the given size" do subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) + partition = partitions_proc.call(config).first + expect(partition.size.default?).to eq(false) expect(partition.size.min).to eq(10.GiB) - expect(partition.size.max).to eq(Y2Storage::DiskSize.unlimited) + expect(partition.size.max).to eq(15.GiB) end + end - context "and snapshots are enabled" do - let(:partitions) do - [ - { - filesystem: { - type: "btrfs", - path: "/" - } - } - ] - end + context "if min size is 'current'" do + let(:size) { { min: "current", max: "40 GiB" } } - it "sets a size adding the fallback and snapshots sizes" do - subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) - expect(partition.size.min).to eq(40.GiB) - expect(partition.size.max).to eq(Y2Storage::DiskSize.unlimited) - end + it "sets the device size as min size" do + subject.solve(config) + partition = partitions_proc.call(config).first + expect(partition.size.default?).to eq(false) + expect(partition.size.min).to eq(20.GiB) + expect(partition.size.max).to eq(40.GiB) end end - context "and the config specifies the fallback paths" do - let(:partitions) do - [ - { - filesystem: { - type: filesystem, - path: "/" - } - }, - { - filesystem: { path: "/home" } - } - ] - end + context "if max size is 'current'" do + let(:size) { { min: "10 GiB", max: "current" } } - let(:filesystem) { "xfs" } - - it "sets a size ignoring the fallback sizes" do + it "sets the device size as max size" do subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) - expect(partition.size.min).to eq(5.GiB) - expect(partition.size.max).to eq(10.GiB) + partition = partitions_proc.call(config).first + expect(partition.size.default?).to eq(false) + expect(partition.size.min).to eq(10.GiB) + expect(partition.size.max).to eq(20.GiB) end + end - context "and snapshots are enabled" do - let(:filesystem) { "btrfs" } + context "if max size is missing" do + let(:size) { { min: "10 GiB" } } - it "sets a size adding the snapshots size" do - subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) - expect(partition.size.min).to eq(20.GiB) - expect(partition.size.max).to eq(40.GiB) - end + it "sets max size to unlimited" do + subject.solve(config) + partition = partitions_proc.call(config).first + 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 "and the volume has to be enlarged according to RAM size" do - before do - allow_any_instance_of(Y2Storage::Arch).to receive(:ram_size).and_return(8.GiB) - end - - let(:partitions) do - [ - { - filesystem: { path: "swap" } - } - ] - end + context "for a drive" do + let(:config_json) { { drives: drives } } - it "sets the RAM size" do - subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) - expect(partition.size.min).to eq(8.GiB) - expect(partition.size.max).to eq(8.GiB) - end + let(:drives) do + [ + { + encryption: encryption, + filesystem: filesystem, + partitions: partitions + } + ] end - end - context "if a config specifies a size" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { - search: search, - filesystem: { path: "/" }, - size: { - min: "10 GiB", - max: "15 GiB" - } - } - ] - } - ] - } - end + let(:encryption) { nil } + let(:filesystem) { nil } + let(:partitions) { [] } - let(:scenario) { "disks.yaml" } + encryption_proc = proc { |c| c.drives.first.encryption } + include_examples "encryption", encryption_proc - # Enable fallbacks and snapshots to check they don't affect in this case. - let(:min_fallbacks) { ["/home"] } - let(:max_fallbacks) { ["/home"] } - let(:snapshots_increment) { "300%" } + filesystem_proc = proc { |c| c.drives.first.filesystem } + include_examples "filesystem", filesystem_proc - context "and there is no device assigned" do - let(:search) { nil } + partitions_proc = proc { |c| c.drives.first.partitions } + include_examples "partition", partitions_proc + include_examples "new partition size", partitions_proc + include_examples "reused partition size", partitions_proc - it "sets the given size" 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(15.GiB) + context "if a drive omits the search" do + let(:drives) do + [ + {}, + {}, + {} + ] end - end - context "and there is a device assigned" do - let(:search) { "/dev/vda2" } + let(:scenario) { "disks.yaml" } - it "sets the given size" do + it "sets the first unassigned device to the drive" 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(15.GiB) + search1, search2, search3 = config.drives.map(&:search) + expect(search1.solved?).to eq(true) + expect(search1.device.name).to eq("/dev/vda") + expect(search2.solved?).to eq(true) + expect(search2.device.name).to eq("/dev/vdb") + expect(search3.solved?).to eq(true) + expect(search3.device.name).to eq("/dev/vdc") end - end - end - context "if a config specifies 'current' for min size" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { - search: search, - filesystem: { path: "/" }, - size: { - min: "current", - max: "40 GiB" - } - } - ] - } - ] - } - end + context "and any of the devices are excluded from the list of candidate devices" do + let(:disk_analyzer) { instance_double(Y2Storage::DiskAnalyzer) } + before do + allow(disk_analyzer).to receive(:candidate_disks).and_return [ + devicegraph.find_by_name("/dev/vdb"), devicegraph.find_by_name("/dev/vdc") + ] + end - context "and there is no device assigned" do - let(:search) { nil } + it "sets the first unassigned candidate devices to the drive" do + subject.solve(config) + searches = config.drives.map(&:search) + expect(searches[0].solved?).to eq(true) + expect(searches[0].device.name).to eq("/dev/vdb") + expect(searches[1].solved?).to eq(true) + expect(searches[1].device.name).to eq("/dev/vdc") + end - it "sets a size according to the product info" do - subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) - expect(partition.size.min).to eq(5.GiB) - expect(partition.size.max).to eq(10.GiB) + it "does not set devices that are not installation candidates" do + subject.solve(config) + searches = config.drives.map(&:search) + expect(searches[2].solved?).to eq(true) + expect(searches[2].device).to be_nil + end end - end - context "and there is a device assigned" do - let(:scenario) { "disks.yaml" } - - let(:search) { "/dev/vda2" } + context "and there is not unassigned device" do + let(:drives) do + [ + {}, + {}, + {}, + {} + ] + end - it "sets the device size as min size" do - subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(false) - expect(partition.size.min).to eq(20.GiB) - expect(partition.size.max).to eq(40.GiB) + it "does not set a device to the drive" do + subject.solve(config) + search = config.drives[3].search + expect(search.solved?).to eq(true) + expect(search.device).to be_nil + end end end - end - context "if a config specifies 'current' for max size" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { - search: search, - filesystem: { path: "/" }, - size: { - min: "10 GiB", - max: "current" - } - } - ] - } + context "if a drive contains an empty search" do + let(:drives) do + [ + { search: {} } ] - } - end - - context "and there is no device assigned" do - let(:search) { nil } - - it "sets a size according to the product info" do - subject.solve(config) - partition = partition_proc.call(config) - expect(partition.size.default?).to eq(true) - expect(partition.size.min).to eq(5.GiB) - expect(partition.size.max).to eq(10.GiB) end - end - context "and there is a device assigned" do let(:scenario) { "disks.yaml" } - let(:search) { "/dev/vda2" } - - it "sets the device size as max size" do + it "expands the number of drives to match all the existing disks" 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(20.GiB) + expect(config.drives.size).to eq 3 + search1, search2, search3 = config.drives.map(&:search) + expect(search1.solved?).to eq(true) + expect(search1.device.name).to eq("/dev/vda") + expect(search2.solved?).to eq(true) + expect(search2.device.name).to eq("/dev/vdb") + expect(search3.solved?).to eq(true) + expect(search3.device.name).to eq("/dev/vdc") 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" - } - } - ] - } + context "if a drive contains a search with '*'" do + let(:drives) do + [ + { search: "*" } ] - } - 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 + it "expands the number of drives to match all the existing disks" 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) + expect(config.drives.size).to eq 3 + expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) + expect(config.drives.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda", "/dev/vdb", "/dev/vdc"] end end - end - end - - context "if a drive omits the search" do - let(:config_json) { { drives: drives } } - - let(:drives) do - [ - {}, - {}, - {} - ] - end - let(:scenario) { "disks.yaml" } - - it "sets the first unassigned device to the drive" do - subject.solve(config) - search1, search2, search3 = config.drives.map(&:search) - expect(search1.solved?).to eq(true) - expect(search1.device.name).to eq("/dev/vda") - expect(search2.solved?).to eq(true) - expect(search2.device.name).to eq("/dev/vdb") - expect(search3.solved?).to eq(true) - expect(search3.device.name).to eq("/dev/vdc") - end + context "if a drive contains a search with no conditions but with a max" do + let(:drives) do + [ + { search: { max: max } } + ] + end - context "and any of the devices are excluded from the list of candidate devices" do - let(:disk_analyzer) { instance_double(Y2Storage::DiskAnalyzer) } - before do - allow(disk_analyzer).to receive(:candidate_disks).and_return [ - devicegraph.find_by_name("/dev/vdb"), devicegraph.find_by_name("/dev/vdc") - ] - end + let(:scenario) { "disks.yaml" } - it "sets the first unassigned candidate devices to the drive" do - subject.solve(config) - searches = config.drives.map(&:search) - expect(searches[0].solved?).to eq(true) - expect(searches[0].device.name).to eq("/dev/vdb") - expect(searches[1].solved?).to eq(true) - expect(searches[1].device.name).to eq("/dev/vdc") - end + context "and the max is equal or smaller than the number of disks" do + let(:max) { 2 } - it "does not set devices that are not installation candidates" do - subject.solve(config) - searches = config.drives.map(&:search) - expect(searches[2].solved?).to eq(true) - expect(searches[2].device).to be_nil - end - end + it "expands the number of drives to match the max" do + subject.solve(config) + expect(config.drives.size).to eq 2 + expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) + expect(config.drives.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda", "/dev/vdb"] + end + end - context "and there is not unassigned device" do - let(:drives) do - [ - {}, - {}, - {}, - {} - ] - end + context "and the max is bigger than the number of disks" do + let(:max) { 20 } - it "does not set a device to the drive" do - subject.solve(config) - search = config.drives[3].search - expect(search.solved?).to eq(true) - expect(search.device).to be_nil + it "expands the number of drives to match all the existing disks" do + subject.solve(config) + expect(config.drives.size).to eq 3 + expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) + expect(config.drives.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda", "/dev/vdb", "/dev/vdc"] + end + end end - end - end - - context "if a drive contains an empty search" do - let(:config_json) { { drives: drives } } - let(:drives) do - [ - { search: {} } - ] - end - - let(:scenario) { "disks.yaml" } + context "if a drive has a search with a device name" do + let(:drives) do + [ + { search: search } + ] + end - it "expands the number of drives to match all the existing disks" do - subject.solve(config) - expect(config.drives.size).to eq 3 - search1, search2, search3 = config.drives.map(&:search) - expect(search1.solved?).to eq(true) - expect(search1.device.name).to eq("/dev/vda") - expect(search2.solved?).to eq(true) - expect(search2.device.name).to eq("/dev/vdb") - expect(search3.solved?).to eq(true) - expect(search3.device.name).to eq("/dev/vdc") - end - end + let(:scenario) { "disks.yaml" } - context "if a drive contains a search with '*'" do - let(:config_json) { { drives: drives } } + context "and the device is found" do + let(:search) { "/dev/vdb" } - let(:drives) do - [ - { search: "*" } - ] - end + it "sets the device to the drive" do + subject.solve(config) + search = config.drives.first.search + expect(search.solved?).to eq(true) + expect(search.device.name).to eq("/dev/vdb") + end + end - let(:scenario) { "disks.yaml" } + context "and the device is not found" do + let(:search) { "/dev/vdd" } - it "expands the number of drives to match all the existing disks" do - subject.solve(config) - expect(config.drives.size).to eq 3 - expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) - expect(config.drives.map(&:search).map(&:device).map(&:name)) - .to eq ["/dev/vda", "/dev/vdb", "/dev/vdc"] - end - end + # Speed-up fallback search (and make sure it fails) + before { allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) } - context "if a drive contains a search with no conditions but with a max" do - let(:config_json) { { drives: drives } } + it "does not set a device to the drive" do + subject.solve(config) + search = config.drives.first.search + expect(search.solved?).to eq(true) + expect(search.device).to be_nil + end + end - let(:drives) do - [ - { search: { max: max } } - ] - end + context "and the device was already assigned" do + let(:drives) do + [ + {}, + { search: "/dev/vda" } + ] + end - let(:scenario) { "disks.yaml" } + it "does not set a device to the drive" do + subject.solve(config) + search = config.drives[1].search + expect(search.solved?).to eq(true) + expect(search.device).to be_nil + end + end - context "and the max is equal or smaller than the number of disks" do - let(:max) { 2 } + context "and there is other drive with the same device" do + let(:drives) do + [ + { search: "/dev/vdb" }, + { search: "/dev/vdb" } + ] + end - it "expands the number of drives to match the max" do - subject.solve(config) - expect(config.drives.size).to eq 2 - expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) - expect(config.drives.map(&:search).map(&:device).map(&:name)) - .to eq ["/dev/vda", "/dev/vdb"] + it "only sets the device to the first drive" do + subject.solve(config) + search1, search2 = config.drives.map(&:search) + expect(search1.solved?).to eq(true) + expect(search1.device.name).to eq("/dev/vdb") + expect(search2.solved?).to eq(true) + expect(search2.device).to be_nil + end + end end end - context "and the max is bigger than the number of disks" do - let(:max) { 20 } - - it "expands the number of drives to match all the existing disks" do - subject.solve(config) - expect(config.drives.size).to eq 3 - expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) - expect(config.drives.map(&:search).map(&:device).map(&:name)) - .to eq ["/dev/vda", "/dev/vdb", "/dev/vdc"] + context "for a MD RAID" do + let(:config_json) do + { + mdRaids: [ + { + encryption: encryption, + filesystem: filesystem, + partitions: partitions + } + ] + } end - end - end - context "if a drive has a search with a device name" do - let(:config_json) { { drives: drives } } + let(:encryption) { nil } + let(:filesystem) { nil } + let(:partitions) { [] } - let(:drives) do - [ - { search: search } - ] - end + encryption_proc = proc { |c| c.md_raids.first.encryption } + include_examples "encryption", encryption_proc - let(:scenario) { "disks.yaml" } + filesystem_proc = proc { |c| c.md_raids.first.filesystem } + include_examples "filesystem", filesystem_proc - context "and the device is found" do - let(:search) { "/dev/vdb" } + partitions_proc = proc { |c| c.md_raids.first.partitions } + include_examples "partition", partitions_proc + include_examples "new partition size", partitions_proc + end - it "sets the device to the drive" do - subject.solve(config) - search = config.drives.first.search - expect(search.solved?).to eq(true) - expect(search.device.name).to eq("/dev/vdb") + context "for a volume group" do + let(:config_json) do + { + volumeGroups: [ + { + physicalVolumes: physical_volumes, + logicalVolumes: logical_volumes + } + ] + } end - end - context "and the device is not found" do - let(:search) { "/dev/vdd" } + let(:physical_volumes) { [] } + let(:logical_volumes) { [] } - # Speed-up fallback search (and make sure it fails) - before { allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) } + context "if encryption is specified for physical volumes" do + let(:physical_volumes) do + [ + { + generate: { + encryption: encryption + } + } + ] + end - it "does not set a device to the drive" do - subject.solve(config) - search = config.drives.first.search - expect(search.solved?).to eq(true) - expect(search.device).to be_nil + encryption_proc = proc { |c| c.volume_groups.first.physical_volumes_encryption } + include_examples "encryption", encryption_proc end - end - context "and the device was already assigned" do - let(:drives) do - [ - {}, - { search: "/dev/vda" } - ] - end + context "for a logical volume" do + let(:logical_volumes) do + [ + { + encryption: encryption, + filesystem: filesystem + } + ] + end - it "does not set a device to the drive" do - subject.solve(config) - search = config.drives[1].search - expect(search.solved?).to eq(true) - expect(search.device).to be_nil - end - end + let(:encryption) { nil } + let(:filesystem) { nil } - context "and there is other drive with the same device" do - let(:drives) do - [ - { search: "/dev/vdb" }, - { search: "/dev/vdb" } - ] + logical_volume_proc = proc { |c| c.volume_groups.first.logical_volumes.first } + include_examples "block device", logical_volume_proc end - it "only sets the device to the first drive" do - subject.solve(config) - search1, search2 = config.drives.map(&:search) - expect(search1.solved?).to eq(true) - expect(search1.device.name).to eq("/dev/vdb") - expect(search2.solved?).to eq(true) - expect(search2.device).to be_nil + context "for a new logical volume" do + let(:logical_volumes) { volumes } + + logical_volumes_proc = proc { |c| c.volume_groups.first.logical_volumes } + include_examples "new volume size", logical_volumes_proc end end end diff --git a/service/test/agama/storage/config_solvers/boot_test.rb b/service/test/agama/storage/config_solvers/boot_test.rb new file mode 100644 index 0000000000..dc39249a6d --- /dev/null +++ b/service/test/agama/storage/config_solvers/boot_test.rb @@ -0,0 +1,362 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/config" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_solvers/boot" + +describe Agama::Storage::ConfigSolvers::Boot do + subject { described_class.new(product_config) } + + let(:product_config) { Agama::Config.new({}) } + + describe "#solve" do + let(:config_json) { nil } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + context "if a config does not specify the boot device alias" do + let(:config_json) do + { + boot: { configure: configure_boot }, + drives: drives, + mdRaids: md_raids, + volumeGroups: volume_groups + } + end + + let(:drives) { [] } + let(:md_raids) { [] } + let(:volume_groups) { [] } + + context "and boot is not set to be configured" do + let(:configure_boot) { false } + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + + context "and boot is set to be configured" do + let(:configure_boot) { true } + + context "and the boot device is not set to default" do + before do + config.boot.device.default = false + end + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + + context "and the boot device is set to default" do + before do + config.boot.device.default = true + end + + context "and there is a drive containing a root partition" do + let(:drives) do + [ + { + alias: device_alias, + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ] + end + + let(:device_alias) { "root" } + + it "sets the alias of the root device as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("root") + end + + context "and the root device has no alias" do + let(:device_alias) { nil } + + it "sets an alias to the root device" do + subject.solve(config) + drive = config.drives.first + expect(drive.alias).to_not be_nil + end + + it "sets the alias of the root device as boot device alias" do + subject.solve(config) + drive = config.drives.first + expect(config.boot.device.device_alias).to eq(drive.alias) + end + end + end + + context "and there is a MD RAID containing a root partition" do + let(:md_raids) do + [ + { + alias: "root", + partitions: [ + { + filesystem: { path: "/" } + } + ], + devices: md_devices + } + ] + end + + let(:device_alias) { "root" } + let(:md_devices) { [] } + + context "and a partition is used as MD RAID device member" do + let(:drives) do + [ + { + alias: device_alias, + partitions: [ + { alias: "p1" } + ] + }, + { alias: "disk2" } + ] + end + + let(:device_alias) { "disk1" } + + let(:md_devices) { ["disk2", "p1"] } + + it "sets the alias of the drive as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk1") + end + + context "and the drive has no alias" do + let(:device_alias) { nil } + + it "sets an alias to the drive" do + subject.solve(config) + drive = config.drives.first + expect(drive.alias).to_not be_nil + end + + it "sets the alias of the drive device as boot device alias" do + subject.solve(config) + drive = config.drives.first + expect(config.boot.device.device_alias).to eq(drive.alias) + end + end + end + + context "and whole disks are used as MD RAID device members" do + let(:drives) do + [ + { alias: "disk1" }, + { alias: "disk2" } + ] + end + + let(:md_devices) { ["disk1", "disk2"] } + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + end + + context "and there is a root logical volume" do + let(:volume_groups) do + [ + { + name: "system", + physicalVolumes: physical_volumes, + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + ] + end + + context "and there is a drive as target for physical volumes" do + let(:drives) do + [ + { alias: "disk1" }, + { alias: "disk2" } + ] + end + + let(:physical_volumes) { [{ generate: ["disk2", "disk1"] }] } + + it "sets the alias of the first target drive as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk2") + end + end + + context "and there is a MD RAID as target for physical volumes" do + let(:md_raids) do + [ + { + alias: "md1", + devices: ["p1"] + } + ] + end + + let(:drives) do + [ + { + alias: "disk1", + partitions: [ + { alias: "p1" } + ] + } + ] + end + + let(:physical_volumes) { [{ generate: ["md1"] }] } + + it "sets the alias of the first drive used as MD RAID member as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk1") + end + end + + context "and there is no target device for physical volumes" do + let(:drives) do + [ + { + alias: "disk1", + partitions: [ + { alias: "p1" } + ] + }, + { + alias: device_alias, + partitions: [ + { alias: "p2" } + ] + }, + { alias: "disk3" } + ] + end + + let(:device_alias) { "disk2" } + + context "and there is any partition as physical volume" do + let(:physical_volumes) { ["disk3", "p2", "p1"] } + + it "sets the alias of the drive of the first partition as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk2") + end + + context "and the drive of the first partition has no alias" do + let(:device_alias) { nil } + + it "sets an alias to the drive" do + subject.solve(config) + drive = config.drives[1] + expect(drive.alias).to_not be_nil + end + + it "sets the alias of the drive as boot device alias" do + subject.solve(config) + drive = config.drives[1] + expect(config.boot.device.device_alias).to eq(drive.alias) + end + end + end + + context "and there is no partition as physical volume" do + let(:physical_volumes) { ["disk1", "disk2"] } + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + end + end + + context "and there is neither a partition nor a logical volume for root" do + let(:drives) do + [ + { + alias: "disk1", + partitions: [ + { + filesystem: { path: "/test1" } + } + ] + } + ] + end + + let(:md_raids) do + [ + { + alias: "md1", + partitions: [ + { + filesystem: { path: "/test2" } + } + ] + } + ] + end + + let(:volume_groups) do + [ + { + name: "system", + physicalVolumes: [{ generate: ["disk1"] }], + logicalVolumes: [ + { + filesystem: { path: "/test3" } + } + ] + } + ] + end + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + end + end + end + end +end diff --git a/service/test/agama/storage/config_test.rb b/service/test/agama/storage/config_test.rb index b83b140c0b..c29301b031 100644 --- a/service/test/agama/storage/config_test.rb +++ b/service/test/agama/storage/config_test.rb @@ -98,7 +98,7 @@ end end - describe "#root_device" do + describe "#root_drive" do let(:config_json) do { drives: [ @@ -106,18 +106,13 @@ drive ], volumeGroups: [ - { name: "vg1" }, - volume_group - ] - } - end - - let(:root_volume_group) do - { - name: "vg2", - logicalVolumes: [ { - filesystem: { path: "/" } + name: "vg1", + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] } ] } @@ -131,11 +126,9 @@ } end - let(:volume_group) { root_volume_group } - it "returns the drive" do - expect(subject.root_device).to be_a(Agama::Storage::Configs::Drive) - expect(subject.root_device.alias).to eq("disk2") + expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_drive.alias).to eq("disk2") end end @@ -152,94 +145,77 @@ } end - let(:volume_group) { root_volume_group } - it "returns the drive" do - expect(subject.root_device).to be_a(Agama::Storage::Configs::Drive) - expect(subject.root_device.alias).to eq("disk2") + expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_drive.alias).to eq("disk2") end end context "if there is neither root drive nor root partition" do let(:drive) { {} } - context "and there is not a volume group containing a logical volume used for root" do - let(:volume_group) { {} } - - it "returns nil" do - expect(subject.root_device).to be_nil - end - end - - context "and there is a volume group containing a logical volume used for root" do - let(:volume_group) { root_volume_group } - - it "returns the volume group" do - expect(subject.root_device).to be_a(Agama::Storage::Configs::VolumeGroup) - expect(subject.root_device.name).to eq("vg2") - end + it "returns nil" do + expect(subject.root_drive).to be_nil end end end - describe "#root_drive" do + describe "#root_md_raid" do let(:config_json) do { - drives: [ - { alias: "disk1" }, - drive - ], - volumeGroups: [ + drives: [ { - name: "vg1", - logicalVolumes: [ + alias: "disk1", + partitions: [ { filesystem: { path: "/" } } ] } + ], + mdRaids: [ + md_raid ] } end - context "if there is a drive used for root" do - let(:drive) do + context "if there is a MD RAID used for root" do + let(:md_raid) do { - alias: "disk2", + alias: "md1", filesystem: { path: "/" } } end - it "returns the drive" do - expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) - expect(subject.root_drive.alias).to eq("disk2") + it "returns the MD RAID" do + expect(subject.root_md_raid).to be_a(Agama::Storage::Configs::MdRaid) + expect(subject.root_md_raid.alias).to eq("md1") end end - context "if there is a drive containing a partition used for root" do - let(:drive) do + context "if there is a MD RAID containing a partition used for root" do + let(:md_raid) do { - alias: "disk2", + alias: "md1", partitions: [ { - alias: "part1", filesystem: { path: "/" } } ] } end - it "returns the drive" do - expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) - expect(subject.root_drive.alias).to eq("disk2") + it "returns the MD RAID" do + expect(subject.root_md_raid).to be_a(Agama::Storage::Configs::MdRaid) + expect(subject.root_md_raid.alias).to eq("md1") end end - context "if there is neither root drive nor root partition" do - let(:drive) { {} } + context "if there is neither root MD RAID nor root partition" do + let(:md_raid) { {} } it "returns nil" do - expect(subject.root_drive).to be_nil + expect(subject.root_md_raid).to be_nil end end end @@ -311,7 +287,7 @@ { alias: "disk2", partitions: [ - { alias: "disk1" } + { alias: "part2" } ] } ] @@ -339,4 +315,642 @@ end end end + + describe "#md_raid" do + let(:config_json) do + { + mdRaids: [ + { + alias: "md1", + partitions: [ + { alias: "part1" } + ] + }, + { + alias: "md2", + partitions: [ + { alias: "part2" } + ] + } + ] + } + end + + context "if there is a MD RAID with the given alias" do + let(:device_alias) { "md1" } + + it "returns the MD RAID" do + md_raid = subject.md_raid(device_alias) + + expect(md_raid).to be_a(Agama::Storage::Configs::MdRaid) + expect(md_raid.alias).to eq(device_alias) + end + end + + context "if there is not a MD RAID with the given alias" do + let(:device_alias) { "part1" } + + it "returns nil" do + md_raid = subject.md_raid(device_alias) + + expect(md_raid).to be_nil + end + end + end + + describe "#partitions" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { alias: "p1" }, + { alias: "p2" } + ] + } + ], + mdRaids: [ + { + partitions: [ + { alias: "p3" } + ] + } + ] + } + end + + it "returns all partitions" do + partitions = subject.partitions + + expect(partitions.size).to eq(3) + expect(partitions.map(&:alias)).to contain_exactly("p1", "p2", "p3") + end + end + + describe "#logical_volumes" do + let(:config_json) do + { + volumeGroups: [ + { + logicalVolumes: [ + { name: "lv1" }, + { name: "lv2" } + ] + }, + { + logicalVolumes: [ + { name: "lv3" } + ] + } + ] + } + end + + it "returns all logical volumes" do + logical_volumes = subject.logical_volumes + + expect(logical_volumes.size).to eq(3) + expect(logical_volumes.map(&:name)).to contain_exactly("lv1", "lv2", "lv3") + end + end + + describe "#filesystems" do + let(:config_json) do + { + drives: [ + { + filesystem: { path: "/test1" } + }, + { + partitions: [ + { + filesystem: { path: "/test2" } + }, + { + filesystem: { path: "/test3" } + } + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + { + filesystem: { path: "/test4" } + } + ] + } + ], + mdRaids: [ + { + filesystem: { path: "/test5" } + }, + { + partitions: [ + { + filesystem: { path: "/test6" } + }, + { + filesystem: { path: "/test7" } + } + ] + } + ] + } + end + + it "returns all filesystems" do + filesystems = subject.filesystems + + expect(filesystems.size).to eq(7) + expect(filesystems.map(&:path)) + .to contain_exactly("/test1", "/test2", "/test3", "/test4", "/test5", "/test6", "/test7") + end + end + + describe "#supporting_encryption" do + let(:config_json) do + { + drives: [ + {}, + { + partitions: [ + {} + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + {} + ] + } + ], + mdRaids: [ + {}, + { + partitions: [ + {} + ] + } + ] + } + end + + it "returns all configs with configurable encryption" do + configs = subject.supporting_encryption + expect(configs.size).to eq(7) + end + + it "includes all drives" do + expect(subject.supporting_encryption).to include(*subject.drives) + end + + it "includes all MD RAIDs" do + expect(subject.supporting_encryption).to include(*subject.md_raids) + end + + it "includes all partitions" do + expect(subject.supporting_encryption).to include(*subject.partitions) + end + + it "includes all logical volumes" do + expect(subject.supporting_encryption).to include(*subject.logical_volumes) + end + + it "does not include volume groups" do + expect(subject.supporting_encryption).to_not include(*subject.volume_groups) + end + end + + describe "#supporting_filesystem" do + let(:config_json) do + { + drives: [ + {}, + { + partitions: [ + {} + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + {} + ] + } + ], + mdRaids: [ + {}, + { + partitions: [ + {} + ] + } + ] + } + end + + it "returns all configs with configurable filesystem" do + configs = subject.supporting_filesystem + expect(configs.size).to eq(7) + end + + it "includes all drives" do + expect(subject.supporting_filesystem).to include(*subject.drives) + end + + it "includes all MD RAIDs" do + expect(subject.supporting_filesystem).to include(*subject.md_raids) + end + + it "includes all partitions" do + expect(subject.supporting_filesystem).to include(*subject.partitions) + end + + it "includes all logical volumes" do + expect(subject.supporting_filesystem).to include(*subject.logical_volumes) + end + + it "does not include volume groups" do + expect(subject.supporting_filesystem).to_not include(*subject.volume_groups) + end + end + + describe "#supporting_size" do + let(:config_json) do + { + drives: [ + {}, + { + partitions: [ + {} + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + {} + ] + } + ], + mdRaids: [ + {}, + { + partitions: [ + {} + ] + } + ] + } + end + + it "returns all configs with configurable size" do + configs = subject.supporting_size + expect(configs.size).to eq(3) + end + + it "includes all partitions" do + expect(subject.supporting_size).to include(*subject.partitions) + end + + it "includes all logical volumes" do + expect(subject.supporting_size).to include(*subject.logical_volumes) + end + + it "does not include drives" do + expect(subject.supporting_size).to_not include(*subject.drives) + end + + it "does not include MD RAIDs" do + expect(subject.supporting_size).to_not include(*subject.md_raids) + end + + it "does not include volume groups" do + expect(subject.supporting_size).to_not include(*subject.volume_groups) + end + end + + describe "#supporting_partitions" do + let(:config_json) do + { + drives: [ + {}, + { + partitions: [ + {} + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + {} + ] + } + ], + mdRaids: [ + {}, + { + partitions: [ + {} + ] + } + ] + } + end + + it "returns all configs with configurable partitions" do + configs = subject.supporting_partitions + expect(configs.size).to eq(4) + end + + it "includes all drives" do + expect(subject.supporting_partitions).to include(*subject.drives) + end + + it "includes all MD RAIDs" do + expect(subject.supporting_partitions).to include(*subject.md_raids) + end + end + + describe "#potential_for_md_device" do + let(:config_json) do + { + drives: [ + { + alias: "disk1", + partitions: [ + { alias: "p1" }, + { alias: "p2" } + ] + }, + { + alias: "disk2" + } + ], + mdRaids: [ + { + alias: "md1", + partitions: [ + { alias: "p3" } + ] + } + ], + volumeGroups: [ + logicalVolumes: [ + { alias: "lv1" } + ] + ] + } + end + + it "returns the drives and partitions from drives" do + configs = subject.potential_for_md_device + expect(configs.map(&:alias)).to contain_exactly("disk1", "disk2", "p1", "p2") + end + end + + describe "#users" do + shared_examples "drive users" do |device_alias| + let(:config_json) do + { + drives: [ + drive, + { alias: "disk2" } + ], + mdRaids: md_raids, + volumeGroups: volume_groups + } + end + + let(:md_raids) { nil } + let(:volume_groups) { nil } + + context "and it is used as MD member device" do + let(:md_raids) do + [ + { devices: [device_alias] }, + { devices: ["disk2"] } + ] + end + + it "returns the MD RAID" do + users = subject.users(device_alias) + expect(users).to contain_exactly(subject.md_raids.first) + end + end + + context "and it is used as physical volume" do + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] }, + { physicalVolumes: ["disk2"] } + ] + end + + it "returns the volume group" do + users = subject.users(device_alias) + expect(users).to contain_exactly(subject.volume_groups.first) + end + end + + context "and it is used as MD RAID member and physical volume" do + let(:md_raids) do + [ + { devices: [device_alias] }, + { devices: ["disk2"] } + ] + end + + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] }, + { physicalVolumes: ["disk2"] } + ] + end + + it "returns the MD RAID and the volume group" do + users = subject.users(device_alias) + expect(users).to contain_exactly( + subject.md_raids.first, + subject.volume_groups.first + ) + end + end + + context "and it is not used neither as MD RAID member nor physical volume" do + let(:md_raids) do + [ + { devices: [] }, + { devices: ["disk2"] } + ] + end + + let(:volume_groups) do + [ + { physicalVolumes: [] }, + { physicalVolumes: ["disk2"] } + ] + end + + it "returns an empty list" do + users = subject.users(device_alias) + expect(users).to eq([]) + end + end + end + + shared_examples "md users" do |device_alias| + let(:config_json) do + { + mdRaids: [ + md_raid, + { alias: "md2" } + ], + volumeGroups: volume_groups + } + end + + let(:volume_groups) { nil } + + context "and it is used as physical volume" do + let(:volume_groups) do + [ + { physicalVolumes: [device_alias] }, + { physicalVolumes: ["md2"] } + ] + end + + it "returns the volume group" do + users = subject.users(device_alias) + expect(users).to contain_exactly(subject.volume_groups.first) + end + end + + context "and it is used as MD member device" do + let(:config_json) do + { + mdRaids: [ + md_raid, + { devices: [device_alias] } + ] + } + end + + it "returns an empty list" do + users = subject.users(device_alias) + expect(users).to eq([]) + end + end + end + + context "if there is a drive with the given alias" do + let(:drive) { { alias: "disk1" } } + + include_examples "drive users", "disk1" + end + + context "if there is a drive with a partition with the given alias" do + let(:drive) do + { + partitions: [ + { alias: "p1" } + ] + } + end + + include_examples "drive users", "p1" + end + + context "if there is a MD RAID with the given alias" do + let(:md_raid) { { alias: "md1" } } + + include_examples "md users", "md1" + end + + context "if there is a MD RAID with a partition with the given alias" do + let(:md_raid) do + { + partitions: [ + { alias: "p1" } + ] + } + end + + include_examples "md users", "p1" + end + end + + describe "#target_users" do + shared_examples "target users" do |device_alias| + context "and it is used as target for physical volumes" do + let(:volume_groups) do + [ + { + name: "vg1", + physicalVolumes: [{ generate: [device_alias] }] + }, + { name: "vg2" }, + { + name: "vg3", + physicalVolumes: [{ generate: [device_alias] }] + } + ] + end + + it "returns the volume groups" do + users = subject.target_users(device_alias) + expect(users).to contain_exactly(subject.volume_groups[0], subject.volume_groups[2]) + end + end + + context "and it is not used as target for physical volumes" do + let(:volume_groups) do + [ + { name: "vg1" } + ] + end + + it "returns an empty list" do + users = subject.target_users(device_alias) + expect(users).to eq([]) + end + end + end + + let(:config_json) do + { + drives: drives, + mdRaids: md_raids, + volumeGroups: volume_groups + } + end + + let(:drives) { nil } + let(:md_raids) { nil } + let(:volume_groups) { nil } + + context "if there is a drive with the given alias" do + let(:drives) do + [ + { alias: "disk1" } + ] + end + + include_examples "target users", "disk1" + end + + context "if there is a MD RAID with the given alias" do + let(:md_raids) do + [ + { alias: "md1" } + ] + end + + include_examples "target users", "md1" + end + end end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 4fc8b6a850..e56dc1a08b 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -184,6 +184,7 @@ def drive(partitions) ] } ], + mdRaids: [], volumeGroups: [] } } diff --git a/service/test/y2storage/agama_proposal_md_lvm.rb b/service/test/y2storage/agama_proposal_md_lvm.rb new file mode 100644 index 0000000000..a6d892d7ca --- /dev/null +++ b/service/test/y2storage/agama_proposal_md_lvm.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../agama/storage/storage_helpers" +require "agama/config" +require "agama/storage/config" +require "agama/storage/config_conversions/from_json" +require "y2storage" +require "y2storage/agama_proposal" + +describe Y2Storage::AgamaProposal do + using Y2Storage::Refinements::SizeCasts + include Agama::RSpec::StorageHelpers + + subject(:proposal) do + described_class.new(config, issues_list: issues_list) + end + + let(:config) { config_from_json } + + let(:config_from_json) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + let(:issues_list) { [] } + + before do + mock_storage(devicegraph: scenario) + # To speed-up the tests + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + end + + let(:scenario) { "empty-hd-50GiB.yaml" } + + describe "#propose" do + context "when the config defines a new LVM using a new RAID as PV" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + partitions: [ + { search: "*", delete: true }, + { alias: "sda1", size: { min: "1 MiB" } } + ] + }, + { + partitions: [ + { search: "*", delete: true }, + { alias: "sdb1", size: { min: "1 MiB" } } + ] + } + ], + mdRaids: [ + { + level: "raid1", + alias: "md", + devices: ["sda1", "sdb1"] + } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: ["md"], + logicalVolumes: [ + { name: "root", filesystem: { path: "/" } }, + { name: "swap", filesystem: { path: "swap" } } + ] + } + ], + boot: { configure: false } + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + expect(devicegraph.md_raids).to contain_exactly( + an_object_having_attributes(name: "/dev/md0", md_level: Y2Storage::MdLevel::RAID1) + ) + expect(devicegraph.lvm_vgs).to contain_exactly( + an_object_having_attributes(vg_name: "system") + ) + vg = devicegraph.lvm_vgs.first + expect(vg.lvm_lvs.map(&:name)).to contain_exactly( + "/dev/system/root", "/dev/system/swap" + ) + expect(vg.lvm_pvs.size).to eq 1 + expect(vg.lvm_pvs.first.blk_device.name).to eq "/dev/md0" + end + end + + context "when the config defines a new LVM generating PVs on a new RAID" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + partitions: [ + { search: "*", delete: true }, + { alias: "sda1", size: { min: "1 MiB" } } + ] + }, + { + partitions: [ + { search: "*", delete: true }, + { alias: "sdb1", size: { min: "1 MiB" } } + ] + } + ], + mdRaids: [ + { + level: "raid1", + alias: "md", + devices: ["sda1", "sdb1"] + } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [ + { generate: { targetDevices: ["md"] } } + ], + logicalVolumes: [ + { name: "root", size: { min: "10 GiB" }, filesystem: { path: "/" } }, + { name: "swap", size: "2 GiB", filesystem: { path: "swap" } } + ] + } + ], + boot: { configure: false } + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + expect(devicegraph.md_raids).to contain_exactly( + an_object_having_attributes(name: "/dev/md0", md_level: Y2Storage::MdLevel::RAID1) + ) + expect(devicegraph.lvm_vgs).to contain_exactly( + an_object_having_attributes(vg_name: "system") + ) + vg = devicegraph.lvm_vgs.first + expect(vg.lvm_lvs.map(&:name)).to contain_exactly( + "/dev/system/root", "/dev/system/swap" + ) + expect(vg.lvm_pvs.size).to eq 1 + expect(vg.lvm_pvs.first.blk_device.name).to eq "/dev/md0p1" + end + end + + context "when the config defines several LVM generating PVs on several new RAIDs" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + partitions: [ + { search: "*", delete: true }, + { alias: "sda1", size: { min: "1 MiB" } }, + { alias: "sda2", size: "10 GiB" } + ] + }, + { + partitions: [ + { search: "*", delete: true }, + { alias: "sdb1", size: { min: "1 MiB" } }, + { alias: "sdb2", size: "10 GiB" } + ] + } + ], + mdRaids: [ + { + level: "raid1", + alias: "md0", + devices: ["sda1", "sdb1"] + }, + { + level: "raid0", + alias: "md1", + devices: ["sdb2", "sda2"], + partitions: [ + { filesystem: { path: "/extra" }, size: "1GiB" } + ] + } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [ + { generate: { targetDevices: ["md0", "md1"] } } + ], + logicalVolumes: [ + { name: "root", size: { min: "20 GiB" }, filesystem: { path: "/" } }, + { name: "swap", size: "1 GiB", filesystem: { path: "swap" } } + ] + }, + { + name: "vg1", + physicalVolumes: [ + { + generate: { + targetDevices: ["md1"], + encryption: { luks2: { password: "s3cr3t" } } + } + } + ], + logicalVolumes: [ + { + name: "home", + filesystem: { + path: "/home", + type: "xfs" + }, + size: "3 GiB" + } + ] + } + ], + boot: { configure: false } + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + expect(devicegraph.md_raids).to contain_exactly( + an_object_having_attributes(name: "/dev/md0", md_level: Y2Storage::MdLevel::RAID1), + an_object_having_attributes(name: "/dev/md1", md_level: Y2Storage::MdLevel::RAID0) + ) + md0 = devicegraph.find_by_any_name("/dev/md0") + md0_formatted = md0.partitions.select(&:formatted?) + expect(md0_formatted.map(&:filesystem).map(&:mount_path)).to include "/extra" + + expect(devicegraph.lvm_vgs).to contain_exactly( + an_object_having_attributes(vg_name: "system"), + an_object_having_attributes(vg_name: "vg1") + ) + + vg_system = devicegraph.find_by_any_name("/dev/system") + expect(vg_system.lvm_lvs.map(&:name)).to contain_exactly( + "/dev/system/root", "/dev/system/swap" + ) + pvs_system = vg_system.lvm_pvs.map(&:blk_device) + expect(pvs_system.map(&:name)).to contain_exactly("/dev/md0p2", "/dev/md1p2") + + vg1 = devicegraph.find_by_any_name("/dev/vg1") + expect(vg1.lvm_lvs.map(&:name)).to contain_exactly("/dev/vg1/home") + pvs_vg1 = vg1.lvm_pvs.map(&:blk_device) + expect(pvs_vg1.map(&:name)).to contain_exactly("/dev/mapper/cr_md1p1") + end + end + end +end diff --git a/service/test/y2storage/agama_proposal_md_test.rb b/service/test/y2storage/agama_proposal_md_test.rb new file mode 100644 index 0000000000..b18808f1a7 --- /dev/null +++ b/service/test/y2storage/agama_proposal_md_test.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../agama/storage/storage_helpers" +require "agama/config" +require "agama/storage/config" +require "agama/storage/config_conversions/from_json" +require "y2storage" +require "y2storage/agama_proposal" + +describe Y2Storage::AgamaProposal do + using Y2Storage::Refinements::SizeCasts + include Agama::RSpec::StorageHelpers + + subject(:proposal) do + described_class.new(config, issues_list: issues_list) + end + + let(:config) { config_from_json } + + let(:config_from_json) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + let(:issues_list) { [] } + + before do + mock_storage(devicegraph: scenario) + # To speed-up the tests + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + end + + let(:scenario) { "empty-hd-50GiB.yaml" } + + describe "#propose" do + context "when the config has a partitioned MD Raid and a formatted one" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + partitions: [ + { search: "*", delete: true }, + { alias: "sda1", size: { min: "1 MiB" } }, + { alias: "sda2", size: "10 GiB" } + ] + }, + { + partitions: [ + { search: "*", delete: true }, + { alias: "sdb1", size: { min: "1 MiB" } }, + { alias: "sdb2", size: "10 GiB" } + ] + } + ], + mdRaids: [ + { + level: "raid1", + devices: ["sda1", "sdb1"], + partitions: [ + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } } + ] + }, + { + level: "raid0", + name: "home", + devices: ["sdb2", "sda2"], + filesystem: { path: "/home" } + } + ], + boot: { configure: false } + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + expect(devicegraph.md_raids).to contain_exactly( + an_object_having_attributes(name: "/dev/md0", md_level: Y2Storage::MdLevel::RAID1), + an_object_having_attributes(name: "/dev/md/home", md_level: Y2Storage::MdLevel::RAID0) + ) + raid0 = devicegraph.find_by_name("/dev/md0") + raid_home = devicegraph.find_by_name("/dev/md/home") + + members0_size = 40.GiB - 2.MiB + expect(raid0.devices).to contain_exactly( + an_object_having_attributes(name: "/dev/vda1", size: members0_size), + an_object_having_attributes(name: "/dev/vdb1", size: members0_size) + ) + # Ensure proper order + expect(raid0.devices.map(&:name)).to eq ["/dev/vda1", "/dev/vdb1"] + + members_home_size = 10.GiB + 1.MiB - 16.5.KiB + expect(raid_home.devices).to contain_exactly( + an_object_having_attributes(name: "/dev/vda2", size: members_home_size), + an_object_having_attributes(name: "/dev/vdb2", size: members_home_size) + ) + # Ensure proper order + expect(raid_home.devices.map(&:name)).to eq ["/dev/vdb2", "/dev/vda2"] + + expect(raid0.partitions.size).to eq 2 + mount_points = raid0.partitions.map { |p| p.filesystem.mount_path } + expect(mount_points).to contain_exactly("/", "swap") + + expect(raid_home.partitions.size).to eq 0 + expect(raid_home.filesystem.mount_path).to eq "/home" + end + end + + context "when creating a MD Raid on top of several devices with the same alias" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + search: { max: 10 }, + alias: "all-disks" + } + ], + mdRaids: [ + { + level: "raid1", + devices: ["all-disks"], + filesystem: { path: "/" } + } + ], + boot: { configure: false } + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + expect(devicegraph.md_raids.size).to eq 1 + raid = devicegraph.md_raids.first + + expect(raid.devices.map(&:name)).to contain_exactly("/dev/vda", "/dev/vdb", "/dev/vdc") + expect(raid.filesystem.mount_path).to eq "/" + end + end + + context "when specifying chunk size and parity" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + search: { max: 3 }, + alias: "all-disks" + } + ], + mdRaids: [ + { + level: "raid5", + parity: "right_asymmetric", + chunkSize: "256KiB", + devices: ["all-disks"], + filesystem: { path: "/" } + } + ], + boot: { configure: false } + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + expect(devicegraph.md_raids.size).to eq 1 + raid = devicegraph.md_raids.first + + expect(raid.md_level).to eq Y2Storage::MdLevel::RAID5 + expect(raid.chunk_size).to eq 256.KiB + expect(raid.md_parity).to eq Y2Storage::MdParity::RIGHT_ASYMMETRIC + end + end + + context "when formatting an existing RAID" do + let(:scenario) { "partitioned_md.yml" } + + let(:config_json) do + { + mdRaids: [ + { + search: { max: 1 }, + filesystem: { path: "/" } + } + ], + boot: { configure: false } + } + end + + it "uses the RAID" do + md_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/md0").sid + proposal.propose + + md = proposal.devices.find_by_name("/dev/md0") + expect(md.sid).to eq md_sid + expect(md.partitions).to be_empty + expect(md.filesystem.mount_path).to eq "/" + end + end + + context "when deleting existing partitions from an existing RAID" do + let(:scenario) { "partitioned_md.yml" } + + let(:config_json) do + { + mdRaids: [ + { + search: { max: 1 }, + partitions: [ + { search: "*", delete: true }, + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } } + ] + } + ], + boot: { configure: false } + } + end + + it "uses the RAID for the new partitions" do + md_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/md0").sid + proposal.propose + + md = proposal.devices.find_by_name("/dev/md0") + expect(md.sid).to eq md_sid + expect(md.partitions.size).to eq 2 + paths = md.partitions.map(&:filesystem).map(&:mount_path) + expect(paths).to contain_exactly("/", "swap") + end + end + + context "when deleting existing partitions on demand from an existing RAID" do + let(:scenario) { "partitioned_md.yml" } + + let(:config_json) do + { + mdRaids: [ + { + search: { max: 1 }, + partitions: [ + { search: "*", deleteIfNeeded: true }, + { filesystem: { path: "/" }, size: parts_size }, + { filesystem: { path: "swap" }, size: parts_size } + ] + } + ], + boot: { configure: false } + } + end + + context "if there is no need to delete the partition" do + let(:parts_size) { "5 GiB" } + + it "keeps the partition and creates the new ones" do + md_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/md0").sid + proposal.propose + + md = proposal.devices.find_by_name("/dev/md0") + expect(md.sid).to eq md_sid + expect(md.partitions.size).to eq 3 + filesystems = md.partitions.map(&:filesystem) + expect(filesystems).to contain_exactly( + nil, + an_object_having_attributes(mount_path: "/"), + an_object_having_attributes(mount_path: "swap") + ) + end + end + + context "if the partition must be deleted" do + let(:parts_size) { "9.7 GiB" } + + it "deletes the original partition and creates the new ones" do + md_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/md0").sid + proposal.propose + + md = proposal.devices.find_by_name("/dev/md0") + expect(md.sid).to eq md_sid + expect(md.partitions.size).to eq 2 + paths = md.partitions.map(&:filesystem).map(&:mount_path) + expect(paths).to contain_exactly("/", "swap") + end + end + end + + context "when reusing partitions from an existing RAID" do + let(:scenario) { "partitioned_md.yml" } + + let(:config_json) do + { + mdRaids: [ + { + search: "/dev/md0", + partitions: [ + { search: { max: 1 }, filesystem: { path: "/" } } + ] + } + ], + boot: { configure: false } + } + end + + it "reuses the RAID and its partition" do + md_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/md0").sid + part_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/md0p1").sid + proposal.propose + + md = proposal.devices.find_by_name("/dev/md0") + expect(md.sid).to eq md_sid + expect(md.partitions.size).to eq 1 + partition = md.partitions.first + expect(partition.filesystem.mount_path).to eq "/" + expect(partition.sid).to eq part_sid + end + end + end +end