diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index fb598c9f44..bd0046e355 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -94,12 +94,14 @@ "additionalProperties": false, "required": ["vgName"], "properties": { + "name": { "type": "string" }, "vgName": { "type": "string" }, "extentSize": { "type": "integer" }, "targetDevices": { "type": "array", "items": { "type": "string" } }, + "spacePolicy": { "$ref": "#/$defs/spacePolicy" }, "logicalVolumes": { "type": "array", "items": { "$ref": "#/$defs/logicalVolume" } @@ -110,12 +112,17 @@ "type": "object", "additionalProperties": false, "properties": { + "name": { "type": "string" }, "lvName": { "type": "string" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, - "size": { "$ref": "#/$defs/size" }, "stripes": { "type": "integer" }, - "stripeSize": { "type": "integer" } + "stripeSize": { "type": "integer" }, + "size": { "$ref": "#/$defs/size" }, + "delete": { "type": "boolean" }, + "deleteIfNeeded": { "type": "boolean" }, + "resize": { "type": "boolean" }, + "resizeIfNeeded": { "type": "boolean" } } }, "spacePolicy": { diff --git a/rust/agama-lib/share/storage.schema.json b/rust/agama-lib/share/storage.schema.json index a95fa83dbb..9786b6dbdd 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -300,7 +300,9 @@ { "$ref": "#/$defs/advancedLogicalVolumesGenerator" }, { "$ref": "#/$defs/logicalVolume" }, { "$ref": "#/$defs/thinPoolLogicalVolume" }, - { "$ref": "#/$defs/thinLogicalVolume" } + { "$ref": "#/$defs/thinLogicalVolume" }, + { "$ref": "#/$defs/logicalVolumeToDelete" }, + { "$ref": "#/$defs/logicalVolumeToDeleteIfNeeded" } ] }, "advancedLogicalVolumesGenerator": { @@ -366,6 +368,31 @@ "filesystem": { "$ref": "#/$defs/filesystem" } } }, + "logicalVolumeToDelete": { + "type": "object", + "additionalProperties": false, + "required": ["delete", "search"], + "properties": { + "search": { "$ref": "#/$defs/deleteLogicalVolumeSearch" }, + "delete": { + "description": "Delete the logical volume.", + "const": true + } + } + }, + "logicalVolumeToDeleteIfNeeded": { + "type": "object", + "additionalProperties": false, + "required": ["deleteIfNeeded", "search"], + "properties": { + "search": { "$ref": "#/$defs/deleteLogicalVolumeSearch" }, + "deleteIfNeeded": { + "description": "Delete the logical volume if needed to make space.", + "const": true + }, + "size": { "$ref": "#/$defs/size" } + } + }, "logicalVolumeStripes": { "description": "Number of stripes.", "type": "integer", @@ -587,6 +614,23 @@ "ifNotFound": { "$ref": "#/$defs/searchCreatableActions" } } }, + "deleteLogicalVolumeSearch": { + "anyOf": [ + { "$ref": "#/$defs/searchAll" }, + { "$ref": "#/$defs/searchName" }, + { "$ref": "#/$defs/deleteLogicalVolumeAdvancedSearch" } + ] + }, + "deleteLogicalVolumeAdvancedSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { "$ref": "#/$defs/logicalVolumeSearchCondition" }, + "sort": { "$ref": "#/$defs/logicalVolumeSearchSort" }, + "max": { "$ref": "#/$defs/searchMax" }, + "ifNotFound": { "$ref": "#/$defs/searchActions" } + } + }, "logicalVolumeSearchCondition": { "anyOf": [ { "$ref": "#/$defs/searchConditionName" }, diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 98ab2ad486..8e5ad56be2 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Mon Apr 13 15:21:17 UTC 2026 - José Iván López González + +- Update storage schemas (gh#agama-project/agama#3380). + ------------------------------------------------------------------- Fri Apr 10 13:46:02 UTC 2026 - Ladislav Slezák diff --git a/rust/share/system.storage.schema.json b/rust/share/system.storage.schema.json index 8b74e16fed..975970c9f1 100644 --- a/rust/share/system.storage.schema.json +++ b/rust/share/system.storage.schema.json @@ -21,6 +21,11 @@ "type": "array", "items": { "type": "integer" } }, + "availableVolumeGroups": { + "description": "SIDs of the available LVM volume groups", + "type": "array", + "items": { "type": "integer" } + }, "candidateDrives": { "description": "SIDs of the drives that are candidate for installation", "type": "array", diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 584771f79a..b8980ae16e 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -399,15 +399,16 @@ def serialize_system return serialize_nil unless manager.probed? json = { - devices: devices_json(:probed), - availableDrives: available_drives, - availableMdRaids: available_md_raids, - candidateDrives: candidate_drives, - candidateMdRaids: candidate_md_raids, - issues: system_issues_json, - productMountPoints: product_mount_points, - encryptionMethods: encryption_methods, - volumeTemplates: volume_templates + devices: devices_json(:probed), + availableDrives: available_drives, + availableMdRaids: available_md_raids, + availableVolumeGroups: available_volume_groups, + candidateDrives: candidate_drives, + candidateMdRaids: candidate_md_raids, + issues: system_issues_json, + productMountPoints: product_mount_points, + encryptionMethods: encryption_methods, + volumeTemplates: volume_templates } JSON.pretty_generate(json) end @@ -525,6 +526,12 @@ def candidate_md_raids proposal.storage_system.candidate_md_raids.map(&:sid) end + # @see Storage::System#available_volume_groups + # @return [Array] + def available_volume_groups + proposal.storage_system.available_volume_groups.map(&:sid) + end + # Meaningful mount points for the current product. # # @return [Array] diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index a36456f286..3a51adbbfb 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -112,6 +112,11 @@ def partitionable(device_alias) supporting_partitions.find { |d| d.alias?(device_alias) } end + # @return [Array] + def volumes + partitions + logical_volumes + end + # @return [Array] def partitions supporting_partitions.flat_map(&:partitions) @@ -131,7 +136,7 @@ def filesystems # # @return [Array<#search>] def supporting_search - drives + md_raids + partitions + drives + md_raids + partitions + volume_groups + logical_volumes end # Configs with configurable encryption. @@ -166,7 +171,7 @@ def supporting_partitions # # @return [#delete?] def supporting_delete - partitions + partitions + logical_volumes end # Config objects that could act as physical volume @@ -223,6 +228,20 @@ def valid_partitions partitions.reject { |p| skipped?(p) } end + # Volume group configs, excluding skipped ones. + # + # @return [Array] + def valid_volume_groups + volume_groups.reject { |d| skipped?(d) } + end + + # Logical volume configs, excluding skipped ones. + # + # @return [Array] + def valid_logical_volumes + logical_volumes.reject { |d| skipped?(d) } + 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) @@ -242,6 +261,14 @@ def target_users(device_alias) [boot_target_user(device_alias), vg_target_users(device_alias)].flatten.compact end + # Finds the config assigned to the given device. + # + # @param device [Y2Storage::BlkDevice] + # @return [#search] + def find_device(device) + supporting_search.find { |c| c.found_device == device } + end + private # MD RAIDs using the given alias as member device. diff --git a/service/lib/agama/storage/config_checkers/logical_volume.rb b/service/lib/agama/storage/config_checkers/logical_volume.rb index accc0a4411..c6cf98190d 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-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -20,8 +20,10 @@ # 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" require "yast/i18n" module Agama @@ -30,18 +32,22 @@ module ConfigCheckers # Class for checking a logical volume config. class LogicalVolume < Base include Yast::I18n + include WithAlias include WithEncryption include WithFilesystem + include WithSearch # @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, product_config) + def initialize(config, volume_group_config, storage_config, product_config) super() textdomain "agama" @config = config @volume_group_config = volume_group_config + @storage_config = storage_config @product_config = product_config end @@ -50,6 +56,8 @@ def initialize(config, volume_group_config, product_config) # @return [Array] def issues [ + alias_issues, + search_issues, filesystem_issues, encryption_issues, missing_thin_pool_issue @@ -64,6 +72,9 @@ def issues # @return [Configs::VolumeGroup] attr_reader :volume_group_config + # @return [Storage::Config] + attr_reader :storage_config + # @return [Agama::Config] attr_reader :product_config diff --git a/service/lib/agama/storage/config_checkers/md_raid.rb b/service/lib/agama/storage/config_checkers/md_raid.rb index 6988d88463..544eafa84e 100644 --- a/service/lib/agama/storage/config_checkers/md_raid.rb +++ b/service/lib/agama/storage/config_checkers/md_raid.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -63,8 +63,7 @@ def issues partitions_issues, devices_issues, level_issue, - devices_size_issue, - reused_member_issues + devices_size_issue ].flatten.compact end @@ -133,188 +132,6 @@ def used_devices storage_config.potential_for_md_device .select { |d| config.devices.include?(d.alias) } end - - # Issues from the member devices of a reused MD RAID. - # - # @return [Array] - def reused_member_issues - return [] unless config.found_device - - config.found_device.devices.map { |d| reused_member_issue(d) } - end - - # Issue from the member devices of a reused MD RAID. - # - # @param device [Y2Storage::BlkDevice] - # @return [Issue, nil] - def reused_member_issue(device) - member_config = find_config(device) - return parent_reused_member_issue(device) unless member_config - - deleted_reused_member_issue(member_config) || - resized_reused_member_issue(member_config) || - formatted_reused_member_issue(member_config) || - partitioned_reused_member_issue(member_config) || - target_reused_member_issue(member_config) - end - - # Issue if the device member is deleted. - # - # @param member_config [#search] - # @return [Issue, nil] - def deleted_reused_member_issue(member_config) - return unless storage_config.supporting_delete.include?(member_config) - return unless member_config.delete? || member_config.delete_if_needed? - - error( - format( - _( - # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and - # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). - "The device '%{member}' cannot be deleted because it is part of the MD RAID " \ - "%{md_raid}" - ), - member: member_config.found_device.name, - md_raid: config.found_device.name - ), - kind: IssueClasses::Config::MISUSED_MD_MEMBER - ) - end - - # Issue if the device member is resized. - # - # @param member_config [#search] - # @return [Issue, nil] - def resized_reused_member_issue(member_config) - return unless storage_config.supporting_size.include?(member_config) - return if member_config.size.default? - - error( - format( - _( - # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and - # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). - "The device '%{member}' cannot be resized because it is part of the MD RAID " \ - "%{md_raid}" - ), - member: member_config.found_device.name, - md_raid: config.found_device.name - ), - kind: IssueClasses::Config::MISUSED_MD_MEMBER - ) - end - - # Issue if the device member is formatted. - # - # @param member_config [#search] - # @return [Issue, nil] - def formatted_reused_member_issue(member_config) - return unless storage_config.supporting_filesystem.include?(member_config) - return unless member_config.filesystem - - error( - format( - _( - # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and - # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). - "The device '%{member}' cannot be formatted because it is part of the MD RAID " \ - "%{md_raid}" - ), - member: member_config.found_device.name, - md_raid: config.found_device.name - ), - kind: IssueClasses::Config::MISUSED_MD_MEMBER - ) - end - - # Issue if the device member is partitioned. - # - # @param member_config [#search] - # @return [Issue, nil] - def partitioned_reused_member_issue(member_config) - return unless storage_config.supporting_partitions.include?(member_config) - return unless member_config.partitions? - - error( - format( - _( - # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and - # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). - "The device '%{member}' cannot be partitioned because it is part of the MD RAID " \ - "%{md_raid}" - ), - member: member_config.found_device.name, - md_raid: config.found_device.name - ), - kind: IssueClasses::Config::MISUSED_MD_MEMBER - ) - end - - # Issue if the device member is used by other device (e.g., as target for physical volumes). - # - # @param member_config [#search] - # @return [Issue, nil] - def target_reused_member_issue(member_config) - return unless users?(member_config) - - error( - format( - _( - # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and - # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). - "The device '%{member}' cannot be used because it is part of the MD RAID " \ - "%{md_raid}" - ), - member: member_config.found_device.name, - md_raid: config.found_device.name - ), - kind: IssueClasses::Config::MISUSED_MD_MEMBER - ) - end - - # Issue if the parent of the device member is formatted. - # - # @param device [Y2Storage::BlkDevice] - # @return [Issue, nil] - def parent_reused_member_issue(device) - return unless device.respond_to?(:partitionable) - - parent_config = find_config(device.partitionable) - return unless parent_config&.filesystem - - error( - format( - _( - # TRANSLATORS: %{device} is replaced by a device name (e.g., "/dev/vda") and - # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). - "The device '%{device}' cannot be formatted because it is part of the MD RAID " \ - "%{md_raid}" - ), - device: parent_config.found_device.name, - md_raid: config.found_device.name - ), - kind: IssueClasses::Config::MISUSED_MD_MEMBER - ) - end - - # Finds the config assigned to the given device. - # - # @param device [Y2Storage::BlkDevice] - # @return [#search] - def find_config(device) - storage_config.supporting_search.find { |c| c.found_device == device } - end - - # Whether the given config has any user (direct user or as target). - # - # @param config [#search] - # @return [Boolean] - def users?(config) - return false unless config.alias - - storage_config.users(config.alias).any? || - storage_config.target_users(config.alias).any? - end end end end diff --git a/service/lib/agama/storage/config_checkers/search.rb b/service/lib/agama/storage/config_checkers/search.rb index 9ddeaaa302..cfdc97ff4b 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-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -24,6 +24,8 @@ require "agama/storage/configs/logical_volume" require "agama/storage/configs/md_raid" require "agama/storage/configs/partition" +require "agama/storage/configs/volume_group" +require "agama/storage/issue_classes" require "yast/i18n" module Agama @@ -34,20 +36,25 @@ class Search < Base include Yast::I18n # @param config [#search] - def initialize(config) + # @param storage_config [Storage::Config] + def initialize(config, storage_config) super() textdomain "agama" @config = config + @storage_config = storage_config end # Search config issues. # # @return [Array] def issues - return [] unless search + return [] unless config.search - [not_found_issue].compact + [ + not_found_issue, + reused_issues + ].flatten.compact end private @@ -55,27 +62,318 @@ def issues # @return [#search] attr_reader :config - # @return [Configs::Search, nil] - def search - config.search - end - - # @see Base - def error(message) - super(message, kind: IssueClasses::Config::SEARCH_NOT_FOUND) - end + # @return [Storage::Config] + attr_reader :storage_config # @return [Issue, nil] def not_found_issue + search = config.search return if search.device || search.create_device? || search.skip_device? if search.name - # TRANSLATORS: %s is replaced by a device name (e.g., "/dev/vda"). - error(format(_("Mandatory device %s not found"), search.name)) + error( + # TRANSLATORS: %s is replaced by a device name (e.g., "/dev/vda"). + format(_("Mandatory device %s not found"), search.name), + kind: IssueClasses::Config::SEARCH_NOT_FOUND + ) + else + error( + # TRANSLATORS: %s is replaced by a device type (e.g., "drive"). + format(_("Mandatory %s not found"), device_type), + kind: IssueClasses::Config::SEARCH_NOT_FOUND + ) + end + end + + # Issues from a reused device. + # + # When a MD RAID or LVM volume group is reused, the members of the device (i.e., MD devices + # or physical volume) must be kept. Otherwise the reused device will be deleted. + # + # @return [Array] + def reused_issues + reused_members.map { |m| reused_member_issue(m) }.compact + end + + # Issue if the member device is used for any other purpose. + # + # @param member [Y2Storage::BlkDevice] Member device. + # @return [Issue, nil] + def reused_member_issue(member) + member_config = storage_config.find_device(member) + return parent_reused_member_issue(member) unless member_config + + deleted_reused_member_issue(member_config) || + resized_reused_member_issue(member_config) || + formatted_reused_member_issue(member_config) || + partitioned_reused_member_issue(member_config) || + target_reused_member_issue(member_config) + end + + # Issue if the parent of the member device is formatted. + # + # @param device [Y2Storage::BlkDevice] Parent of the member device. + # @return [Issue, nil] + def parent_reused_member_issue(device) + return unless device.respond_to?(:partitionable) + + parent_config = storage_config.find_device(device.partitionable) + return unless parent_config&.filesystem + + kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid) + [ + IssueClasses::Config::MISUSED_MD_MEMBER, + _( + # TRANSLATORS: %{parent_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/md0"). + "The device '%{parent_name}' cannot be formatted because it is part of the " \ + "reused MD RAID '%{reused_name}'" + ) + ] + else + [ + IssueClasses::Config::MISUSED_PV, + _( + # TRANSLATORS: %{parent_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/vg0"). + "The device '%{parent_name}' cannot be formatted because it is a physical volume " \ + "of the reused LVM volume group '%{reused_name}'" + ) + ] + end + + error( + format( + message, + parent_name: parent_config.found_device.name, + reused_name: config.found_device.name + ), + kind: kind + ) + end + + # Issue if the member device is deleted. + # + # @param member_config [#search] + # @return [Issue, nil] + def deleted_reused_member_issue(member_config) + return unless storage_config.supporting_delete.include?(member_config) + return unless member_config.delete? || member_config.delete_if_needed? + + kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid) + [ + IssueClasses::Config::MISUSED_MD_MEMBER, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/md0"). + "The device '%{member_name}' cannot be deleted because it is part of the reused " \ + "MD RAID '%{reused_name}'" + ) + ] + else + [ + IssueClasses::Config::MISUSED_PV, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/vg0"). + "The device '%{member_name}' cannot be deleted because it is a physical volume " \ + "of the reused LVM volume group '%{reused_name}'" + ) + ] + end + + error( + format( + message, + member_name: member_config.found_device.name, + reused_name: config.found_device.name + ), + kind: kind + ) + end + + # Issue if the device member is resized. + # + # @param member_config [#search] + # @return [Issue, nil] + def resized_reused_member_issue(member_config) + return unless storage_config.supporting_size.include?(member_config) + return if member_config.size.default? + + kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid) + [ + IssueClasses::Config::MISUSED_MD_MEMBER, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/md0"). + "The device '%{member_name}' cannot be resized because it is part of the reused " \ + "MD RAID '%{reused_name}'" + ) + ] else - # TRANSLATORS: %s is replaced by a device type (e.g., "drive"). - error(format(_("Mandatory %s not found"), device_type)) + [ + IssueClasses::Config::MISUSED_PV, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/vg0"). + "The device '%{member_name}' cannot be resized because it is a physical volume " \ + "of the reused LVM volume group '%{reused_name}'" + ) + ] end + + error( + format( + message, + member_name: member_config.found_device.name, + reused_name: config.found_device.name + ), + kind: kind + ) + end + + # Issue if the device member is formatted. + # + # @param member_config [#search] + # @return [Issue, nil] + def formatted_reused_member_issue(member_config) + return unless storage_config.supporting_filesystem.include?(member_config) + return unless member_config.filesystem + + kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid) + [ + IssueClasses::Config::MISUSED_MD_MEMBER, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/md0"). + "The device '%{member_name}' cannot be formatted because it is part of the " \ + "reused MD RAID '%{reused_name}'" + ) + ] + else + [ + IssueClasses::Config::MISUSED_PV, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/vg0"). + "The device '%{member_name}' cannot be formatted because it is a physical volume " \ + "of the reused LVM volume group '%{reused_name}'" + ) + ] + end + + error( + format( + message, + member_name: member_config.found_device.name, + reused_name: config.found_device.name + ), + kind: kind + ) + end + + # Issue if the device member is partitioned. + # + # @param member_config [#search] + # @return [Issue, nil] + def partitioned_reused_member_issue(member_config) + return unless storage_config.supporting_partitions.include?(member_config) + return unless member_config.partitions? + + kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid) + [ + IssueClasses::Config::MISUSED_MD_MEMBER, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/md0"). + "The device '%{member_name}' cannot be partitioned because it is part of the " \ + "reused MD RAID '%{reused_name}'" + ) + ] + else + [ + IssueClasses::Config::MISUSED_PV, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/vg0"). + "The device '%{member_name}' cannot be partitioned because it is a physical " \ + "volume of the reused LVM volume group '%{reused_name}'" + ) + ] + end + + error( + format( + message, + member_name: member_config.found_device.name, + reused_name: config.found_device.name + ), + kind: kind + ) + end + + # Issue if the device member is used by other device (e.g., as target for physical volumes). + # + # @param member_config [#search] + # @return [Issue, nil] + def target_reused_member_issue(member_config) + return unless users?(member_config) + + kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid) + [ + IssueClasses::Config::MISUSED_MD_MEMBER, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/md0"). + "The device '%{member_name}' cannot be used because it is part of the reused " \ + "MD RAID '%{reused_name}'" + ) + ] + else + [ + IssueClasses::Config::MISUSED_PV, + _( + # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g., + # "/dev/vda", "/dev/vg0"). + "The device '%{member_name}' cannot be used because it is a physical volume " \ + "of the reused LVM volume group '%{reused_name}'" + ) + ] + end + + error( + format( + message, + member_name: member_config.found_device.name, + reused_name: config.found_device.name + ), + kind: kind + ) + end + + # Whether the given config has any user (direct user or as target). + # + # @param config [#search] + # @return [Boolean] + def users?(config) + return false unless config.alias + + storage_config.users(config.alias).any? || + storage_config.target_users(config.alias).any? + end + + # Members of the device reused by the config. + # + # @return [Array] + def reused_members + device = config.found_device + return [] unless device + + return device.lvm_pvs.map(&:plain_blk_device) if device.is?(:lvm_vg) + + return device.plain_devices if device.is?(:md) + + [] end # @return [String] @@ -89,6 +387,8 @@ def device_type _("partition") when Agama::Storage::Configs::LogicalVolume _("LVM logical volume") + when Agama::Storage::Configs::VolumeGroup + _("LVM volume group") else _("device") end diff --git a/service/lib/agama/storage/config_checkers/volume_group.rb b/service/lib/agama/storage/config_checkers/volume_group.rb index 89de8178eb..6bbda49c1e 100644 --- a/service/lib/agama/storage/config_checkers/volume_group.rb +++ b/service/lib/agama/storage/config_checkers/volume_group.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -22,6 +22,7 @@ require "agama/storage/config_checkers/base" require "agama/storage/config_checkers/logical_volume" require "agama/storage/config_checkers/physical_volumes_encryption" +require "agama/storage/config_checkers/with_search" require "yast/i18n" module Agama @@ -30,6 +31,7 @@ module ConfigCheckers # Class for checking a volume group config. class VolumeGroup < Base include Yast::I18n + include WithSearch # @param config [Configs::VolumeGroup] # @param storage_config [Storage::Config] @@ -52,7 +54,8 @@ def issues logical_volumes_issues, physical_volumes_issues, physical_volumes_devices_issues, - physical_volumes_encryption_issues + physical_volumes_encryption_issues, + search_issues ].compact.flatten end @@ -85,7 +88,7 @@ def name_issue def logical_volumes_issues config.logical_volumes.flat_map do |logical_volume| ConfigCheckers::LogicalVolume - .new(logical_volume, config, product_config) + .new(logical_volume, config, storage_config, product_config) .issues end end diff --git a/service/lib/agama/storage/config_checkers/with_search.rb b/service/lib/agama/storage/config_checkers/with_search.rb index 70ef0b87d7..b829b99103 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-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -28,7 +28,7 @@ module ConfigCheckers module WithSearch # @return [Array] def search_issues - ConfigCheckers::Search.new(config).issues + ConfigCheckers::Search.new(config, storage_config).issues end end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb index 4735bf0c2c..678613634c 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -130,7 +130,7 @@ def convert_volume_groups(targets) # @return [Configs::VolumeGroup] def convert_volume_group(volume_group_model, targets) FromModelConversions::VolumeGroup - .new(volume_group_model, targets, model_json[:encryption]) + .new(volume_group_model, product_config, targets, model_json[:encryption]) .convert end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb index 4a8c0cd5b9..076a65c4f3 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb @@ -22,9 +22,9 @@ require "agama/storage/config_conversions/from_model_conversions/base" require "agama/storage/config_conversions/from_model_conversions/with_encryption" require "agama/storage/config_conversions/from_model_conversions/with_filesystem" -require "agama/storage/config_conversions/from_model_conversions/with_partitions" require "agama/storage/config_conversions/from_model_conversions/with_ptable_type" require "agama/storage/config_conversions/from_model_conversions/with_search" +require "agama/storage/config_conversions/from_model_conversions/with_volumes" require "agama/storage/configs/drive" module Agama @@ -36,7 +36,7 @@ class Drive < Base include WithEncryption include WithFilesystem include WithPtableType - include WithPartitions + include WithVolumes include WithSearch # @param model_json [Hash] @@ -72,7 +72,7 @@ def conversions encryption: convert_encryption, filesystem: convert_filesystem, ptable_type: convert_ptable_type, - partitions: convert_partitions(encryption_model) + partitions: convert_volumes(encryption_model) } end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb index e22f85d7c1..21a3b795f3 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/with_delete" require "agama/storage/config_conversions/from_model_conversions/with_filesystem" require "agama/storage/config_conversions/from_model_conversions/with_size" require "agama/storage/config_conversions/from_model_conversions/with_search" @@ -32,6 +33,7 @@ module ConfigConversions module FromModelConversions # Logical volume conversion from model according to the JSON schema. class LogicalVolume < Base + include WithDelete include WithFilesystem include WithSize include WithSearch @@ -50,12 +52,14 @@ def default_config # @return [Hash] def conversions { - name: logical_volume_model[:lvName], - search: convert_search, - filesystem: convert_filesystem, - size: convert_size, - stripes: logical_volume_model[:stripes], - stripe_size: convert_stripe_size + name: logical_volume_model[:lvName], + search: convert_search, + filesystem: convert_filesystem, + size: convert_size, + stripes: logical_volume_model[:stripes], + stripe_size: convert_stripe_size, + delete: convert_delete, + delete_if_needed: convert_delete_if_needed } end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb index d7b480a9a7..9d19086a60 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -22,9 +22,9 @@ require "agama/storage/config_conversions/from_model_conversions/base" require "agama/storage/config_conversions/from_model_conversions/with_encryption" require "agama/storage/config_conversions/from_model_conversions/with_filesystem" -require "agama/storage/config_conversions/from_model_conversions/with_partitions" require "agama/storage/config_conversions/from_model_conversions/with_ptable_type" require "agama/storage/config_conversions/from_model_conversions/with_search" +require "agama/storage/config_conversions/from_model_conversions/with_volumes" require "agama/storage/configs/md_raid" module Agama @@ -36,7 +36,7 @@ class MdRaid < Base include WithEncryption include WithFilesystem include WithPtableType - include WithPartitions + include WithVolumes include WithSearch # @param model_json [Hash] @@ -72,7 +72,7 @@ def conversions encryption: convert_encryption, filesystem: convert_filesystem, ptable_type: convert_ptable_type, - partitions: convert_partitions(encryption_model) + partitions: convert_volumes(encryption_model) } end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb index ca53d87971..321a29ae37 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/with_delete" require "agama/storage/config_conversions/from_model_conversions/with_encryption" require "agama/storage/config_conversions/from_model_conversions/with_filesystem" require "agama/storage/config_conversions/from_model_conversions/with_search" @@ -33,6 +34,7 @@ module ConfigConversions module FromModelConversions # Partition conversion from model according to the JSON schema. class Partition < Base + include WithDelete include WithSearch include WithEncryption include WithFilesystem @@ -79,24 +81,6 @@ def convert_id Y2Storage::PartitionId.find(value) end - - # TODO: do not delete if the partition is used by other device (VG, RAID, etc). - # @return [Boolean] - def convert_delete - # Do not mark to delete if the partition is used. - return false if partition_model[:mountPath] - - partition_model[:delete] - end - - # TODO: do not delete if the partition is used by other device (VG, RAID, etc). - # @return [Boolean] - def convert_delete_if_needed - # Do not mark to delete if the partition is used. - return false if partition_model[:mountPath] - - partition_model[:deleteIfNeeded] - end end end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb index 6f00248906..49ef4b854a 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb @@ -20,9 +20,9 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/from_model_conversions/base" -require "agama/storage/config_conversions/from_model_conversions/logical_volume" require "agama/storage/config_conversions/from_model_conversions/encryption" require "agama/storage/config_conversions/from_model_conversions/with_search" +require "agama/storage/config_conversions/from_model_conversions/with_volumes" require "agama/storage/configs/volume_group" require "y2storage/disk_size" @@ -33,12 +33,15 @@ module FromModelConversions # Volume group conversion from model according to the JSON schema. class VolumeGroup < Base include WithSearch + include WithVolumes # @param model_json [Hash] + # @param product_config [Agama::Config] # @param targets [Array] # @param encryption_model [Hash, nil] - def initialize(model_json, targets, encryption_model = nil) + def initialize(model_json, product_config, targets, encryption_model = nil) super(model_json) + @product_config = product_config @targets = targets @encryption_model = encryption_model end @@ -47,6 +50,9 @@ def initialize(model_json, targets, encryption_model = nil) alias_method :volume_group_model, :model_json + # @return [Agama::Config] + attr_reader :product_config + # @return [Array] attr_reader :targets @@ -68,7 +74,7 @@ def conversions extent_size: convert_extent_size, physical_volumes_devices: convert_physical_volumes_devices, physical_volumes_encryption: convert_physical_volumes_encryption, - logical_volumes: convert_logical_volumes + logical_volumes: convert_volumes } end @@ -97,14 +103,6 @@ def convert_physical_volumes_encryption FromModelConversions::Encryption.new(encryption_model).convert end - # @return [Array, nil] - def convert_logical_volumes - logical_volumes_model = volume_group_model[:logicalVolumes] - return unless logical_volumes_model - - logical_volumes_model.map { |l| FromModelConversions::LogicalVolume.new(l).convert } - end - # @param name [String] # @return [Configs::Drive, Configs::MdRaid, nil] def target(name) diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_delete.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_delete.rb new file mode 100644 index 0000000000..e9625f1b31 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_delete.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for delete properties conversion. + module WithDelete + # TODO: do not delete if the volume is used by other device (VG, RAID, etc). + # @return [Boolean] + def convert_delete + # Do not mark to delete if the volume is used. + return false if model_json[:mountPath] + + model_json[:delete] + end + + # TODO: do not delete if the volume is used by other device (VG, RAID, etc). + # @return [Boolean] + def convert_delete_if_needed + # Do not mark to delete if the volume is used. + return false if model_json[:mountPath] + + model_json[:deleteIfNeeded] + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb deleted file mode 100644 index c5ba9d4219..0000000000 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/config_conversions/from_model_conversions/partition" -require "agama/storage/configs/partition" - -module Agama - module Storage - module ConfigConversions - module FromModelConversions - # Mixin for partitions conversion. - module WithPartitions - # @param encryption_model [Hash, nil] - # @return [Array] - def convert_partitions(encryption_model = nil) - # If the model does not indicate a space policy, then the space policy defined by the - # product is applied. - space_policy = model_json[:spacePolicy] || product_config.space_policy - - case space_policy - when "keep" - used_partition_configs(encryption_model) - when "delete" - [used_partition_configs(encryption_model), delete_all_partition_config].flatten - when "resize" - [used_partition_configs(encryption_model), resize_all_partition_config].flatten - else - [used_partition_configs(encryption_model), action_partition_configs].flatten - end - end - - # @param encryption_model [Hash, nil] - # @return [Array] - def used_partition_configs(encryption_model = nil) - used_partitions.map { |p| convert_partition(p, encryption_model) } - end - - # @return [Array] - def action_partition_configs - action_partitions.map { |p| convert_partition(p) } - end - - # Partitions with any usage (format, mount, etc). - # - # @return [Array] - def used_partitions - partitions.reject { |p| space_policy_partition?(p) } - end - - # Partitions representing a space policy action (delete, resize if needed), excluding - # the keep actions. - # - # Omitting the partitions that only represent a keep action is important. Otherwise, the - # resulting config would contain a partition without any usage (delete, resize, format, - # etc) and without a mount path. Such a partition is not supported by the model yet (see - # {ModelSupportChecker}) and would make impossible to build a model again from the - # resulting config. - # - # @return [Array] - def action_partitions - partitions - .select { |p| space_policy_partition?(p) } - .reject { |p| keep_action_partition?(p) } - end - - # @return [Array] - def partitions - model_json[:partitions] || [] - end - - # Whether the partition only represents a space policy action. - # - # @param partition_model [Hash] - # @return [Boolean] - def space_policy_partition?(partition_model) - delete_action_partition?(partition_model) || - resize_action_partition?(partition_model) || - keep_action_partition?(partition_model) - end - - # @param partition_model [Hash] - # @return [Boolean] - def delete_action_partition?(partition_model) - partition_model[:delete] || partition_model[:deleteIfNeeded] - end - - # @param partition_model [Hash] - # @return [Boolean] - def resize_action_partition?(partition_model) - return false if delete_action_partition?(partition_model) - - return false if any_usage?(partition_model) - - partition_model[:name] && ( - partition_model[:resizeIfNeeded] || - (partition_model[:size] && !partition_model.dig(:size, :default)) - ) - end - - # @param partition_model [Hash] - # @return [Boolean] - def keep_action_partition?(partition_model) - return false if delete_action_partition?(partition_model) - - return false if resize_action_partition?(partition_model) - - return false if any_usage?(partition_model) - - !partition_model[:name].nil? - end - - # TODO: improve check by ensuring the partition is referenced by other device. - # - # @param partition_model [Hash] - # @return [Boolean] - def any_usage?(partition_model) - partition_model[:mountPath] || partition_model[:filesystem] - end - - # @return [Configs::Partition] - def delete_all_partition_config - Configs::Partition.new_for_delete_all - end - - # @return [Configs::Partition] - def resize_all_partition_config - Configs::Partition.new_for_shrink_any_if_needed - end - - # @param partition_model [Hash] - # @param encryption_model [Hash, nil] - # - # @return [Configs::Partition] - def convert_partition(partition_model, encryption_model = nil) - FromModelConversions::Partition.new(partition_model, encryption_model).convert - end - end - end - end - end -end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_volumes.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_volumes.rb new file mode 100644 index 0000000000..c0925f8c1b --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_volumes.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +# Copyright (c) [2024-2026] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/from_model_conversions/partition" +require "agama/storage/config_conversions/from_model_conversions/logical_volume" +require "agama/storage/configs/partition" +require "agama/storage/configs/logical_volume" +require "agama/storage/configs/volume_group" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for volumes conversion. + # + # In this context, volume is a term to refer to partition or logical volume config + # indiscriminately. + module WithVolumes + # @param encryption_model [Hash, nil] + # @return [Array, Array] + def convert_volumes(encryption_model = nil) + # If the model does not indicate a space policy, then the space policy defined by the + # product is applied. + space_policy = model_json[:spacePolicy] || product_config.space_policy + + case space_policy + when "keep" + used_volumes_configs(encryption_model) + when "delete" + [used_volumes_configs(encryption_model), delete_all_volume_config].flatten + when "resize" + [used_volumes_configs(encryption_model), resize_all_volume_config].flatten + else + [used_volumes_configs(encryption_model), action_volume_configs].flatten + end + end + + # @param encryption_model [Hash, nil] + # @return [Array, Array] + def used_volumes_configs(encryption_model = nil) + used_volumes.map { |v| convert_volume(v, encryption_model) } + end + + # @return [Array, Array] + def action_volume_configs + action_volumes.map { |v| convert_volume(v) } + end + + # Volumes with any usage (format, mount, etc). + # + # @return [Array] + def used_volumes + volumes.reject { |v| space_policy_volume?(v) } + end + + # Volumes representing a space policy action (delete, resize if needed), excluding + # the keep actions. + # + # Omitting the volumes that only represent a keep action is important. Otherwise, the + # resulting config would contain a volume without any usage (delete, resize, format, + # etc) and without a mount path. Such a volume is not supported by the model yet (see + # {ModelSupportChecker}) and would make impossible to build a model again from the + # resulting config. + # + # @return [Array] + def action_volumes + volumes + .select { |v| space_policy_volume?(v) } + .reject { |v| keep_action_volume?(v) } + end + + # @return [Array] + def volumes + model_json[:partitions] || model_json[:logicalVolumes] || [] + end + + # Whether the volume only represents a space policy action. + # + # @param volume [Hash] + # @return [Boolean] + def space_policy_volume?(volume) + delete_action_volume?(volume) || + resize_action_volume?(volume) || + keep_action_volume?(volume) + end + + # @param volume [Hash] + # @return [Boolean] + def delete_action_volume?(volume) + volume[:delete] || volume[:deleteIfNeeded] + end + + # @param volume [Hash] + # @return [Boolean] + def resize_action_volume?(volume) + return false if delete_action_volume?(volume) + + return false if any_usage?(volume) + + volume[:name] && ( + volume[:resizeIfNeeded] || + (volume[:size] && !volume.dig(:size, :default)) + ) + end + + # @param volume [Hash] + # @return [Boolean] + def keep_action_volume?(volume) + return false if delete_action_volume?(volume) + + return false if resize_action_volume?(volume) + + return false if any_usage?(volume) + + !volume[:name].nil? + end + + # TODO: improve check by ensuring the volume is referenced by other device. + # + # @param volume [Hash] + # @return [Boolean] + def any_usage?(volume) + volume[:mountPath] || volume[:filesystem] + end + + # @return [Configs::Partition, Configs::LogicalVolume] + def delete_all_volume_config + volume_class.new_for_delete_all + end + + # @return [Configs::Partition, Configs::LogicalVolume] + def resize_all_volume_config + volume_class.new_for_shrink_any_if_needed + end + + # @param volume [Hash] + # @param encryption_model [Hash, nil] + # + # @return [Configs::Partition, Configs::LogicalVolume] + def convert_volume(volume, encryption_model = nil) + return FromModelConversions::LogicalVolume.new(volume).convert if convert_lvm? + + FromModelConversions::Partition.new(volume, encryption_model).convert + end + + # Volume config class depending on the conversion. + def volume_class + convert_lvm? ? Configs::LogicalVolume : Configs::Partition + end + + # Whether the conversion if for LVM. + # + # @return [Boolean] + def convert_lvm? + default_config.is_a?(Configs::VolumeGroup) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb index 81a8524541..a70e1169a9 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/config_conversions/to_model_conversions/base" require "agama/storage/config_conversions/to_model_conversions/with_filesystem" +require "agama/storage/config_conversions/to_model_conversions/with_resize" require "agama/storage/config_conversions/to_model_conversions/with_size" module Agama @@ -30,6 +31,7 @@ module ToModelConversions # LVM logical volume conversion to model according to the JSON schema. class LogicalVolume < Base include WithFilesystem + include WithResize include WithSize # @param config [Configs::LogicalVolume] @@ -48,12 +50,17 @@ def initialize(config, volumes) # @see Base#conversions def conversions { - lvName: config.name, - mountPath: config.filesystem&.path, - filesystem: convert_filesystem, - size: convert_size, - stripes: config.stripes, - stripeSize: config.stripe_size&.to_i + name: config.device_name, + lvName: config.name, + mountPath: config.filesystem&.path, + filesystem: convert_filesystem, + stripes: config.stripes, + stripeSize: config.stripe_size&.to_i, + size: convert_size, + delete: config.delete?, + deleteIfNeeded: config.delete_if_needed?, + resize: convert_resize, + resizeIfNeeded: convert_resize_if_needed } end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb index d6f0ac0865..9a20cdb5d0 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/config_conversions/to_model_conversions/base" require "agama/storage/config_conversions/to_model_conversions/with_filesystem" +require "agama/storage/config_conversions/to_model_conversions/with_resize" require "agama/storage/config_conversions/to_model_conversions/with_size" module Agama @@ -30,6 +31,7 @@ module ToModelConversions # Partition conversion to model according to the JSON schema. class Partition < Base include WithFilesystem + include WithResize include WithSize # @param config [Configs::Partition] @@ -58,22 +60,6 @@ def conversions resizeIfNeeded: convert_resize_if_needed } end - - # @return [Booelan] - def convert_resize - return false unless config.found_device - - size = config.size - !size.nil? && !size.default? && size.min == size.max - end - - # @return [Booelan] - def convert_resize_if_needed - return false unless config.found_device - - size = config.size - !size.nil? && !size.default? && size.min != size.max - end end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb index 7708863586..a6b8e73137 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -19,78 +19,88 @@ # 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/volume_group" + module Agama module Storage module ConfigConversions module ToModelConversions # Space policy conversion to model according to the JSON schema. class SpacePolicy - # TODO: make it work with volume groups and raids too. - # - # @param config [Configs::Drive] + # @param config [Configs::Drive, Configs::MdRaid, Configs::VolumeGroup] def initialize(config) @config = config end # @return [String] def convert - return "delete" if delete_all_partition? - return "resize" if shrink_all_partition? - return "custom" if delete_partition? || resize_partition? + return "delete" if delete_all_volumes? + return "resize" if shrink_all_volumes? + return "custom" if delete_volume? || resize_volume? "keep" end private - # @return [Configs::Drive] + # @return [Configs::Drive, Configs::MdRaid, Configs::VolumeGroup] attr_reader :config + # Volumes from the config. + # + # In this context, volume is a term to refer to partition or logical volume config + # indiscriminately. + # + # @return [Array, Array] + def volumes + config.is_a?(Configs::VolumeGroup) ? config.logical_volumes : config.partitions + end + # @return [Boolean] - def delete_all_partition? - config.partitions.any? { |p| delete_all?(p) } + def delete_all_volumes? + volumes.any? { |v| delete_all?(v) } end # @return [Boolean] - def shrink_all_partition? - config.partitions.any? { |p| shrink_all?(p) } + def shrink_all_volumes? + volumes.any? { |v| shrink_all?(v) } end # @return [Boolean] - def delete_partition? - config.partitions + def delete_volume? + volumes .select(&:found_device) - .any? { |p| p.delete? || p.delete_if_needed? } + .any? { |v| v.delete? || v.delete_if_needed? } end # @return [Boolean] - def resize_partition? - config.partitions + def resize_volume? + volumes .select(&:found_device) - .any? { |p| !p.size.default? } + .any? { |v| !v.size.default? } end - # @param partition_config [Configs::Partition] + # @param volume [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def delete_all?(partition_config) - search_all?(partition_config) && partition_config.delete? + def delete_all?(volume) + search_all?(volume) && volume.delete? end - # @param partition_config [Configs::Partition] + # @param volume [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def shrink_all?(partition_config) - search_all?(partition_config) && - !partition_config.size.nil? && - !partition_config.size.min.nil? && - partition_config.size.min.to_i == 0 + def shrink_all?(volume) + search_all?(volume) && + !volume.size.nil? && + !volume.size.min.nil? && + volume.size.min.to_i == 0 end - # @param partition_config [Configs::Partition] + # @param volume [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def search_all?(partition_config) - !partition_config.search.nil? && - !partition_config.search.condition? && - partition_config.search.max.nil? + def search_all?(volume) + !volume.search.nil? && + !volume.search.condition? && + volume.search.max.nil? end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb index 4ad39bd1f5..d9172c1232 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/config_conversions/to_model_conversions/base" require "agama/storage/config_conversions/to_model_conversions/logical_volume" +require "agama/storage/config_conversions/to_model_conversions/with_space_policy" module Agama module Storage @@ -28,7 +29,7 @@ module ConfigConversions module ToModelConversions # LVM volume group conversion to model according to the JSON schema. class VolumeGroup < Base - include WithFilesystem + include WithSpacePolicy # @param config [Configs::VolumeGroup] # @param storage_config [Storage::Config] @@ -51,9 +52,11 @@ def initialize(config, storage_config, volumes) # @see Base#conversions def conversions { + name: config.device_name, vgName: config.name, extentSize: config.extent_size&.to_i, targetDevices: convert_target_devices, + spacePolicy: convert_space_policy, logicalVolumes: convert_logical_volumes } end @@ -69,9 +72,9 @@ def convert_target_devices # @return [Array] def convert_logical_volumes - config.logical_volumes.map do |logical_volume| - ToModelConversions::LogicalVolume.new(logical_volume, volumes).convert - end + config.logical_volumes + .reject(&:skipped?) + .map { |l| ToModelConversions::LogicalVolume.new(l, volumes).convert } end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_resize.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_resize.rb new file mode 100644 index 0000000000..29d3645a2e --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_resize.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Mixin for resize info conversion to model according to the JSON schema. + module WithResize + # @return [Booelan] + def convert_resize + return false unless config.found_device + + size = config.size + !size.nil? && !size.default? && size.min == size.max + end + + # @return [Booelan] + def convert_resize_if_needed + return false unless config.found_device + + size = config.size + !size.nil? && !size.default? && size.min != size.max + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb index d87ae591c7..9bbb44f8fe 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -29,8 +29,6 @@ module ToModelConversions module WithSpacePolicy # @return [String, nil] def convert_space_policy - return unless config.respond_to?(:partitions) - ToModelConversions::SpacePolicy.new(config).convert end end diff --git a/service/lib/agama/storage/configs/logical_volume.rb b/service/lib/agama/storage/configs/logical_volume.rb index 4c24965458..f10332add2 100644 --- a/service/lib/agama/storage/configs/logical_volume.rb +++ b/service/lib/agama/storage/configs/logical_volume.rb @@ -19,28 +19,18 @@ # 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/size" -require "agama/storage/configs/with_alias" -require "agama/storage/configs/with_filesystem" -require "agama/storage/configs/with_search" -require "agama/storage/configs/with_delete" +require "agama/storage/configs/with_volume_properties" module Agama module Storage module Configs # Section of the configuration representing a LVM logical volume. class LogicalVolume - include WithAlias - include WithFilesystem - include WithSearch - include WithDelete + include WithVolumeProperties # @return [String, nil] attr_accessor :name - # @return [Size] - attr_accessor :size - # @return [Integer, nil] attr_accessor :stripes @@ -54,12 +44,8 @@ class LogicalVolume # @return [String, nil] attr_accessor :used_pool - # @return [Encryption, nil] - attr_accessor :encryption - def initialize - initialize_delete - @size = Size.new + initialize_volume_properties @pool = false end diff --git a/service/lib/agama/storage/configs/partition.rb b/service/lib/agama/storage/configs/partition.rb index 834a51cefa..1a9bd3dc70 100644 --- a/service/lib/agama/storage/configs/partition.rb +++ b/service/lib/agama/storage/configs/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -19,55 +19,20 @@ # 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/size" -require "agama/storage/configs/with_alias" -require "agama/storage/configs/with_filesystem" -require "agama/storage/configs/with_search" -require "agama/storage/configs/with_delete" +require "agama/storage/configs/with_volume_properties" module Agama module Storage module Configs # Section of the configuration representing a partition class Partition - include WithDelete - - # Partition config meaning "delete all partitions". - # - # @return [Configs::Partition] - def self.new_for_delete_all - new.tap do |config| - config.search = Configs::Search.new_for_search_all - config.delete = true - end - end - - # Partition config meaning "shrink any partitions if needed". - # - # @return [Configs::Partition] - def self.new_for_shrink_any_if_needed - new.tap do |config| - config.search = Configs::Search.new_for_search_all - config.size = Configs::Size.new_for_shrink_if_needed - end - end - - include WithAlias - include WithFilesystem - include WithSearch + include WithVolumeProperties # @return [Y2Storage::PartitionId, nil] attr_accessor :id - # @return [Size] - attr_accessor :size - - # @return [Encryption, nil] - attr_accessor :encryption - def initialize - initialize_delete - @size = Size.new + initialize_volume_properties end end end diff --git a/service/lib/agama/storage/configs/with_volume_properties.rb b/service/lib/agama/storage/configs/with_volume_properties.rb new file mode 100644 index 0000000000..72599b181a --- /dev/null +++ b/service/lib/agama/storage/configs/with_volume_properties.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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/size" +require "agama/storage/configs/with_alias" +require "agama/storage/configs/with_filesystem" +require "agama/storage/configs/with_search" +require "agama/storage/configs/with_delete" + +module Agama + module Storage + module Configs + # Mixin for configs with volume properties. + module WithVolumeProperties + def self.included(base) + base.extend(ClassMethods) + end + + # Class methods to build default configs. + module ClassMethods + # Volume config meaning "delete all partitions". + # + # @return [Configs::Partition] + def new_for_delete_all + new.tap do |config| + config.search = Configs::Search.new_for_search_all + config.delete = true + end + end + + # Volume config meaning "shrink any partitions if needed". + # + # @return [Configs::Partition] + def new_for_shrink_any_if_needed + new.tap do |config| + config.search = Configs::Search.new_for_search_all + config.size = Configs::Size.new_for_shrink_if_needed + end + end + end + + include WithAlias + include WithFilesystem + include WithSearch + include WithDelete + + # @return [Size] + attr_accessor :size + + # @return [Encryption, nil] + attr_accessor :encryption + + def initialize_volume_properties + initialize_delete + @size = Size.new + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb index 0bc8b47f3e..68d5f3d73f 100644 --- a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -53,7 +53,7 @@ def lvm_vg_size # # @return [Array] def lvm_vg_pvs - storage_device.lvm_pvs.map(&:sid) + storage_device.lvm_pvs.map(&:plain_blk_device).map(&:sid) end end end diff --git a/service/lib/agama/storage/issue_classes.rb b/service/lib/agama/storage/issue_classes.rb index 87edb9a472..a465a7730c 100644 --- a/service/lib/agama/storage/issue_classes.rb +++ b/service/lib/agama/storage/issue_classes.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -37,6 +37,9 @@ module Config # A device that is part of a reused RAID is chosen to be used with other purpose MISUSED_MD_MEMBER = :configMisusedMdMember + # A device that is a PV of a reused VG is chosen to be used with other purpose + MISUSED_PV = :configMisusedMdMember + # Reused and new devices are both used as target for generating PVs for the same LV INCOMPATIBLE_PV_TARGETS = :configIncompatiblePvTargets diff --git a/service/lib/agama/storage/model_support_checker.rb b/service/lib/agama/storage/model_support_checker.rb index bfa023e9ea..6563bed6c6 100644 --- a/service/lib/agama/storage/model_support_checker.rb +++ b/service/lib/agama/storage/model_support_checker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -59,8 +59,7 @@ def unsupported_config? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ any_partitionable_without_name? || any_volume_group_without_name? || any_volume_group_with_pvs? || - any_partition_without_mount_path? || - any_logical_volume_without_mount_path? || + any_volume_without_mount_path? || any_logical_volume_with_encryption? || any_different_encryption? || any_missing_encryption? || @@ -71,8 +70,8 @@ def unsupported_config? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ # # @return [Boolean] def any_unsupported_device? - thin_pools = config.logical_volumes.select(&:pool?) - thin_volumes = config.logical_volumes.select(&:thin_volume?) + thin_pools = config.valid_logical_volumes.select(&:pool?) + thin_volumes = config.valid_logical_volumes.select(&:thin_volume?) [ config.btrfs_raids, @@ -99,7 +98,7 @@ def any_partitionable_without_name? # # @return [Boolean] def any_volume_group_without_name? - !config.volume_groups.all?(&:name) + !config.valid_volume_groups.all?(&:name) end # Only volume groups with automatically generated physical volumes are supported. @@ -107,96 +106,91 @@ def any_volume_group_without_name? # # @return [Boolean] def any_volume_group_with_pvs? - config.volume_groups.any? { |v| v.physical_volumes.any? } - end - - # Whether there is any logical volume with missing mount path. - # @todo Revisit this check once volume groups can be reused. - # - # @return [Boolean] - def any_logical_volume_without_mount_path? - config.logical_volumes.any? { |p| !p.filesystem&.path } + config.valid_volume_groups.any? { |v| v.physical_volumes.any? } end # Whether there is any logical volume with encryption. # # @return [Boolean] def any_logical_volume_with_encryption? - config.logical_volumes.any?(&:encryption) + config.valid_logical_volumes.any?(&:encryption) end - # Whether there is any partition with missing mount path. + # Whether there is any volume (i.e., partition or logical volume) with missing mount path. # @see #need_mount_path? # # @return [Boolean] - def any_partition_without_mount_path? - config.partitions.any? { |p| need_mount_path?(p) && !p.filesystem&.path } + def any_volume_without_mount_path? + config.volumes.any? { |v| need_mount_path?(v) && !v.filesystem&.path } end - # Whether the config represents a partition that requires a mount path. + # Whether the volume config requires a mount path. # - # A mount path is required for all the partitions that are going to be created. For a config - # reusing an existing partition, the mount path is required only if the partition does not - # represent a space policy action (delete or resize). + # A mount path is required for all the volumes (i.e., partitions or logical volumes) that are + # going to be created. For a config reusing an existing device, the mount path is required + # only if the volume does not represent a space policy action (delete or resize). # # @todo Revisit this check once individual physical volumes are supported by the model. The # partitions representing the new physical volumes would not need a mount path. # - # @param partition_config [Configs::Partition] + # @param volume_config [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def need_mount_path?(partition_config) - return true if new_partition?(partition_config) + def need_mount_path?(volume_config) + return true if new_volume?(volume_config) - reused_partition?(partition_config) && - !delete_action_partition?(partition_config) && - !resize_action_partition?(partition_config) + reused_volume?(volume_config) && + !delete_action?(volume_config) && + !resize_action?(volume_config) end - # Whether the config represents a new partition to be created. + # Whether the config represents a new volume (i.e., partition or logical volume) to be + # created. # # @note The config has to be solved. Otherwise, in some cases it would be impossible to - # determine whether the partition is going to be created or reused. For example, if the + # determine whether the volume is going to be created or reused. For example, if the # config has a search and #if_not_found is set to :create. # - # @param partition_config [Configs::Partition] + # @param volume_config [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def new_partition?(partition_config) - partition_config.search.nil? || partition_config.search.create_device? + def new_volume?(volume_config) + volume_config.search.nil? || volume_config.search.create_device? end - # Whether the config is reusing an existing partition. + # Whether the config is reusing an existing volume (i.e., partition or logical volume). # # @note The config has to be solved. Otherwise, in some cases it would be impossible to - # determine whether the partition is going to be reused or skipped. + # determine whether the volume is going to be reused or skipped. # - # @param partition_config [Configs::Partition] + # @param volume_config [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def reused_partition?(partition_config) - !new_partition?(partition_config) && !partition_config.search.skip_device? + def reused_volume?(volume_config) + !new_volume?(volume_config) && !volume_config.search.skip_device? end - # Whether the partition is configured to be deleted or deleted if needed. + # Whether the volume (i.e., partition or logical volume) is configured to be deleted or + # deleted if needed. # - # @param partition_config [Configs::Partition] + # @param volume_config [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def delete_action_partition?(partition_config) - return false unless reused_partition?(partition_config) + def delete_action?(volume_config) + return false unless reused_volume?(volume_config) - partition_config.delete? || partition_config.delete_if_needed? + volume_config.delete? || volume_config.delete_if_needed? end - # Whether the partition is configured to be resized if needed. + # Whether the volume (i.e., partition or logical volume) is configured to be resized if + # needed. # - # @param partition_config [Configs::Partition] + # @param volume_config [Configs::Partition, Configs::LogicalVolume] # @return [Boolean] - def resize_action_partition?(partition_config) - return false unless reused_partition?(partition_config) - - partition_config.filesystem.nil? && - partition_config.encryption.nil? && - partition_config.size && - !partition_config.size.default? && - partition_config.size.min == Y2Storage::DiskSize.zero + def resize_action?(volume_config) + return false unless reused_volume?(volume_config) + + volume_config.filesystem.nil? && + volume_config.encryption.nil? && + volume_config.size && + !volume_config.size.default? && + volume_config.size.min == Y2Storage::DiskSize.zero end # Whether there are different encryptions. @@ -238,7 +232,7 @@ def any_missing_device_encryption? def any_missing_volume_group_encryption? return false if config.valid_encryptions.none? - config.volume_groups + config.valid_volume_groups .reject { |c| c.physical_volumes_devices.none? } .reject(&:physical_volumes_encryption) .any? diff --git a/service/lib/agama/storage/system.rb b/service/lib/agama/storage/system.rb index b44ccc1649..248faa1e0d 100644 --- a/service/lib/agama/storage/system.rb +++ b/service/lib/agama/storage/system.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -66,8 +66,7 @@ def candidate_drives # All devices that can be referenced by an mdRaid entry at the Agama config # - # This excludes devices with any mounted filesystem and devices that contain a repository - # for installation. + # This excludes MD RAIDs that are not based on available devices. # # @return [Array] def available_md_raids @@ -92,14 +91,34 @@ def candidate_md_raids available_md_raids.reject { |r| r.is?(:software_raid) } end - # Whether the device is usable as drive or mdRaid + # All devices that can be referenced by a volumeGroups entry at the Agama config # - # See {#available_drives} and {#available_md_raids} + # This excludes volume groups that are not based on available devices. + # + # @return [Array] + def available_volume_groups + return [] unless devicegraph + + devicegraph.lvm_vgs.select { |v| available?(v) } + end + + # Whether the device is usable for the installation. + # + # A device is usable if it contains neither a mounted filesystem nor a repository for the + # installation. + # + # For "compound" devices like MD RAIDs or volume groups, all the devices used for creating + # them have to be usable for the installation too. + # + # See {#available_drives}, {#available_md_raids} and {#available_volume_groups} # # @param device [Y2Storage::Partitionable, Y2Storage::Md] # @return [Boolean] def available?(device) - analyzer.available_device?(device) + devices = device.ancestors.select { |a| a.parents.none? } + devices << device if devices.empty? + + devices.all? { |d| analyzer.available_device?(d) } end # Whether the device can be used for installation, including the boot partitions diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index 931438fc87..e70896782e 100644 --- a/service/lib/y2storage/agama_proposal.rb +++ b/service/lib/y2storage/agama_proposal.rb @@ -138,7 +138,8 @@ def calculate_initial_planned(devicegraph) @planned_devices = planner.planned_devices(config) end - # Performs the mandatory space-making actions on the given devicegraph + # Performs the mandatory partition actions for making space on the given devicegraph. The + # actions for making space in the volume groups are performed later by the devices creator. # # @param devicegraph [Devicegraph] the graph gets modified def clean_graph(devicegraph) diff --git a/service/lib/y2storage/proposal/agama_md_planner.rb b/service/lib/y2storage/proposal/agama_md_planner.rb index 71e4fb48eb..763939bf10 100644 --- a/service/lib/y2storage/proposal/agama_md_planner.rb +++ b/service/lib/y2storage/proposal/agama_md_planner.rb @@ -32,6 +32,8 @@ class AgamaMdPlanner < AgamaDevicePlanner # @param config [Agama::Storage::Config] # @return [Array] def planned_devices(md_config, config) + return [] if md_config.search&.skip_device? + md = planned_md(md_config, config) register_partitionable(md, md_config) [md] diff --git a/service/lib/y2storage/proposal/agama_vg_planner.rb b/service/lib/y2storage/proposal/agama_vg_planner.rb index e993bc0b6e..7dbf1462a1 100644 --- a/service/lib/y2storage/proposal/agama_vg_planner.rb +++ b/service/lib/y2storage/proposal/agama_vg_planner.rb @@ -29,6 +29,8 @@ class AgamaVgPlanner < AgamaDevicePlanner # @param vg_config [Agama::Storage::Configs::VolumeGroup] # @return [Array] def planned_devices(vg_config) + return [] if vg_config.search&.skip_device? + [planned_vg(vg_config)] end @@ -95,15 +97,18 @@ def planned_lvs(config) # @param config [Agama::Storage::Configs::VolumeGroup] # @return [Array] def planned_normal_lvs(config) - configs = config.logical_volumes.reject(&:pool?).reject(&:thin_volume?) - configs.map { |c| planned_lv(c, LvType::NORMAL) } + valid_lv_configs(config) + .reject(&:pool?) + .reject(&:thin_volume?) + .map { |c| planned_lv(c, LvType::NORMAL) } end # @param config [Agama::Storage::Configs::VolumeGroup] # @return [Array] def planned_thin_pool_lvs(config) - pool_configs = config.logical_volumes.select(&:pool?) - pool_configs.map { |c| planned_thin_pool_lv(c, config) } + valid_lv_configs(config) + .select(&:pool?) + .map { |c| planned_thin_pool_lv(c, config) } end # Plan a thin pool logical volume and its thin volumes. @@ -147,6 +152,17 @@ def planned_lv(config, type) configure_reuse(planned, config) end end + + # Valid logical volume configs to plan for. + # + # @param config [Agama::Storage::Configs::VolumeGroup] + # @return [Array] + def valid_lv_configs(config) + config.logical_volumes + .reject(&:delete?) + .reject(&:delete_if_needed?) + .reject { |c| c.search&.skip_device? } + end end end end diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 2371a8634e..1166b49e42 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Apr 13 15:22:25 UTC 2026 - José Iván López González + +- Adapt storage model to reuse LVM volume groups + (gh#agama-project/agama#3380). + ------------------------------------------------------------------- Fri Apr 10 08:03:07 UTC 2026 - Lidong Zhong diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 185988fe6d..0c2d31161e 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -209,12 +209,14 @@ def parse(string) allow(proposal.storage_system).to receive(:candidate_md_raids).and_return(candidate_raids) allow(proposal.storage_system).to receive(:candidate_devices) .and_return(candidate_drives + candidate_raids) + allow(proposal.storage_system).to receive(:available_volume_groups).and_return(available_vgs) end let(:available_drives) { [] } let(:candidate_drives) { [] } let(:available_raids) { [] } let(:candidate_raids) { [] } + let(:available_vgs) { [] } describe "serialized_system[:availableDrives]" do context "if there is no available drives" do @@ -306,6 +308,29 @@ def parse(string) end end + describe "serialized_system[:availableVolumeGroups]" do + context "if there is no available volume groups" do + let(:available_vgs) { [] } + + it "returns an empty list" do + expect(parse(subject.serialized_system)[:availableVolumeGroups]).to eq([]) + end + end + + context "if there are available volume groups" do + let(:available_vgs) { [vg1, vg2, vg3] } + + let(:vg1) { instance_double(Y2Storage::LvmVg, sid: 200) } + let(:vg2) { instance_double(Y2Storage::LvmVg, sid: 201) } + let(:vg3) { instance_double(Y2Storage::LvmVg, sid: 202) } + + it "retuns the id of each volume group" do + result = parse(subject.serialized_system)[:availableVolumeGroups] + expect(result).to contain_exactly(200, 201, 202) + end + end + end + describe "serialized_system[:issues]" do context "if there is no candidate drives" do let(:candidate_drives) { [] } diff --git a/service/test/agama/storage/config_checkers/logical_volume_test.rb b/service/test/agama/storage/config_checkers/logical_volume_test.rb index b2023c8298..9d270bf675 100644 --- a/service/test/agama/storage/config_checkers/logical_volume_test.rb +++ b/service/test/agama/storage/config_checkers/logical_volume_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -26,7 +26,7 @@ describe Agama::Storage::ConfigCheckers::LogicalVolume do include_context "config" - subject { described_class.new(lv_config, config, product_config) } + subject { described_class.new(lv_config, vg_config, config, product_config) } let(:config_json) do { @@ -34,6 +34,7 @@ { logicalVolumes: [ { + search: search, filesystem: filesystem, encryption: encryption, usedPool: pool @@ -48,13 +49,16 @@ } end + let(:search) { nil } let(:filesystem) { nil } let(:encryption) { nil } let(:pool) { nil } - let(:lv_config) { config.volume_groups.first.logical_volumes.first } + let(:vg_config) { config.volume_groups.first } + let(:lv_config) { vg_config.logical_volumes.first } describe "#issues" do + include_examples "search issues" include_examples "filesystem issues" include_examples "encryption issues" diff --git a/service/test/agama/storage/config_checkers/md_raid_test.rb b/service/test/agama/storage/config_checkers/md_raid_test.rb index bd83dc4209..7f7c8ab7a5 100644 --- a/service/test/agama/storage/config_checkers/md_raid_test.rb +++ b/service/test/agama/storage/config_checkers/md_raid_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -126,180 +126,6 @@ end end - context "if the MD RAID is reused" do - let(:scenario) { "md_disks.yaml" } - let(:search) { "/dev/md0" } - - before { solve_config } - - context "and there is a config reusing a device member" do - let(:drives) do - [ - { - alias: "vda", - search: "/dev/vda", - filesystem: member_filesystem, - partitions: member_partitions - } - ] - end - - let(:member_filesystem) { nil } - let(:member_partitions) { nil } - - context "and the member config has filesystem" do - let(:member_filesystem) { { path: "/" } } - - it "includes the expected issue" do - issues = subject.issues - expect(issues).to include an_object_having_attributes( - kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, - description: /.*vda.*cannot be formatted.*part of.*md0/ - ) - end - end - - context "and the member config has partitions" do - let(:member_partitions) do - [ - { - filesystem: { path: "/" } - } - ] - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues).to include an_object_having_attributes( - kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, - description: /.*vda.*cannot be partitioned.*part of.*md0/ - ) - end - end - - context "and the member config is used by other device" do - let(:volume_groups) do - [ - { - physicalVolumes: ["vda"] - } - ] - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues).to include an_object_having_attributes( - kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, - description: /.*vda.*cannot be used.*part of.*md0/ - ) - end - end - - context "and the member config is deleted" do - let(:scenario) { "md_raids.yaml" } - - let(:drives) do - [ - { - search: "/dev/vda", - partitions: [ - { - search: "/dev/vda1", - delete: true - } - ] - } - ] - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues).to include an_object_having_attributes( - kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, - description: /.*vda1.*cannot be deleted.*part of.*md0/ - ) - end - end - - context "and the member config is resized" do - let(:scenario) { "md_raids.yaml" } - - let(:drives) do - [ - { - search: "/dev/vda", - partitions: [ - { - search: "/dev/vda1", - size: "2 GiB" - } - ] - } - ] - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues).to include an_object_having_attributes( - kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, - description: /.*vda1.*cannot be resized.*part of.*md0/ - ) - end - end - end - - context "and a member is indirectly deleted (i.e., the drive is formatted)" do - let(:scenario) { "md_raids.yaml" } - - let(:drives) do - [ - { - search: "/dev/vda", - filesystem: { path: "/data" } - } - ] - end - - it "includes the expected issue" do - issues = subject.issues - expect(issues).to include an_object_having_attributes( - kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, - description: /.*vda.*cannot be formatted.*part of.*md0/ - ) - end - 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 - context "if the reused MD RAID is valid" do let(:scenario) { "md_disks.yaml" } diff --git a/service/test/agama/storage/config_checkers/search_test.rb b/service/test/agama/storage/config_checkers/search_test.rb index 15f31583d7..7057b296db 100644 --- a/service/test/agama/storage/config_checkers/search_test.rb +++ b/service/test/agama/storage/config_checkers/search_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -19,25 +19,40 @@ # 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 "../config_context" require "agama/storage/config_checkers/search" -require "y2storage/disk" +require "agama/storage/issue_classes" describe Agama::Storage::ConfigCheckers::Search do - subject { described_class.new(config) } + include_context "config" - let(:config) { Agama::Storage::Configs::Drive.new } + subject { described_class.new(device_config, config) } describe "#issues" do + before { solve_config } + + let(:config_json) do + { + drives: [ + { search: search } + ] + } + end + + let(:scenario) { "disks.yaml" } + let(:search) { nil } + let(:device_config) { config.drives.first } + context "if the device is not found" do - before do - config.search.solve + let(:search) do + { + condition: { name: "/dev/unknown" }, + ifNotFound: if_not_found + } end context "and the device can be skipped" do - before do - config.search.if_not_found = :skip - end + let(:if_not_found) { "skip" } it "does not include any issue" do expect(subject.issues).to be_empty @@ -45,9 +60,7 @@ end context "and the device should be created instead" do - before do - config.search.if_not_found = :create - end + let(:if_not_found) { "create" } it "does not include any issue" do expect(subject.issues).to be_empty @@ -55,26 +68,360 @@ end context "and the device cannot be skipped or created" do - before do - config.search.if_not_found = :error - end + let(:if_not_found) { "error" } it "includes the expected issue" do issues = subject.issues expect(issues).to include an_object_having_attributes( kind: Agama::Storage::IssueClasses::Config::SEARCH_NOT_FOUND, - description: "Mandatory drive not found" + description: "Mandatory device /dev/unknown not found" ) end end end - context "if the device is found" do - before do - config.search.solve(disk) + context "if a MD RAID is reused" do + let(:config_json) do + { + drives: drives, + mdRaids: [ + { + search: search, + filesystem: filesystem, + encryption: encryption, + partitions: partitions + } + ], + volumeGroups: volume_groups + } + end + + let(:drives) { nil } + let(:search) { "/dev/md0" } + let(:filesystem) { nil } + let(:encryption) { nil } + let(:partitions) { nil } + let(:volume_groups) { nil } + + let(:scenario) { "md_disks.yaml" } + let(:device_config) { config.md_raids.first } + + context "and there is a config reusing a device member" do + let(:drives) do + [ + { + alias: "vda", + search: "/dev/vda", + filesystem: member_filesystem, + partitions: member_partitions + } + ] + end + + let(:member_filesystem) { nil } + let(:member_partitions) { nil } + + context "and the member config has filesystem" do + let(:member_filesystem) { { path: "/" } } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, + description: /.*vda.*cannot be formatted.*part of.* MD RAID .*md0/ + ) + end + end + + context "and the member config has partitions" do + let(:member_partitions) do + [ + { + filesystem: { path: "/" } + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, + description: /.*vda.*cannot be partitioned.*part of.* MD RAID .*md0/ + ) + end + end + + context "and the member config is used by other device" do + let(:volume_groups) do + [ + { + physicalVolumes: ["vda"] + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, + description: /.*vda.*cannot be used.*part of.* MD RAID .*md0/ + ) + end + end + + context "and the member config is deleted" do + let(:scenario) { "md_raids.yaml" } + + let(:drives) do + [ + { + search: "/dev/vda", + partitions: [ + { + search: "/dev/vda1", + delete: true + } + ] + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, + description: /.*vda1.*cannot be deleted.*part of.* MD RAID .*md0/ + ) + end + end + + context "and the member config is resized" do + let(:scenario) { "md_raids.yaml" } + + let(:drives) do + [ + { + search: "/dev/vda", + partitions: [ + { + search: "/dev/vda1", + size: "2 GiB" + } + ] + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, + description: /.*vda1.*cannot be resized.*part of.* MD RAID .*md0/ + ) + end + end + + context "and a member is indirectly deleted (i.e., the drive is formatted)" do + let(:scenario) { "md_raids.yaml" } + + let(:drives) do + [ + { + search: "/dev/vda", + filesystem: { path: "/data" } + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER, + description: /.*vda.*cannot be formatted.*part of.* MD RAID .*md0/ + ) + end + end + end + end + + context "if a volume group is reused" do + let(:config_json) do + { + drives: drives, + volumeGroups: volume_groups, + mdRaids: md_raids + } + end + + let(:drives) { [] } + let(:md_raids) { [] } + + let(:volume_groups) do + [ + { search: "/dev/vg0" } + ] + end + + let(:device_config) { config.volume_groups.first } + let(:scenario) { "lvm-over-raids.yaml" } + + context "and there is a config reusing a physical volume" do + let(:md_raids) do + [ + { + alias: "md0", + search: "/dev/md0", + filesystem: member_filesystem, + partitions: member_partitions + } + ] + end + + let(:member_filesystem) { nil } + let(:member_partitions) { nil } + + context "and the member config has filesystem" do + let(:member_filesystem) { { path: "/" } } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_PV, + description: /.*md0.*cannot be formatted.*physical volume of .*volume group .*vg0/ + ) + end + end + + context "and the member config has partitions" do + let(:member_partitions) do + [ + { + filesystem: { path: "/" } + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_PV, + description: /.*md0.*cannot be partitioned.*physical volume of.*volume group .*vg0/ + ) + end + end + + context "and the member config is used by other device" do + let(:volume_groups) do + [ + { search: "/dev/vg0" }, + { physicalVolumes: ["md0"] } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_PV, + description: /.*md0.*cannot be used.*physical volume of.*volume group .*vg0/ + ) + end + end + + context "and the member config is deleted" do + let(:scenario) { "several_vgs.yaml" } + + let(:drives) do + [ + { + search: "/dev/sda", + partitions: [ + { + search: "/dev/sda3", + delete: true + } + ] + } + ] + end + + let(:volume_groups) do + [ + { search: "/dev/data" } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_PV, + description: /.*sda3.*cannot be deleted.*physical volume of.* volume group .*data/ + ) + end + end + + context "and the member config is resized" do + let(:scenario) { "several_vgs.yaml" } + + let(:drives) do + [ + { + search: "/dev/sda", + partitions: [ + { + search: "/dev/sda3", + size: "2 GiB" + } + ] + } + ] + end + + let(:volume_groups) do + [ + { search: "/dev/data" } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_PV, + description: /.*sda3.*cannot be resized.*physical volume of.* volume group .*data/ + ) + end + end + + context "and a member is indirectly deleted (parent device is formatted)" do + let(:scenario) { "several_vgs.yaml" } + + let(:drives) do + [ + { + search: "/dev/sda", + filesystem: { path: "/data" } + } + ] + end + + let(:volume_groups) do + [ + { search: "/dev/data" } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + kind: Agama::Storage::IssueClasses::Config::MISUSED_PV, + description: /.*sda.*cannot be formatted.*physical volume of.* volume group .*data/ + ) + end + end end + end - let(:disk) { instance_double(Y2Storage::Disk) } + context "if the device is found" do + let(:search) { "/dev/vda" } it "does not include an issue" do expect(subject.issues.size).to eq(0) diff --git a/service/test/agama/storage/config_checkers/volume_group_test.rb b/service/test/agama/storage/config_checkers/volume_group_test.rb index e267b7b596..3c182fcc23 100644 --- a/service/test/agama/storage/config_checkers/volume_group_test.rb +++ b/service/test/agama/storage/config_checkers/volume_group_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require_relative "../config_context" +require_relative "./examples" require "agama/storage/config_checkers/volume_group" describe Agama::Storage::ConfigCheckers::VolumeGroup do @@ -35,18 +36,22 @@ volumeGroups: [ { name: name, + search: search, physicalVolumes: physical_volumes } ] } end - let(:name) { nil } + let(:name) { "vg0" } + let(:search) { nil } let(:physical_volumes) { nil } let(:vg_config) { config.volume_groups.first } describe "#issues" do + include_examples "search issues" + context "if the volume group has no name" do let(:name) { nil } diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/boot_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/boot_test.rb new file mode 100644 index 0000000000..fdd2666c69 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/boot_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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_model_conversions/boot" +require "agama/storage/configs/boot" +require "agama/storage/configs/drive" +require "agama/storage/configs/search" + +describe Agama::Storage::ConfigConversions::FromModelConversions::Boot do + subject do + described_class.new(model_json, targets) + end + + let(:model_json) do + { + configure: configure, + device: { + default: default, + name: name + } + } + end + + let(:configure) { false } + let(:default) { false } + let(:name) { nil } + + let(:targets) { [] } + + describe "#convert" do + it "returns a boot config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Configs::Boot) + end + + context "if boot is not set to be configured" do + let(:configure) { false } + let(:default) { true } + let(:name) { "/dev/vda" } + + it "returns the expected config" do + config = subject.convert + expect(config.configure?).to eq(false) + expect(config.device.default?).to eq(true) + expect(config.device.device_alias).to be_nil + end + end + + context "if boot is set to be configured" do + let(:configure) { true } + + context "and the boot device is set to default" do + let(:default) { true } + let(:name) { "/dev/vda" } + + it "returns the expected config" do + config = subject.convert + expect(config.configure?).to eq(true) + expect(config.device.default?).to eq(true) + expect(config.device.device_alias).to be_nil + end + end + + context "and the boot device is not set to default" do + let(:default) { false } + + context "and the boot device does not specify 'name'" do + let(:name) { nil } + + it "returns the expected config" do + config = subject.convert + expect(config.configure?).to eq(true) + expect(config.device.default?).to eq(false) + expect(config.device.device_alias).to be_nil + end + end + + context "and the boot device specifies a 'name'" do + let(:name) { "/dev/vda" } + + context "and there is a target for the given boot device name" do + let(:targets) { [drive] } + + let(:drive) do + Agama::Storage::Configs::Drive.new.tap do |drive| + drive.search = Agama::Storage::Configs::Search.new.tap { |s| s.name = name } + end + end + + it "sets an alias to the drive config" do + subject.convert + expect(drive.alias).to_not be_nil + end + + it "returns the expected config" do + config = subject.convert + expect(config.configure?).to eq(true) + expect(config.device.default?).to eq(false) + expect(config.device.device_alias).to eq(drive.alias) + end + end + + context "and there is not a target for the given boot device name" do + let(:drives) { [] } + + it "returns the expected config" do + config = subject.convert + expect(config.configure?).to eq(true) + expect(config.device.default?).to eq(false) + expect(config.device.device_alias).to be_nil + end + end + end + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/config_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/config_test.rb new file mode 100644 index 0000000000..3dda8cf065 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/config_test.rb @@ -0,0 +1,487 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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 "./context" +require "agama/config" +require "agama/storage/config" +require "agama/storage/config_conversions/from_model_conversions/config" +require "agama/storage/configs/boot" +require "agama/storage/configs/boot_device" +require "agama/storage/configs/drive" +require "agama/storage/configs/encryption" +require "agama/storage/configs/md_raid" + +describe Agama::Storage::ConfigConversions::FromModelConversions::Config do + include_context "from model conversions" + + subject do + described_class.new(model_json, product_config, storage_system) + end + + describe "#convert" do + let(:model_json) do + { + encryption: encryption, + boot: boot, + drives: drives, + volumeGroups: volume_groups, + mdRaids: md_raids + } + end + + let(:encryption) { nil } + 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.volume_groups).to be_empty + end + end + + context "if 'mdRaids' is not specified" do + let(:md_raids) { nil } + + it "sets #md_raids to the expected value" do + config = subject.convert + expect(config.md_raids).to be_empty + end + end + + context "if 'boot' is specified" do + let(:boot) do + { + configure: true, + device: { + default: true + } + } + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(true) + expect(boot.device.device_alias).to be_nil + end + + context "and there is a drive config for the given boot device name" do + let(:boot) do + { + configure: true, + device: { + default: false, + name: "/dev/vda" + } + } + end + + let(:drives) do + [ + { name: "/dev/vda" } + ] + end + + it "does not add more drives" do + config = subject.convert + expect(config.drives.size).to eq(1) + expect(config.drives.first.search.name).to eq("/dev/vda") + end + + it "sets an alias to the drive config" do + config = subject.convert + drive = config.drives.first + expect(drive.alias).to_not be_nil + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + drive = config.drives.first + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq(drive.alias) + end + end + + context "and there is not a drive config for the given boot device name" do + let(:boot) do + { + configure: true, + device: { + default: false, + name: "/dev/vda" + } + } + end + + let(:drives) do + [ + { name: "/dev/vdb" } + ] + end + + it "adds a drive for the boot device" do + config = subject.convert + expect(config.drives.size).to eq(2) + + drive = config.drives.find { |d| d.search.name == "/dev/vda" } + expect(drive.alias).to_not be_nil + expect(drive.partitions).to be_empty + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + drive = config.drives.find { |d| d.search.name == "/dev/vda" } + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq(drive.alias) + end + end + + context "and there is a MD RAID config for the given boot device name" do + let(:boot) do + { + configure: true, + device: { + default: false, + name: "/dev/md0" + } + } + end + + let(:md_raids) do + [ + { name: "/dev/md0" } + ] + end + + it "does not add more MD RAIDs" do + config = subject.convert + expect(config.md_raids.size).to eq(1) + expect(config.md_raids.first.search.name).to eq("/dev/md0") + end + + it "sets an alias to the MD RAID config" do + config = subject.convert + md = config.md_raids.first + expect(md.alias).to_not be_nil + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + md = config.md_raids.first + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq(md.alias) + end + end + + context "and there is not a MD RAID config for the given boot device name" do + let(:scenario) { "md_raids.yaml" } + + let(:boot) do + { + configure: true, + device: { + default: false, + name: "/dev/md0" + } + } + end + + let(:md_raids) do + [ + { name: "/dev/md1" } + ] + end + + it "adds a MD RAID for the boot device" do + config = subject.convert + expect(config.md_raids.size).to eq(2) + + md = config.md_raids.find { |d| d.search.name == "/dev/md0" } + expect(md.alias).to_not be_nil + expect(md.partitions).to be_empty + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + md = config.md_raids.find { |d| d.search.name == "/dev/md0" } + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq(md.alias) + 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 + [ + { name: "/dev/vda" }, + { name: "/dev/vdb" } + ] + 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.search.name).to eq("/dev/vda") + expect(drive1.partitions).to eq([]) + expect(drive2.search.name).to eq("/dev/vdb") + expect(drive2.partitions).to eq([]) + end + end + 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 raids" do + let(:md_raids) do + [ + { name: "/dev/md0" }, + { name: "/dev/md1" } + ] + 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)) + + md_raid1, md_raid2 = config.md_raids + expect(md_raid1.search.name).to eq("/dev/md0") + expect(md_raid1.partitions).to eq([]) + expect(md_raid2.search.name).to eq("/dev/md1") + expect(md_raid2.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: "/dev/vg0" }, + { name: "/dev/vg1" } + ] + 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)) + + vg1, vg2 = config.volume_groups + expect(vg1.search.name).to eq("/dev/vg0") + expect(vg1.logical_volumes).to eq([]) + expect(vg2.search.name).to eq("/dev/vg1") + expect(vg2.logical_volumes).to eq([]) + end + end + + context "if a volume group specifies 'targetDevices'" do + let(:scenario) { "md_raids.yaml" } + + let(:volume_groups) { [{ targetDevices: ["/dev/vda", "/dev/vdb", "/dev/md0"] }] } + + let(:drives) do + [ + { name: "/dev/vda" }, + { name: "/dev/vdc" } + ] + end + + let(:md_raids) do + [ + { name: "/dev/md1" } + ] + end + + it "adds the missing drives" do + config = subject.convert + expect(config.drives.size).to eq(3) + expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive)) + expect(config.drives).to include(an_object_having_attributes({ device_name: "/dev/vdb" })) + end + + it "adds the missing MD RAIDs" do + config = subject.convert + expect(config.md_raids.size).to eq(2) + expect(config.md_raids).to all(be_a(Agama::Storage::Configs::MdRaid)) + expect(config.md_raids) + .to include(an_object_having_attributes({ device_name: "/dev/md0" })) + end + end + end + + context "if 'encryption' is specified" do + let(:encryption) do + { + method: "luks1", + password: "12345" + } + end + + let(:drives) do + [ + { + name: "/dev/vda", + partitions: [ + { + name: "/dev/vda1", + mountPath: "/test" + }, + { + name: "/dev/vda2", + mountPath: "/test2", + filesystem: { reuse: true } + }, + { + mountPath: "/boot/efi" + }, + { + mountPath: "/test3" + }, + {} + ] + } + ] + end + + let(:md_raids) do + [ + { + name: "/dev/md0", + partitions: [ + { name: "/dev/md0-p1" }, + {} + ] + } + ] + end + + let(:volume_groups) do + [ + { + vgName: "system", + targetDevices: ["/dev/vda"] + } + ] + end + + it "sets #encryption to the newly formatted partitions, except the boot-related ones" do + config = subject.convert + partitions = config.partitions + new_partitions = partitions.reject(&:search) + reused_partitions = partitions.select(&:search) + mounted_partitions, reformatted_partitions = reused_partitions.partition do |part| + part.filesystem.reuse? + end + new_non_boot_partitions, new_boot_partitions = new_partitions.partition do |part| + part.filesystem&.path != "/boot/efi" + end + + expect(new_non_boot_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1)) + expect(new_non_boot_partitions.map { |p| p.encryption.password }).to all(eq("12345")) + expect(reformatted_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1)) + expect(reformatted_partitions.map { |p| p.encryption.password }).to all(eq("12345")) + expect(mounted_partitions.map(&:encryption)).to all(be_nil) + expect(new_boot_partitions.map(&:encryption)).to all(be_nil) + end + + it "sets #encryption for the automatically created physical volumes" do + config = subject.convert + volume_group = config.volume_groups.first + target_encryption = volume_group.physical_volumes_encryption + + expect(target_encryption.method.id).to eq(:luks1) + expect(target_encryption.password).to eq("12345") + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/context.rb b/service/test/agama/storage/config_conversions/from_model_conversions/context.rb new file mode 100644 index 0000000000..6775c89f5b --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/context.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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 "../../product_config_context" +require "agama/storage/system" +require "y2storage/encryption_method" + +shared_context "from model conversions" do + include Agama::RSpec::StorageHelpers + + include_context "product config" + + before do + mock_storage(devicegraph: scenario) + + # Speed up tests by avoding real check of TPM presence. + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + end + + let(:scenario) { "disks.yaml" } + + let(:storage_system) { Agama::Storage::System.new } +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/drive_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/drive_test.rb new file mode 100644 index 0000000000..7e2f25e4be --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/drive_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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 "./context" +require_relative "./examples" +require "agama/storage/config_conversions/from_model_conversions/drive" +require "agama/storage/configs/drive" +require "agama/storage/configs/search" + +describe Agama::Storage::ConfigConversions::FromModelConversions::Drive do + include_context "from model conversions" + + subject do + described_class.new(model_json, product_config) + end + + describe "#convert" do + let(:model_json) { {} } + + it "returns a drive config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Configs::Drive) + end + + context "if 'name' is not specified" do + let(:model_json) { {} } + + 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) + end + end + + context "if neither 'mountPath' nor 'filesystem' are specified" do + let(:model_json) { {} } + include_examples "without filesystem" + end + + context "if 'ptableType' is not specified" do + let(:model_json) { {} } + include_examples "without ptableType" + end + + context "if 'spacePolicy' is not specified" do + let(:model_json) { {} } + include_examples "without spacePolicy", :partitions + end + + context "if 'name' is specified" do + let(:model_json) { { name: name } } + include_examples "with name" + end + + context "if 'mountPath' is specified" do + let(:model_json) { { mountPath: mountPath } } + include_examples "with mountPath" + end + + context "if 'filesystem' is specified" do + let(:model_json) { { filesystem: filesystem } } + include_examples "with filesystem" + end + + context "if 'mountPath' and 'filesystem' are specified" do + let(:model_json) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem" + end + + context "if 'ptableType' is specified" do + let(:model_json) { { ptableType: ptableType } } + include_examples "with ptableType" + end + + context "if 'partitions' is specified" do + let(:model_json) { { partitions: partitions } } + include_examples "with partitions" + end + + context "if 'spacePolicy' is specified" do + let(:model_json) { { spacePolicy: spacePolicy } } + include_examples "with spacePolicy" + end + + context "if 'spacePolicy' and 'partitions' are specified" do + let(:model_json) { { spacePolicy: spacePolicy, partitions: partitions } } + include_examples "with spacePolicy and volumes" + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/examples.rb b/service/test/agama/storage/config_conversions/from_model_conversions/examples.rb new file mode 100644 index 0000000000..57603851a7 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/examples.rb @@ -0,0 +1,837 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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/btrfs" +require "agama/storage/configs/filesystem" +require "agama/storage/configs/partition" +require "agama/storage/configs/search" +require "agama/storage/configs/size" +require "y2storage/filesystems/type" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +shared_examples "without filesystem" do + it "does not set #filesystem" do + config = subject.convert + expect(config.filesystem).to be_nil + end +end + +shared_examples "without ptableType" do + it "does not set #ptable_type" do + config = subject.convert + expect(config.ptable_type).to be_nil + end +end + +shared_examples "without spacePolicy" do |volumes_property| + context "if the default space policy is 'keep'" do + let(:space_policy) { "keep" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = config.public_send(volumes_property) + expect(volumes).to be_empty + end + end + + context "if the default space policy is 'delete'" do + let(:space_policy) { "delete" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = config.public_send(volumes_property) + expect(volumes.size).to eq(1) + + volume = volumes.first + expect(volume.search.name).to be_nil + expect(volume.search.if_not_found).to eq(:skip) + expect(volume.search.max).to be_nil + expect(volume.delete?).to eq(true) + end + end + + context "if the default space policy is 'resize'" do + let(:space_policy) { "resize" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = config.public_send(volumes_property) + expect(volumes.size).to eq(1) + + volume = volumes.first + expect(volume.search.name).to be_nil + expect(volume.search.if_not_found).to eq(:skip) + expect(volume.search.max).to be_nil + expect(volume.delete?).to eq(false) + expect(volume.size.default?).to eq(false) + expect(volume.size.min).to eq(Y2Storage::DiskSize.zero) + expect(volume.size.max).to be_nil + end + end + + context "if the default space policy is 'custom'" do + let(:space_policy) { "custom" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = config.public_send(volumes_property) + expect(volumes).to be_empty + end + end +end + +shared_examples "without size" do + 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 + +shared_examples "without delete" do + it "sets #delete to false" do + config = subject.convert + expect(config.delete?).to eq(false) + end +end + +shared_examples "without deleteIfNeeded" do + it "sets #delete_if_needed to false" do + config = subject.convert + expect(config.delete_if_needed?).to eq(false) + end +end + +shared_examples "with name" do + let(:name) { "/dev/vda" } + + 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/vda") + expect(config.search.max).to be_nil + expect(config.search.if_not_found).to eq(:error) + end +end + +shared_examples "with mountPath" do + let(:mountPath) { "/test" } + + 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 eq("/test") + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end +end + +shared_examples "with filesystem" do + let(:filesystem) do + { + reuse: reuse, + default: default, + type: type, + label: label + } + end + + let(:reuse) { false } + let(:default) { false } + let(:type) { nil } + let(:label) { "test" } + + context "if the filesystem is default" do + let(:default) { true } + + RSpec.shared_examples "#filesystem set to default btrfs" do + it "sets #filesystem to the expected btrfs-related values" 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(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.label).to eq("test") + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + + context "and the type is 'btrfs'" do + let(:type) { "btrfs" } + + include_examples "#filesystem set to default btrfs" + + it "sets Btrfs snapshots to false" do + config = subject.convert + filesystem = config.filesystem + expect(filesystem.type.btrfs.snapshots?).to eq(false) + end + end + + context "and the type is 'btrfsSnapshots'" do + let(:type) { "btrfsSnapshots" } + + include_examples "#filesystem set to default btrfs" + + it "sets Btrfs snapshots to true" do + config = subject.convert + filesystem = config.filesystem + expect(filesystem.type.btrfs.snapshots?).to eq(true) + end + end + + context "and the type is 'btrfsImmutable'" do + let(:type) { "btrfsSnapshots" } + + include_examples "#filesystem set to default btrfs" + + it "sets Btrfs snapshots to true" do + config = subject.convert + filesystem = config.filesystem + expect(filesystem.type.btrfs.snapshots?).to eq(true) + end + end + + context "and the type is not 'btrfs'" do + let(:type) { "xfs" } + + 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(true) + 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 be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + end + + context "if the filesystem is not default" do + let(:default) { false } + + RSpec.shared_examples "#filesystem set to non-default btrfs" do + it "sets #filesystem to the expected btrfs-related values" 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).to be_a(Agama::Storage::Configs::Btrfs) + expect(filesystem.label).to eq("test") + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + + context "and the type is 'btrfs'" do + let(:type) { "btrfs" } + + include_examples "#filesystem set to non-default btrfs" + + it "sets Btrfs snapshots to false" do + config = subject.convert + filesystem = config.filesystem + expect(filesystem.type.btrfs.snapshots?).to eq(false) + end + end + + context "and the type is 'btrfsSnapshots'" do + let(:type) { "btrfsSnapshots" } + + include_examples "#filesystem set to non-default btrfs" + + it "sets Btrfs snapshots to true" do + config = subject.convert + filesystem = config.filesystem + expect(filesystem.type.btrfs.snapshots?).to eq(true) + end + end + + context "and the type is 'btrfsImmutable'" do + let(:type) { "btrfsImmutable" } + + include_examples "#filesystem set to non-default btrfs" + + it "sets Btrfs snapshots to true" do + config = subject.convert + filesystem = config.filesystem + expect(filesystem.type.btrfs.snapshots?).to eq(true) + end + end + + context "and the type is not 'btrfs'" do + let(:type) { "xfs" } + + 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::XFS) + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to eq("test") + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + end + + context "if the filesystem specifies 'reuse'" do + let(:reuse) { true } + + 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 be_nil + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to eq("test") + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + + context "if the filesystem does not specify 'type'" do + let(:type) { nil } + + 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 be_nil + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to eq("test") + 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 the filesystem does not specify 'label'" do + let(:label) { nil } + + 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 be_nil + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to eq([]) + expect(filesystem.mount_options).to eq([]) + end + end +end + +shared_examples "with mountPath and filesystem" do + let(:mountPath) { "/test" } + + let(:filesystem) do + { + default: false, + type: "btrfs", + label: "test" + } + 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).to be_a(Agama::Storage::Configs::Btrfs) + expect(filesystem.label).to eq("test") + expect(filesystem.path).to eq("/test") + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end +end + +shared_examples "with ptableType" do + let(:ptableType) { "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 + +shared_examples "with size" do + context "if the size is default" do + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + it "sets #size to the expected value" do + config = subject.convert + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + + context "if the size is not default" do + let(:size) do + { + default: false, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + it "sets #size to the expected value" do + config = subject.convert + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + + context "if the size does not spicify 'max'" do + let(:size) do + { + default: false, + min: 1.GiB.to_i + } + end + + it "sets #size to the expected value" do + config = subject.convert + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(Y2Storage::DiskSize.unlimited) + end + end +end + +shared_examples "with partitions" 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 + [ + { mountPath: "/" }, + { mountPath: "/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 + +shared_examples "with spacePolicy" do + def volumes_config(config) + config.respond_to?(:logical_volumes) ? config.logical_volumes : config.partitions + end + + context "if space policy is 'keep'" do + let(:spacePolicy) { "keep" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes).to be_empty + end + end + + context "if space policy is 'delete'" do + let(:spacePolicy) { "delete" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes.size).to eq(1) + + volume = volumes.first + expect(volume.search.name).to be_nil + expect(volume.search.if_not_found).to eq(:skip) + expect(volume.search.max).to be_nil + expect(volume.delete?).to eq(true) + end + end + + context "if space policy is 'resize'" do + let(:spacePolicy) { "resize" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes.size).to eq(1) + + volume = volumes.first + expect(volume.search.name).to be_nil + expect(volume.search.if_not_found).to eq(:skip) + expect(volume.search.max).to be_nil + expect(volume.delete?).to eq(false) + expect(volume.size.default?).to eq(false) + expect(volume.size.min).to eq(Y2Storage::DiskSize.zero) + expect(volume.size.max).to be_nil + end + end + + context "if space policy is 'custom'" do + let(:spacePolicy) { "custom" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes).to be_empty + end + end +end + +shared_examples "with spacePolicy and volumes" do + def volumes_config(config) + config.respond_to?(:logical_volumes) ? config.logical_volumes : config.partitions + end + + let(:volumes_json) do + [ + # Reused volumes with some usage. + { + name: "/dev/vol1", + mountPath: "/test1", + size: { default: true, min: 10.GiB.to_i } + }, + # Reused volume with some usage. + { + name: "/dev/vol2", + mountPath: "/test2", + resizeIfNeeded: true, + size: { default: false, min: 10.GiB.to_i } + }, + # Reused volume with some usage. + { + name: "/dev/vol3", + mountPath: "/test3", + resize: true, + size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } + }, + # Reused volume representing a space action (resize). + { + name: "/dev/vol4", + resizeIfNeeded: true, + size: { default: false, min: 10.GiB.to_i } + }, + # Reused volume representing a space action (resize). + { + name: "/dev/vol5", + resize: true, + size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } + }, + # Reused volume representing a space action (delete). + { + name: "/dev/vol6", + delete: true + }, + # Reused volume representing a space action (delete). + { + name: "/dev/vol7", + deleteIfNeeded: true + }, + # Reused volume representing a space action (keep). + { + name: "/dev/vol8" + }, + # New volume. + {}, + # New volume. + { + mountPath: "/", + resizeIfNeeded: true, + size: { default: false, min: 10.GiB.to_i }, + filesystem: { type: "btrfs" } + } + ] + end + + let(:partitions) { volumes_json } + let(:logical_volumes) { volumes_json } + + context "if space policy is 'keep'" do + let(:spacePolicy) { "keep" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes.size).to eq(5) + expect(volumes[0].search.name).to eq("/dev/vol1") + expect(volumes[1].search.name).to eq("/dev/vol2") + expect(volumes[2].search.name).to eq("/dev/vol3") + expect(volumes[3].filesystem).to be_nil + expect(volumes[4].filesystem.path).to eq("/") + end + end + + context "if space policy is 'delete'" do + let(:spacePolicy) { "delete" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes.size).to eq(6) + expect(volumes[0].search.name).to eq("/dev/vol1") + expect(volumes[1].search.name).to eq("/dev/vol2") + expect(volumes[2].search.name).to eq("/dev/vol3") + expect(volumes[3].filesystem).to be_nil + expect(volumes[4].filesystem.path).to eq("/") + expect(volumes[5].search.name).to be_nil + expect(volumes[5].search.max).to be_nil + expect(volumes[5].delete).to eq(true) + end + end + + context "if space policy is 'resize'" do + let(:spacePolicy) { "resize" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes.size).to eq(6) + expect(volumes[0].search.name).to eq("/dev/vol1") + expect(volumes[1].search.name).to eq("/dev/vol2") + expect(volumes[2].search.name).to eq("/dev/vol3") + expect(volumes[3].filesystem).to be_nil + expect(volumes[4].filesystem.path).to eq("/") + expect(volumes[5].search.name).to be_nil + expect(volumes[5].search.max).to be_nil + expect(volumes[5].size.default?).to eq(false) + expect(volumes[5].size.min).to eq(Y2Storage::DiskSize.zero) + expect(volumes[5].size.max).to be_nil + end + end + + context "if space policy is 'custom'" do + let(:spacePolicy) { "custom" } + + it "sets volumes to the expected value" do + config = subject.convert + volumes = volumes_config(config) + expect(volumes.size).to eq(9) + expect(volumes[0].search.name).to eq("/dev/vol1") + expect(volumes[1].search.name).to eq("/dev/vol2") + expect(volumes[2].search.name).to eq("/dev/vol3") + expect(volumes[3].filesystem).to be_nil + expect(volumes[4].filesystem.path).to eq("/") + expect(volumes[5].search.name).to eq("/dev/vol4") + expect(volumes[6].search.name).to eq("/dev/vol5") + expect(volumes[7].search.name).to eq("/dev/vol6") + expect(volumes[8].search.name).to eq("/dev/vol7") + end + end +end + +shared_examples "with resizeIfNeeded" do + context "if 'resizeIfNeeded' is true" do + let(:resize_if_needed) { true } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::Size) + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(Y2Storage::DiskSize.zero) + expect(config.size.max).to be_nil + end + end + + context "if 'resizeIfNeeded' is false" do + let(:resize_if_needed) { false } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::Size) + 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 "with size and resizeIfNeeded" do + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + context "if 'resizeIfNeeded' is true" do + let(:resize_if_needed) { true } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::Size) + expect(config.size.default?).to eq(false) + expect(config.size.min).to eq(Y2Storage::DiskSize.zero) + expect(config.size.max).to be_nil + end + end + + context "if 'resizeIfNeeded' is false" do + let(:resize_if_needed) { false } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::Size) + expect(config.size.default?).to eq(true) + expect(config.size.min).to eq(1.GiB) + expect(config.size.max).to eq(10.GiB) + end + end +end + +shared_examples "with size and resize" do + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + context "if 'resize' is true" do + let(:resize) { true } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::Size) + expect(config.size.default?).to eq(true) + expect(config.size.min).to eq(1.GiB) + expect(config.size.max).to eq(10.GiB) + end + end + + context "if 'size' is false" do + let(:resize) { false } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::Size) + expect(config.size.default?).to eq(true) + expect(config.size.min).to eq(1.GiB) + expect(config.size.max).to eq(10.GiB) + end + end +end + +shared_examples "with delete" do + let(:mount_path) { nil } + + it "sets #delete to true" do + config = subject.convert + expect(config.delete?).to eq(true) + end + + context "and 'mountPath' is specified" do + let(:mount_path) { "/test" } + + it "sets #delete to false" do + config = subject.convert + expect(config.delete?).to eq(false) + end + end +end + +shared_examples "with deleteIfNeeded" do + let(:mount_path) { nil } + + it "sets #delete_if_needed to true" do + config = subject.convert + expect(config.delete_if_needed?).to eq(true) + end + + context "and the partition has a mount path" do + let(:mount_path) { "/test" } + + it "sets #delete_if_needed to false" do + config = subject.convert + expect(config.delete_if_needed?).to eq(false) + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/logical_volume_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/logical_volume_test.rb new file mode 100644 index 0000000000..15335247f6 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/logical_volume_test.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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 "./examples" +require "agama/storage/config_conversions/from_model_conversions/logical_volume" +require "agama/storage/configs/logical_volume" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::FromModelConversions::LogicalVolume do + subject do + described_class.new(model_json) + end + + describe "#convert" do + let(:model_json) { {} } + + it "returns a logical volume config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Configs::LogicalVolume) + end + + context "if 'lvName' is not specified" do + let(:model_json) { {} } + + it "does not set #name" do + config = subject.convert + expect(config.name).to be_nil + end + end + + context "if 'size' is not specified" do + let(:model_json) { {} } + include_examples "without size" + end + + context "if neither 'mountPath' nor 'filesystem' are specified" do + let(:model_json) { {} } + include_examples "without filesystem" + end + + context "if 'stripes' is not specified" do + let(:model_json) { {} } + + it "does not set #stripes" do + config = subject.convert + expect(config.stripes).to be_nil + end + end + + context "if 'stripeSize' is not specified" do + let(:model_json) { {} } + + it "does not set #stripe_size" do + config = subject.convert + expect(config.stripe_size).to be_nil + end + end + + context "if 'lvName' is specified" do + let(:model_json) { { lvName: "lv1" } } + + it "sets #name to the expected value" do + config = subject.convert + expect(config.name).to eq("lv1") + end + end + + context "if 'size' is specified" do + let(:model_json) { { size: size } } + include_examples "with size" + end + + context "if 'mountPath' is specified" do + let(:model_json) { { mountPath: mountPath } } + include_examples "with mountPath" + end + + context "if 'filesystem' is specified" do + let(:model_json) { { filesystem: filesystem } } + include_examples "with filesystem" + end + + context "if 'mountPath' and 'filesystem' are specified" do + let(:model_json) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem" + end + + context "if 'stripes' is specified" do + let(:model_json) { { stripes: 4 } } + + it "sets #stripes to the expected value" do + config = subject.convert + expect(config.stripes).to eq(4) + end + end + + context "if 'stripeSize' is specified" do + let(:model_json) { { stripeSize: 2.KiB.to_i } } + + it "sets #stripeSize to the expected value" do + config = subject.convert + expect(config.stripe_size).to eq(2.KiB) + end + end + + context "if 'resizeIfNeeded' is specified" do + let(:model_json) { { resizeIfNeeded: resize_if_needed } } + include_examples "with resizeIfNeeded" + end + + context "if 'size' and 'resizeIfNeeded' are specified" do + let(:model_json) { { size: size, resizeIfNeeded: resize_if_needed } } + include_examples "with size and resizeIfNeeded" + end + + context "if 'size' and 'resize' are specified" do + let(:model_json) { { size: size, resize: resize } } + include_examples "with size and resize" + end + + context "if 'delete' is specified" do + let(:model_json) { { delete: true, mountPath: mount_path } } + include_examples "with delete" + end + + context "if 'deleteIfNeeded' is specified" do + let(:model_json) { { deleteIfNeeded: true, mountPath: mount_path } } + include_examples "with deleteIfNeeded" + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/md_raid_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/md_raid_test.rb new file mode 100644 index 0000000000..b84abed58c --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/md_raid_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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 "./context" +require_relative "./examples" +require "agama/storage/config_conversions/from_model_conversions/md_raid" +require "agama/storage/configs/md_raid" + +describe Agama::Storage::ConfigConversions::FromModelConversions::MdRaid do + include_context "from model conversions" + + subject do + described_class.new(model_json, product_config) + end + + describe "#convert" do + let(:model_json) { {} } + + it "returns a MD RAID config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Configs::MdRaid) + end + + context "if 'name' is not specified" do + let(:model_json) { {} } + + it "sets #search to the expected value" do + config = subject.convert + expect(config.search).to be_nil + end + end + + context "if neither 'mountPath' nor 'filesystem' are specified" do + let(:model_json) { {} } + include_examples "without filesystem" + end + + context "if 'ptableType' is not specified" do + let(:model_json) { {} } + include_examples "without ptableType" + end + + context "if 'spacePolicy' is not specified" do + let(:model_json) { {} } + include_examples "without spacePolicy", :partitions + end + + context "if 'name' is specified" do + let(:model_json) { { name: name } } + include_examples "with name" + end + + context "if 'mountPath' is specified" do + let(:model_json) { { mountPath: mountPath } } + include_examples "with mountPath" + end + + context "if 'filesystem' is specified" do + let(:model_json) { { filesystem: filesystem } } + include_examples "with filesystem" + end + + context "if 'mountPath' and 'filesystem' are specified" do + let(:model_json) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem" + end + + context "if 'ptableType' is specified" do + let(:model_json) { { ptableType: ptableType } } + include_examples "with ptableType" + end + + context "if 'partitions' is specified" do + let(:model_json) { { partitions: partitions } } + include_examples "with partitions" + end + + context "if 'spacePolicy' is specified" do + let(:model_json) { { spacePolicy: spacePolicy } } + include_examples "with spacePolicy" + end + + context "if 'spacePolicy' and 'partitions' are specified" do + let(:model_json) { { spacePolicy: spacePolicy, partitions: partitions } } + include_examples "with spacePolicy and volumes" + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/partition_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/partition_test.rb new file mode 100644 index 0000000000..55bc8f04b2 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/partition_test.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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 "./examples" +require "agama/storage/config_conversions/from_model_conversions/partition" +require "agama/storage/configs/partition" +require "y2storage/partition_id" + +describe Agama::Storage::ConfigConversions::FromModelConversions::Partition do + subject do + described_class.new(model_json) + end + + describe "#convert" do + let(:model_json) { {} } + + it "returns a partition config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Configs::Partition) + end + + context "if 'name' is not specified" do + let(:model_json) { {} } + + it "does not set #search" do + config = subject.convert + expect(config.search).to be_nil + end + end + + context "if a partition does not spicify 'id'" do + let(:model_json) { {} } + + it "does not set #id" do + config = subject.convert + expect(config.id).to be_nil + end + end + + context "if 'size' is not specified" do + let(:model_json) { {} } + include_examples "without size" + end + + context "if neither 'mountPath' nor 'filesystem' are specified" do + let(:model_json) { {} } + include_examples "without filesystem" + end + + context "if 'delete' is not specified" do + let(:model_json) { {} } + include_examples "without delete" + end + + context "if 'deleteIfNeeded' is not specified" do + let(:model_json) { {} } + include_examples "without deleteIfNeeded" + end + + context "if 'name' is not specified" do + # Add mount path in order to use the partition. Otherwise the partition is omitted because it + # is considered a keep action. + let(:model_json) { { name: name, mountPath: "/test2" } } + include_examples "with name" + end + + context "if 'id' is specified" do + let(:model_json) { { id: "esp" } } + + it "sets #id to the expected value" do + config = subject.convert + expect(config.id).to eq(Y2Storage::PartitionId::ESP) + end + end + + context "if 'size' is specified" do + let(:model_json) { { size: size } } + include_examples "with size" + end + + context "if 'mountPath' is specified" do + let(:model_json) { { mountPath: mountPath } } + include_examples "with mountPath" + end + + context "if 'filesystem' is specified" do + let(:model_json) { { filesystem: filesystem } } + include_examples "with filesystem" + end + + context "if 'mountPath' and 'filesystem' are specified" do + let(:model_json) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem" + end + + context "if 'resizeIfNeeded' is specified" do + let(:model_json) { { resizeIfNeeded: resize_if_needed } } + include_examples "with resizeIfNeeded" + end + + context "if 'size' and 'resizeIfNeeded' are specified" do + let(:model_json) { { size: size, resizeIfNeeded: resize_if_needed } } + include_examples "with size and resizeIfNeeded" + end + + context "if 'size' and 'resize' are specified" do + let(:model_json) { { size: size, resize: resize } } + include_examples "with size and resize" + end + + context "if 'delete' is specified" do + let(:model_json) { { delete: true, mountPath: mount_path } } + include_examples "with delete" + end + + context "if 'deleteIfNeeded' is specified" do + let(:model_json) { { deleteIfNeeded: true, mountPath: mount_path } } + include_examples "with deleteIfNeeded" + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/volume_group_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/volume_group_test.rb new file mode 100644 index 0000000000..07207a3c1f --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_conversions/volume_group_test.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] 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 "./context" +require_relative "./examples" +require "agama/storage/config_conversions/from_model_conversions/volume_group" +require "agama/storage/configs/drive" +require "agama/storage/configs/logical_volume" +require "agama/storage/configs/md_raid" +require "agama/storage/configs/search" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::FromModelConversions::VolumeGroup do + include_context "from model conversions" + + subject do + described_class.new(model_json, product_config, targets) + end + + describe "#convert" do + let(:model_json) { {} } + + let(:targets) { [] } + + it "returns a volume group config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Configs::VolumeGroup) + end + + context "if 'vgName' is not specified" do + let(:model_json) { {} } + + it "does not set #name" do + config = subject.convert + expect(config.name).to be_nil + end + end + + context "if 'extentSize' is not specified" do + let(:model_json) { {} } + + it "does not set #extent_size" do + config = subject.convert + expect(config.extent_size).to be_nil + end + end + + context "if 'targetDevices' is not specified" do + let(:model_json) { {} } + + it "sets #physical_volumes_devices to the expected value" do + config = subject.convert + expect(config.physical_volumes_devices).to eq([]) + end + end + + context "if 'logicalVolumes' is not specified" do + let(:model_json) { {} } + + it "sets #logical_volumes to the expected value" do + config = subject.convert + expect(config.logical_volumes).to eq([]) + end + end + + context "if 'spacePolicy' is not specified" do + let(:model_json) { {} } + include_examples "without spacePolicy", :logical_volumes + end + + context "if 'vgName' is specified" do + let(:model_json) { { vgName: "vg1" } } + + it "sets #name to the expected value" do + config = subject.convert + expect(config.name).to eq("vg1") + end + end + + context "if 'extentSize' is specified" do + let(:model_json) { { extentSize: 1.KiB.to_i } } + + it "sets #extent_size to the expected value" do + config = subject.convert + expect(config.extent_size).to eq(1.KiB) + end + end + + context "if 'targetDevices' is specified" do + let(:model_json) { { targetDevices: ["/dev/vda", "/dev/md0"] } } + + let(:drive) do + Agama::Storage::Configs::Drive.new.tap do |drive| + drive.search = Agama::Storage::Configs::Search.new.tap { |s| s.name = "/dev/vda" } + end + end + + let(:md_raid) do + Agama::Storage::Configs::MdRaid.new.tap do |md_raid| + md_raid.search = Agama::Storage::Configs::Search.new.tap { |s| s.name = "/dev/md0" } + end + end + + let(:targets) { [drive, md_raid] } + + it "sets an alias to the target devices" do + subject.convert + expect(drive.alias).to_not be_nil + expect(md_raid.alias).to_not be_nil + end + + it "sets #physical_volumes_devices to the expected value" do + config = subject.convert + expect(config.physical_volumes_devices).to eq([drive.alias, md_raid.alias]) + end + end + + context "if 'logicalVolumes' is specified" do + let(:model_json) { { logicalVolumes: logical_volumes } } + + context "with an empty list" do + let(:logical_volumes) { [] } + + it "sets #logical_volumes to the expected value" do + config = subject.convert + expect(config.logical_volumes).to eq([]) + end + end + + context "with a list of logical volumes" do + let(:logical_volumes) do + [ + { lvName: "lv1" }, + { lvName: "lv2" } + ] + end + it "sets #logical_volumes to the expected value" do + config = subject.convert + expect(config.logical_volumes) + .to all(be_a(Agama::Storage::Configs::LogicalVolume)) + expect(config.logical_volumes.size).to eq(2) + + lv1, lv2 = config.logical_volumes + expect(lv1.name).to eq("lv1") + expect(lv2.name).to eq("lv2") + end + end + end + + context "if 'spacePolicy' is specified" do + let(:model_json) { { spacePolicy: spacePolicy } } + include_examples "with spacePolicy" + end + + context "if 'spacePolicy' and 'logicalVolumes' are specified" do + let(:model_json) { { spacePolicy: spacePolicy, logicalVolumes: logical_volumes } } + include_examples "with spacePolicy and volumes" + end + end +end diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index 239a51d331..d1e03baa2e 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -19,1731 +19,63 @@ # 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_relative "./from_model_conversions/context" require "agama/storage/config" require "agama/storage/config_conversions/from_model" -require "agama/storage/configs" -require "y2storage/encryption_method" -require "y2storage/filesystems/mount_by_type" -require "y2storage/filesystems/type" -require "y2storage/pbkd_function" -require "y2storage/refinements" - -# TODO: this test suite requires a better organization, similar to ToJSON tests. - -using Y2Storage::Refinements::SizeCasts - -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 spacePolicy" do |config_proc| - context "if the default space policy is 'keep'" do - let(:product_space_policy) { "keep" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions).to be_empty - end - end - - context "if the default space policy is 'delete'" do - let(:product_space_policy) { "delete" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(1) - - partition = partitions.first - expect(partition.search.name).to be_nil - expect(partition.search.if_not_found).to eq(:skip) - expect(partition.search.max).to be_nil - expect(partition.delete?).to eq(true) - end - end - - context "if the default space policy is 'resize'" do - let(:product_space_policy) { "resize" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(1) - - partition = partitions.first - expect(partition.search.name).to be_nil - expect(partition.search.if_not_found).to eq(:skip) - expect(partition.search.max).to be_nil - expect(partition.delete?).to eq(false) - expect(partition.size.default?).to eq(false) - expect(partition.size.min).to eq(Y2Storage::DiskSize.zero) - expect(partition.size.max).to be_nil - end - end - - context "if the default space policy is 'custom'" do - let(:product_space_policy) { "custom" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions).to be_empty - end - end -end - -shared_examples "without size" do |config_proc| - it "sets #size to default size" do - config = config_proc.call(subject.convert) - expect(config.size.default?).to eq(true) - expect(config.size.min).to be_nil - expect(config.size.max).to be_nil - end -end - -shared_examples "without delete" do |config_proc| - it "sets #delete to false" do - config = config_proc.call(subject.convert) - expect(config.delete?).to eq(false) - end -end - -shared_examples "without deleteIfNeeded" do |config_proc| - it "sets #delete_if_needed to false" do - config = config_proc.call(subject.convert) - expect(config.delete_if_needed?).to eq(false) - end -end - -shared_examples "with name" do |config_proc| - let(:name) { "/dev/vda" } - - it "sets #search to the expected value" do - config = config_proc.call(subject.convert) - expect(config.search).to be_a(Agama::Storage::Configs::Search) - expect(config.search.name).to eq("/dev/vda") - expect(config.search.max).to be_nil - expect(config.search.if_not_found).to eq(:error) - end -end - -shared_examples "with mountPath" do |config_proc| - let(:mountPath) { "/test" } - - it "sets #filesystem to the expected value" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type).to be_nil - expect(filesystem.label).to be_nil - expect(filesystem.path).to eq("/test") - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty - end -end - -shared_examples "with filesystem" do |config_proc| - let(:filesystem) do - { - reuse: reuse, - default: default, - type: type, - label: label - } - end - - let(:reuse) { false } - let(:default) { false } - let(:type) { nil } - let(:label) { "test" } - - context "if the filesystem is default" do - let(:default) { true } - - RSpec.shared_examples "#filesystem set to default btrfs" do - it "sets #filesystem to the expected btrfs-related values" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type.default?).to eq(true) - expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) - expect(filesystem.label).to eq("test") - expect(filesystem.path).to be_nil - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty - end - end - - context "and the type is 'btrfs'" do - let(:type) { "btrfs" } - - include_examples "#filesystem set to default btrfs" - - it "sets Btrfs snapshots to false" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem.type.btrfs.snapshots?).to eq(false) - end - end - - context "and the type is 'btrfsSnapshots'" do - let(:type) { "btrfsSnapshots" } - - include_examples "#filesystem set to default btrfs" - - it "sets Btrfs snapshots to true" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem.type.btrfs.snapshots?).to eq(true) - end - end - - context "and the type is 'btrfsImmutable'" do - let(:type) { "btrfsSnapshots" } - - include_examples "#filesystem set to default btrfs" - - it "sets Btrfs snapshots to true" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem.type.btrfs.snapshots?).to eq(true) - end - end - - context "and the type is not 'btrfs'" do - let(:type) { "xfs" } - - it "sets #filesystem to the expected value" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type.default?).to eq(true) - expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) - expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to eq("test") - expect(filesystem.path).to be_nil - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty - end - end - end - - context "if the filesystem is not default" do - let(:default) { false } - - RSpec.shared_examples "#filesystem set to non-default btrfs" do - it "sets #filesystem to the expected btrfs-related values" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type.default?).to eq(false) - expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) - expect(filesystem.label).to eq("test") - expect(filesystem.path).to be_nil - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty - end - end - - context "and the type is 'btrfs'" do - let(:type) { "btrfs" } - - include_examples "#filesystem set to non-default btrfs" - - it "sets Btrfs snapshots to false" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem.type.btrfs.snapshots?).to eq(false) - end - end - - context "and the type is 'btrfsSnapshots'" do - let(:type) { "btrfsSnapshots" } - - include_examples "#filesystem set to non-default btrfs" - - it "sets Btrfs snapshots to true" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem.type.btrfs.snapshots?).to eq(true) - end - end - - context "and the type is 'btrfsImmutable'" do - let(:type) { "btrfsImmutable" } - - include_examples "#filesystem set to non-default btrfs" - - it "sets Btrfs snapshots to true" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem.type.btrfs.snapshots?).to eq(true) - end - end - - context "and the type is not 'btrfs'" do - let(:type) { "xfs" } - - it "sets #filesystem to the expected value" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type.default?).to eq(false) - expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) - expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to eq("test") - expect(filesystem.path).to be_nil - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty - end - end - end - - context "if the filesystem specifies 'reuse'" do - let(:reuse) { true } - - 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 be_nil - expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to eq("test") - expect(filesystem.path).to be_nil - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty - end - end - - context "if the filesystem does not specify 'type'" do - let(:type) { nil } - - it "sets #filesystem to the expected value" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type.default?).to eq(false) - expect(filesystem.type.fs_type).to be_nil - expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to eq("test") - 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 the filesystem does not specify 'label'" do - let(:label) { nil } - - it "sets #filesystem to the expected value" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type.default?).to eq(false) - expect(filesystem.type.fs_type).to be_nil - expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to be_nil - expect(filesystem.path).to be_nil - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to eq([]) - expect(filesystem.mount_options).to eq([]) - end - end -end - -shared_examples "with mountPath and filesystem" do |config_proc| - let(:mountPath) { "/test" } - - let(:filesystem) do - { - default: false, - type: "btrfs", - label: "test" - } - end - - it "sets #filesystem to the expected value" do - config = config_proc.call(subject.convert) - filesystem = config.filesystem - expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(false) - expect(filesystem.type.default?).to eq(false) - expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) - expect(filesystem.label).to eq("test") - expect(filesystem.path).to eq("/test") - expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty - end -end - -shared_examples "with ptableType" do |config_proc| - let(:ptableType) { "gpt" } - - it "sets #ptable_type to the expected value" do - config = config_proc.call(subject.convert) - expect(config.ptable_type).to eq(Y2Storage::PartitionTables::Type::GPT) - end -end - -shared_examples "with size" do |config_proc| - context "if the size is default" do - let(:size) do - { - default: true, - min: 1.GiB.to_i, - max: 10.GiB.to_i - } - end - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end - - context "if the size is not default" do - let(:size) do - { - default: false, - min: 1.GiB.to_i, - max: 10.GiB.to_i - } - end - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(false) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end - - context "if the size does not spicify 'max'" do - let(:size) do - { - default: false, - min: 1.GiB.to_i - } - end - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(false) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(Y2Storage::DiskSize.unlimited) - end - end -end - -shared_examples "with partitions" do |config_proc| - let(:partitions) do - [ - partition, - { mountPath: "/test" } - ] - end - - let(:partition) { { mountPath: "/" } } - - context "with an empty list" do - let(:partitions) { [] } - - it "sets #partitions to empty" do - config = config_proc.call(subject.convert) - expect(config.partitions).to eq([]) - end - end - - context "with a list of partitions" do - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(2) - - partition1, partition2 = partitions - expect(partition1).to be_a(Agama::Storage::Configs::Partition) - expect(partition1.filesystem.path).to eq("/") - expect(partition2).to be_a(Agama::Storage::Configs::Partition) - expect(partition2.filesystem.path).to eq("/test") - end - end - - partition_proc = proc { |c| config_proc.call(c).partitions.first } - - context "if a partition does not specify 'name'" do - let(:partition) { {} } - - it "does not set #search" do - partition = partition_proc.call(subject.convert) - expect(partition.search).to be_nil - end - end - - context "if a partition does not spicify 'id'" do - let(:partition) { {} } - - it "does not set #id" do - partition = partition_proc.call(subject.convert) - expect(partition.id).to be_nil - end - end - - context "if a partition does not spicify 'size'" do - let(:partition) { {} } - include_examples "without size", partition_proc - end - - context "if a partition does not spicify neither 'mountPath' nor 'filesystem'" do - let(:partition) { {} } - include_examples "without filesystem", partition_proc - end - - context "if a partition does not spicify 'delete'" do - let(:partition) { {} } - include_examples "without delete", partition_proc - end - - context "if a partition does not spicify 'deleteIfNeeded'" do - let(:partition) { {} } - include_examples "without deleteIfNeeded", partition_proc - end - - context "if a partition specifies 'name'" do - # Add mount path in order to use the partition. Otherwise the partition is omitted because it - # is considered a keep action. - let(:partition) { { name: name, mountPath: "/test2" } } - include_examples "with name", partition_proc - end - - context "if a partition spicifies 'id'" do - let(:partition) { { id: "esp" } } - - it "sets #id to the expected value" do - partition = partition_proc.call(subject.convert) - expect(partition.id).to eq(Y2Storage::PartitionId::ESP) - end - end - - context "if a partition specifies 'mountPath'" do - let(:partition) { { mountPath: mountPath } } - include_examples "with mountPath", partition_proc - end - - context "if a partition specifies 'filesystem'" do - let(:partition) { { filesystem: filesystem } } - include_examples "with filesystem", partition_proc - end - - context "if a partition specifies both 'mountPath' and 'filesystem'" do - let(:partition) { { mountPath: mountPath, filesystem: filesystem } } - include_examples "with mountPath and filesystem", partition_proc - end - - context "if a partition spicifies 'size'" do - let(:partition) { { size: size } } - include_examples "with size", partition_proc - end -end - -shared_examples "with spacePolicy" do |config_proc| - context "if space policy is 'keep'" do - let(:spacePolicy) { "keep" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions).to be_empty - end - end - - context "if space policy is 'delete'" do - let(:spacePolicy) { "delete" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(1) - - partition = partitions.first - expect(partition.search.name).to be_nil - expect(partition.search.if_not_found).to eq(:skip) - expect(partition.search.max).to be_nil - expect(partition.delete?).to eq(true) - end - end - - context "if space policy is 'resize'" do - let(:spacePolicy) { "resize" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(1) - - partition = partitions.first - expect(partition.search.name).to be_nil - expect(partition.search.if_not_found).to eq(:skip) - expect(partition.search.max).to be_nil - expect(partition.delete?).to eq(false) - expect(partition.size.default?).to eq(false) - expect(partition.size.min).to eq(Y2Storage::DiskSize.zero) - expect(partition.size.max).to be_nil - end - end - - context "if space policy is 'custom'" do - let(:spacePolicy) { "custom" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions).to be_empty - end - end -end - -shared_examples "with spacePolicy and partitions" do |config_proc| - let(:partitions) do - [ - # Reused partition with some usage. - { - name: "/dev/vda1", - mountPath: "/test1", - size: { default: true, min: 10.GiB.to_i } - }, - # Reused partition with some usage. - { - name: "/dev/vda2", - mountPath: "/test2", - resizeIfNeeded: true, - size: { default: false, min: 10.GiB.to_i } - }, - # Reused partition with some usage. - { - name: "/dev/vda3", - mountPath: "/test3", - resize: true, - size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } - }, - # Reused partition representing a space action (resize). - { - name: "/dev/vda4", - resizeIfNeeded: true, - size: { default: false, min: 10.GiB.to_i } - }, - # Reused partition representing a space action (resize). - { - name: "/dev/vda5", - resize: true, - size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } - }, - # Reused partition representing a space action (delete). - { - name: "/dev/vda6", - delete: true - }, - # Reused partition representing a space action (delete). - { - name: "/dev/vda7", - deleteIfNeeded: true - }, - # Reused partition representing a space action (keep). - { - name: "/dev/vda8" - }, - # New partition. - {}, - # New partition. - { - mountPath: "/", - resizeIfNeeded: true, - size: { default: false, min: 10.GiB.to_i }, - filesystem: { type: "btrfs" } - } - ] - end - - context "if space policy is 'keep'" do - let(:spacePolicy) { "keep" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(5) - expect(partitions[0].search.name).to eq("/dev/vda1") - expect(partitions[1].search.name).to eq("/dev/vda2") - expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].filesystem).to be_nil - expect(partitions[4].filesystem.path).to eq("/") - end - end - - context "if space policy is 'delete'" do - let(:spacePolicy) { "delete" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(6) - expect(partitions[0].search.name).to eq("/dev/vda1") - expect(partitions[1].search.name).to eq("/dev/vda2") - expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].filesystem).to be_nil - expect(partitions[4].filesystem.path).to eq("/") - expect(partitions[5].search.name).to be_nil - expect(partitions[5].search.max).to be_nil - expect(partitions[5].delete).to eq(true) - end - end - - context "if space policy is 'resize'" do - let(:spacePolicy) { "resize" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(6) - expect(partitions[0].search.name).to eq("/dev/vda1") - expect(partitions[1].search.name).to eq("/dev/vda2") - expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].filesystem).to be_nil - expect(partitions[4].filesystem.path).to eq("/") - expect(partitions[5].search.name).to be_nil - expect(partitions[5].search.max).to be_nil - expect(partitions[5].size.default?).to eq(false) - expect(partitions[5].size.min).to eq(Y2Storage::DiskSize.zero) - expect(partitions[5].size.max).to be_nil - end - end - - context "if space policy is 'custom'" do - let(:spacePolicy) { "custom" } - - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - partitions = config.partitions - expect(partitions.size).to eq(9) - expect(partitions[0].search.name).to eq("/dev/vda1") - expect(partitions[1].search.name).to eq("/dev/vda2") - expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].filesystem).to be_nil - expect(partitions[4].filesystem.path).to eq("/") - expect(partitions[5].search.name).to eq("/dev/vda4") - expect(partitions[6].search.name).to eq("/dev/vda5") - expect(partitions[7].search.name).to eq("/dev/vda6") - expect(partitions[8].search.name).to eq("/dev/vda7") - end - - context "if a partition spicifies 'resizeIfNeeded'" do - let(:partitions) { [{ resizeIfNeeded: resizeIfNeeded }] } - - context "if 'resizeIfNeeded' is true" do - let(:resizeIfNeeded) { true } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.partitions.first.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(false) - expect(size.min).to eq(Y2Storage::DiskSize.zero) - expect(size.max).to be_nil - end - end - - context "if 'resizeIfNeeded' is false" do - let(:resizeIfNeeded) { false } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.partitions.first.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to be_nil - expect(size.max).to be_nil - end - end - end - - context "if a partition spicifies both 'size' and 'resizeIfNeeded'" do - let(:partitions) { [{ size: size, resizeIfNeeded: resizeIfNeeded }] } - - let(:size) do - { - default: true, - min: 1.GiB.to_i, - max: 10.GiB.to_i - } - end - - context "if 'resizeIfNeeded' is true" do - let(:resizeIfNeeded) { true } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.partitions.first.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(false) - expect(size.min).to eq(Y2Storage::DiskSize.zero) - expect(size.max).to be_nil - end - end - - context "if 'resizeIfNeeded' is false" do - let(:resizeIfNeeded) { false } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.partitions.first.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end - end - - context "if a partition spicifies both 'size' and 'resize'" do - let(:partitions) { [{ size: size, resize: resize }] } - - let(:size) do - { - default: true, - min: 1.GiB.to_i, - max: 10.GiB.to_i - } - end - - context "if 'resize' is true" do - let(:resize) { true } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.partitions.first.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end - - context "if 'size' is false" do - let(:resize) { false } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.partitions.first.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end - end - - context "if a partition specifies 'delete'" do - let(:partitions) { [{ delete: true, mountPath: mount_path }] } - - let(:mount_path) { nil } - - it "sets #delete to true" do - config = config_proc.call(subject.convert) - partition = config.partitions.first - expect(partition.delete?).to eq(true) - end - - context "and the partition has a mount path" do - let(:mount_path) { "/test" } - - it "sets #delete to false" do - config = config_proc.call(subject.convert) - partition = config.partitions.first - expect(partition.delete?).to eq(false) - end - end - end - - context "if a partition specifies 'deleteIfNeeded'" do - let(:partitions) { [{ deleteIfNeeded: true, mountPath: mount_path }] } - - let(:mount_path) { nil } - - it "sets #delete_if_needed to true" do - config = config_proc.call(subject.convert) - partition = config.partitions.first - expect(partition.delete_if_needed?).to eq(true) - end - - context "and the partition has a mount path" do - let(:mount_path) { "/test" } - - it "sets #delete_if_needed to false" do - config = config_proc.call(subject.convert) - partition = config.partitions.first - expect(partition.delete_if_needed?).to eq(false) - end - end - end - end -end describe Agama::Storage::ConfigConversions::FromModel do - include Agama::RSpec::StorageHelpers + include_context "from model conversions" subject do described_class.new(model_json, product_config: product_config) end - let(:product_config) do - Agama::Config.new({ "storage" => { "space_policy" => product_space_policy } }) - end - - let(:product_space_policy) { nil } - - before do - mock_storage(devicegraph: scenario) - - # Speed up tests by avoding real check of TPM presence. - allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) - end - - let(:scenario) { "disks.yaml" } - describe "#convert" do - let(:model_json) { {} } + let(:model_json) do + { + boot: { + configure: true + }, + drives: [ + { name: "/dev/vda" } + ], + mdRaids: [ + { name: "/dev/md0" } + ], + volumeGroups: [ + { name: "/dev/vg0" } + ] + } + end it "returns a storage config" do config = subject.convert expect(config).to be_a(Agama::Storage::Config) - end - - context "with an empty JSON" do - let(:model_json) { {} } - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - expect(boot).to be_a(Agama::Storage::Configs::Boot) - expect(boot.configure?).to eq(true) - expect(boot.device).to be_a(Agama::Storage::Configs::BootDevice) - expect(boot.device.default?).to eq(true) - expect(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.volume_groups).to be_empty - end - end - - context "with a JSON specifying 'boot'" do - let(:model_json) do - { - boot: { - configure: configure, - device: { - default: default, - name: name - } - }, - drives: drives, - mdRaids: md_raids - } - end - - let(:default) { false } - let(:name) { nil } - let(:drives) { [] } - let(:md_raids) { [] } - - context "if boot is set to be configured" do - let(:configure) { true } - - context "and the boot device is set to default" do - let(:default) { true } - let(:name) { "/dev/vda" } - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(true) - expect(boot.device.device_alias).to be_nil - end - end - - context "and the boot device is not set to default" do - let(:default) { false } - - context "and the boot device does not specify 'name'" do - let(:name) { nil } - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(false) - expect(boot.device.device_alias).to be_nil - end - end - - context "and the boot device specifies a 'name'" do - context "and there is a drive config for the given boot device name" do - let(:name) { "/dev/vda" } - - let(:drives) do - [ - { name: "/dev/vda" } - ] - end - - it "does not add more drives" do - config = subject.convert - expect(config.drives.size).to eq(1) - expect(config.drives.first.search.name).to eq("/dev/vda") - end - - it "sets an alias to the drive config" do - config = subject.convert - drive = config.drives.first - expect(drive.alias).to_not be_nil - end - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - drive = config.drives.first - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(false) - expect(boot.device.device_alias).to eq(drive.alias) - end - end - - context "and there is not a drive config for the given boot device name" do - let(:name) { "/dev/vda" } - - let(:drives) do - [ - { name: "/dev/vdb" } - ] - end - - it "adds a drive for the boot device" do - config = subject.convert - expect(config.drives.size).to eq(2) - - drive = config.drives.find { |d| d.search.name == name } - expect(drive.alias).to_not be_nil - expect(drive.partitions).to be_empty - end - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - drive = config.drives.find { |d| d.search.name == name } - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(false) - expect(boot.device.device_alias).to eq(drive.alias) - end - end - - context "and there is a MD RAID config for the given boot device name" do - let(:name) { "/dev/md0" } - - let(:md_raids) do - [ - { name: "/dev/md0" } - ] - end - - it "does not add more MD RAIDs" do - config = subject.convert - expect(config.md_raids.size).to eq(1) - expect(config.md_raids.first.search.name).to eq("/dev/md0") - end - - it "sets an alias to the MD RAID config" do - config = subject.convert - md = config.md_raids.first - expect(md.alias).to_not be_nil - end - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - md = config.md_raids.first - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(false) - expect(boot.device.device_alias).to eq(md.alias) - end - end - - context "and there is not a MD RAID config for the given boot device name" do - let(:scenario) { "md_raids.yaml" } - - let(:name) { "/dev/md0" } - - let(:md_raids) do - [ - { name: "/dev/md1" } - ] - end - - it "adds a MD RAID for the boot device" do - config = subject.convert - expect(config.md_raids.size).to eq(2) - - md = config.md_raids.find { |d| d.search.name == name } - expect(md.alias).to_not be_nil - expect(md.partitions).to be_empty - end - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - md = config.md_raids.find { |d| d.search.name == name } - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(false) - expect(boot.device.device_alias).to eq(md.alias) - end - end - end - end - end - - context "if boot is not set to be configured" do - let(:configure) { false } - let(:default) { true } - let(:name) { "/dev/vda" } - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - expect(boot.configure?).to eq(false) - expect(boot.device.default?).to eq(true) - expect(boot.device.device_alias).to be_nil - end - end - end - - context "with a JSON specifying 'encryption'" do - let(:model_json) do - { - encryption: { - method: "luks1", - password: "12345" - }, - drives: [ - { - name: "/dev/vda", - partitions: [ - { - name: "/dev/vda1", - mountPath: "/test" - }, - { - name: "/dev/vda2", - mountPath: "/test2", - filesystem: { reuse: true } - }, - { - size: { default: false, min: 256.MiB.to_i }, - mountPath: "/boot/efi" - }, - { - size: { default: false, min: 1.GiB.to_i }, - mountPath: "/test3" - }, - {} - ] - } - ], - mdRaids: [ - { - name: "/dev/md0", - partitions: [ - { name: "/dev/md0-p1" }, - {} - ] - } - ], - volumeGroups: [ - { - vgName: "system", - targetDevices: ["/dev/vda"] - } - ] - } - end - - it "sets #encryption to the newly formatted partitions, except the boot-related ones" do - config = subject.convert - partitions = config.partitions - new_partitions = partitions.reject(&:search) - reused_partitions = partitions.select(&:search) - mounted_partitions, reformatted_partitions = reused_partitions.partition do |part| - part.filesystem.reuse? - end - new_non_boot_partitions, new_boot_partitions = new_partitions.partition do |part| - part.filesystem&.path != "/boot/efi" - end - - expect(new_non_boot_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1)) - expect(new_non_boot_partitions.map { |p| p.encryption.password }).to all(eq("12345")) - expect(reformatted_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1)) - expect(reformatted_partitions.map { |p| p.encryption.password }).to all(eq("12345")) - expect(mounted_partitions.map(&:encryption)).to all(be_nil) - expect(new_boot_partitions.map(&:encryption)).to all(be_nil) - end - - it "sets #encryption for the automatically created physical volumes" do - config = subject.convert - volume_group = config.volume_groups.first - target_encryption = volume_group.physical_volumes_encryption - - expect(target_encryption.method.id).to eq(:luks1) - expect(target_encryption.password).to eq("12345") - end - end - - context "with a JSON specifying 'drives'" do - let(:model_json) do - { drives: drives } - end - - let(:drives) do - [ - drive, - { name: "/dev/vdb" } - ] - end - - let(:drive) do - { name: "/dev/vda" } - end - - context "with an empty list" do - let(:drives) { [] } - - it "sets #drives to the expected value" do - config = subject.convert - expect(config.drives).to eq([]) - end - end - - context "with a list of drives" do - it "sets #drives to the expected value" do - config = subject.convert - expect(config.drives.size).to eq(2) - expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive)) - - drive1, drive2 = config.drives - expect(drive1.search.name).to eq("/dev/vda") - expect(drive1.partitions).to eq([]) - expect(drive2.search.name).to eq("/dev/vdb") - expect(drive2.partitions).to eq([]) - end - end - - drive_proc = proc { |c| c.drives.first } - - context "if a drive does not specify 'name'" do - let(:drive) { {} } - - it "sets #search to the expected value" do - drive = drive_proc.call(subject.convert) - expect(drive.search).to be_a(Agama::Storage::Configs::Search) - expect(drive.search.name).to be_nil - expect(drive.search.if_not_found).to eq(:error) - end - end - - context "if a drive does not spicify neither 'mountPath' nor 'filesystem'" do - let(:drive) { {} } - include_examples "without filesystem", drive_proc - end - - context "if a drive does not spicify 'ptableType'" do - let(:drive) { {} } - include_examples "without ptableType", drive_proc - end - - context "if a drive does not specifies 'spacePolicy'" do - let(:drive) { {} } - include_examples "without spacePolicy", drive_proc - end - - context "if a drive specifies 'name'" do - let(:drive) { { name: name } } - include_examples "with name", drive_proc - end - - context "if a drive specifies 'mountPath'" do - let(:drive) { { mountPath: mountPath } } - include_examples "with mountPath", drive_proc - end - - context "if a drive specifies 'filesystem'" do - let(:drive) { { filesystem: filesystem } } - include_examples "with filesystem", drive_proc - end - - context "if a drive specifies both 'mountPath' and 'filesystem'" do - let(:drive) { { mountPath: mountPath, filesystem: filesystem } } - include_examples "with mountPath and filesystem", drive_proc - end - - context "if a drive specifies 'ptableType'" do - let(:drive) { { ptableType: ptableType } } - include_examples "with ptableType", drive_proc - end - - context "if a drive specifies 'partitions'" do - let(:drive) { { partitions: partitions } } - include_examples "with partitions", drive_proc - end - - context "if a drive specifies 'spacePolicy'" do - let(:drive) { { spacePolicy: spacePolicy } } - include_examples "with spacePolicy", drive_proc - end - - context "if a drive specifies both 'spacePolicy' and 'partitions'" do - let(:drive) { { spacePolicy: spacePolicy, partitions: partitions } } - include_examples "with spacePolicy and partitions", drive_proc - end - end - - context "with a JSON specifying 'mdRaids'" do - let(:model_json) do - { mdRaids: md_raids } - end - - let(:md_raids) do - [ - md_raid, - { name: "/dev/md1" } - ] - end - - let(:md_raid) do - { name: "/dev/md0" } - end - - 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 - 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.search.name).to eq("/dev/md0") - expect(md1.partitions).to eq([]) - expect(md2.search.name).to eq("/dev/md1") - expect(md2.partitions).to eq([]) - end - end - - md_raid_proc = proc { |c| c.md_raids.first } - - context "if a MD RAID does not specify 'name'" do - let(:md_raid) { {} } - - it "sets #search to the expected value" do - md = md_raid_proc.call(subject.convert) - expect(md.search).to be_nil - end - end - - context "if a MD RAID does not spicify neither 'mountPath' nor 'filesystem'" do - let(:md_raid) { {} } - include_examples "without filesystem", md_raid_proc - end - - context "if a MD RAID does not spicify 'ptableType'" do - let(:md_raid) { {} } - include_examples "without ptableType", md_raid_proc - end - - context "if a MD RAID does not specifies 'spacePolicy'" do - let(:md_raid) { {} } - include_examples "without spacePolicy", md_raid_proc - end - - context "if a MD RAID specifies 'name'" do - let(:md_raid) { { name: name } } - include_examples "with name", md_raid_proc - end - - context "if a MD RAID specifies 'mountPath'" do - let(:md_raid) { { mountPath: mountPath } } - include_examples "with mountPath", md_raid_proc - end - - context "if a MD RAID specifies 'filesystem'" do - let(:md_raid) { { filesystem: filesystem } } - include_examples "with filesystem", md_raid_proc - end - - context "if a MD RAID specifies both 'mountPath' and 'filesystem'" do - let(:md_raid) { { mountPath: mountPath, filesystem: filesystem } } - include_examples "with mountPath and filesystem", md_raid_proc - end - - context "if a MD RAID specifies 'ptableType'" do - let(:md_raid) { { ptableType: ptableType } } - include_examples "with ptableType", md_raid_proc - end - - context "if a MD RAID specifies 'partitions'" do - let(:md_raid) { { partitions: partitions } } - include_examples "with partitions", md_raid_proc - end - - context "if a MD RAID specifies 'spacePolicy'" do - let(:md_raid) { { spacePolicy: spacePolicy } } - include_examples "with spacePolicy", md_raid_proc - end - - context "if a MD RAID specifies both 'spacePolicy' and 'partitions'" do - let(:md_raid) { { spacePolicy: spacePolicy, partitions: partitions } } - include_examples "with spacePolicy and partitions", md_raid_proc - end - end - - context "with a JSON specifying 'volumeGroups'" do - let(:model_json) do - { - drives: drives, - mdRaids: md_raids, - volumeGroups: volume_groups - } - end - - let(:drives) { [] } - let(:md_raids) { [] } - - let(:volume_groups) do - [ - volume_group, - { vgName: "vg2" } - ] - end - - let(:volume_group) do - { vgName: "vg1" } - end - - 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)) - - vg1, vg2 = config.volume_groups - expect(vg1.name).to eq("vg1") - expect(vg1.logical_volumes).to eq([]) - expect(vg2.name).to eq("vg2") - expect(vg2.logical_volumes).to eq([]) - end - end - - volume_group_proc = proc { |c| c.volume_groups.first } - - context "if a volume group does not specify 'vgName'" do - let(:volume_group) { {} } - - it "does not set #name" do - volume_group = volume_group_proc.call(subject.convert) - expect(volume_group.name).to be_nil - end - end - - context "if a volume group does not specify 'extentSize'" do - let(:volume_group) { {} } - - it "does not set #extent_size" do - volume_group = volume_group_proc.call(subject.convert) - expect(volume_group.extent_size).to be_nil - end - end - - context "if a volume group does not specify 'targetDevices'" do - let(:volume_group) { {} } - - it "sets #physical_volumes_devices to the expected value" do - volume_group = volume_group_proc.call(subject.convert) - expect(volume_group.physical_volumes_devices).to eq([]) - end - end - - context "if a volume group does not specify 'logicalVolumes'" do - let(:volume_group) { {} } - - it "sets #logical_volumes to the expected value" do - volume_group = volume_group_proc.call(subject.convert) - expect(volume_group.logical_volumes).to eq([]) - end - end - - context "if a volume group specifies 'vgName'" do - let(:volume_group) { { vgName: "vg1" } } - - it "sets #name to the expected value" do - volume_group = volume_group_proc.call(subject.convert) - expect(volume_group.name).to eq("vg1") - end - end - - context "if a volume group specifies 'extentSize'" do - let(:volume_group) { { extentSize: 1.KiB.to_i } } - - it "sets #extent_size to the expected value" do - volume_group = volume_group_proc.call(subject.convert) - expect(volume_group.extent_size).to eq(1.KiB) - end - end - - context "if a volume group specifies 'targetDevices'" do - let(:scenario) { "md_raids.yaml" } - - let(:volume_group) { { targetDevices: ["/dev/vda", "/dev/vdb", "/dev/md0"] } } - - let(:drives) do - [ - { name: "/dev/vda" }, - { name: "/dev/vdc" } - ] - end - - let(:md_raids) do - [ - { name: "/dev/md1" } - ] - end - - it "adds the missing drives" do - config = subject.convert - expect(config.drives.size).to eq(3) - expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive)) - expect(config.drives).to include(an_object_having_attributes({ device_name: "/dev/vdb" })) - end - - it "adds the missing MD RAIDs" do - config = subject.convert - expect(config.md_raids.size).to eq(2) - expect(config.md_raids).to all(be_a(Agama::Storage::Configs::MdRaid)) - expect(config.md_raids) - .to include(an_object_having_attributes({ device_name: "/dev/md0" })) - end - - it "sets an alias to the target devices" do - config = subject.convert - vda = config.drives.find { |d| d.device_name == "/dev/vda" } - vdb = config.drives.find { |d| d.device_name == "/dev/vdb" } - vdc = config.drives.find { |d| d.device_name == "/dev/vdc" } - md0 = config.md_raids.find { |d| d.device_name == "/dev/md0" } - md1 = config.md_raids.find { |d| d.device_name == "/dev/md1" } - expect(vda.alias).to_not be_nil - expect(vdb.alias).to_not be_nil - expect(vdc.alias).to be_nil - expect(md0.alias).to_not be_nil - expect(md1.alias).to be_nil - end - - it "sets #physical_volumes_devices to the expected value" do - config = subject.convert - volume_group = volume_group_proc.call(config) - vda = config.drives.find { |d| d.device_name == "/dev/vda" } - vdb = config.drives.find { |d| d.device_name == "/dev/vdb" } - md0 = config.md_raids.find { |d| d.device_name == "/dev/md0" } - expect(volume_group.physical_volumes_devices).to eq([vda.alias, vdb.alias, md0.alias]) - end - end - - context "if a volume group specifies 'logicalVolumes'" do - let(:volume_group) { { logicalVolumes: logical_volumes } } - - let(:logical_volumes) do - [ - logical_volume, - { lvName: "lv2" } - ] - end - - let(:logical_volume) { { lvName: "lv1" } } - - context "with an empty list" do - let(:logical_volumes) { [] } - - it "sets #logical_volumes to empty" do - config = subject.convert - expect(config.logical_volumes).to eq([]) - end - end - - context "with a list of logical volumes" do - it "sets #logical_volumes to the expected value" do - volume_group = volume_group_proc.call(subject.convert) - expect(volume_group.logical_volumes) - .to all(be_a(Agama::Storage::Configs::LogicalVolume)) - expect(volume_group.logical_volumes.size).to eq(2) - - lv1, lv2 = volume_group.logical_volumes - expect(lv1.name).to eq("lv1") - expect(lv2.name).to eq("lv2") - end - end - - logical_volume_proc = proc { |c| volume_group_proc.call(c).logical_volumes.first } - - context "if a logical volume does not specify 'lvName'" do - let(:logical_volume) { {} } - - it "does not set #name" do - logical_volume = logical_volume_proc.call(subject.convert) - expect(logical_volume.name).to be_nil - end - end - - context "if a logical volume does not spicify neither 'mountPath' nor 'filesystem'" do - let(:logical_volume) { {} } - include_examples "without filesystem", logical_volume_proc - end - - context "if a logical volume does not spicify 'size'" do - let(:logical_volume) { {} } - include_examples "without size", logical_volume_proc - end - - context "if a logical volume does not spicify 'stripes'" do - let(:logical_volume) { {} } - - it "does not set #stripes" do - logical_volume = logical_volume_proc.call(subject.convert) - expect(logical_volume.stripes).to be_nil - end - end - - context "if a logical volume does not spicify 'stripeSize'" do - let(:logical_volume) { {} } - - it "does not set #stripe_size" do - logical_volume = logical_volume_proc.call(subject.convert) - expect(logical_volume.stripe_size).to be_nil - end - end - - context "if a logical volume specifies 'lvName'" do - let(:logical_volume) { { lvName: "lv1" } } - - it "sets #name to the expected value" do - logical_volume = logical_volume_proc.call(subject.convert) - expect(logical_volume.name).to eq("lv1") - end - end - - context "if a logical volume specifies 'mountPath'" do - let(:logical_volume) { { mountPath: mountPath } } - include_examples "with mountPath", logical_volume_proc - end - context "if a logical volume specifies 'filesystem'" do - let(:logical_volume) { { filesystem: filesystem } } - include_examples "with filesystem", logical_volume_proc - end + boot = config.boot + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(true) - context "if a logical volume specifies both 'mountPath' and 'filesystem'" do - let(:logical_volume) { { mountPath: mountPath, filesystem: filesystem } } - include_examples "with mountPath and filesystem", logical_volume_proc - end + drives = config.drives + expect(drives.size).to eq(1) - context "if a logical volume spicifies 'size'" do - let(:logical_volume) { { size: size } } - include_examples "with size", logical_volume_proc - end + drive = drives.first + expect(drive.search.name).to eq("/dev/vda") + expect(drive.partitions).to be_empty - context "if a logical volume specifies 'stripes'" do - let(:logical_volume) { { stripes: 4 } } + md_raids = config.md_raids + expect(md_raids.size).to eq(1) - it "sets #stripes to the expected value" do - logical_volume = logical_volume_proc.call(subject.convert) - expect(logical_volume.stripes).to eq(4) - end - end + md_raid = md_raids.first + expect(md_raid.search.name).to eq("/dev/md0") + expect(md_raid.partitions).to be_empty - context "if a logical volume specifies 'stripeSize'" do - let(:logical_volume) { { stripeSize: 2.KiB.to_i } } + volume_groups = config.volume_groups + expect(volume_groups.size).to eq(1) - it "sets #stripeSize to the expected value" do - logical_volume = logical_volume_proc.call(subject.convert) - expect(logical_volume.stripe_size).to eq(2.KiB) - end - end - end + volume_group = volume_groups.first + expect(volume_group.search.name).to eq("/dev/vg0") + expect(volume_group.logical_volumes).to be_empty end end end diff --git a/service/test/agama/storage/config_conversions/model_support_checker_test.rb b/service/test/agama/storage/config_conversions/model_support_checker_test.rb index caad48abbd..661a6b9123 100644 --- a/service/test/agama/storage/config_conversions/model_support_checker_test.rb +++ b/service/test/agama/storage/config_conversions/model_support_checker_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -184,6 +184,129 @@ end end + shared_examples "volume without mount path" do |device_name| + context "and the volume has not a search (new volume)" do + let(:search) { nil } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the volume has a search" do + let(:search) do + { + condition: condition, + ifNotFound: if_not_found + } + end + + let(:if_not_found) { nil } + + shared_examples "reused volume" do + context "and the volume is set to be deleted" do + let(:delete) { true } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the volume is set to be deleted if needed" do + let(:deleteIfNeeded) { true } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the volume is not set to be deleted" do + let(:delete) { false } + let(:deleteIfNeeded) { false } + + context "and the volume has encryption" do + let(:encryption) do + { luks1: { password: "12345" } } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the volume has filesystem" do + let(:filesystem) { { type: "xfs" } } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the volume has a size" do + let(:size) do + { + default: false, + min: 1.GiB, + max: 10.GiB + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the volume is only set to be resized if needed" do + let(:encryption) { nil } + let(:filesystem) { nil } + let(:size) do + { + default: false, + min: Y2Storage::DiskSize.zero + } + end + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + end + end + + context "and the volume is found" do + let(:condition) { { name: device_name } } + + include_examples "reused volume" + end + + context "and the volume is not found" do + let(:condition) { { name: "/no/found" } } + + context "and the volume can be skipped" do + let(:if_not_found) { "skip" } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the volume cannot be skipped" do + let(:if_not_found) { "error" } + + include_examples "reused volume" + end + + context "and the volume can be created" do + let(:if_not_found) { "create" } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + end + end + end + context "if there is a drive with encryption" do let(:config_json) do { @@ -325,143 +448,44 @@ let(:encryption) { nil } let(:size) { nil } - context "and the partition has not a search (new partition)" do - let(:search) { nil } - - it "returns false" do - expect(subject.supported?).to eq(false) - end - end - - context "and the partition has a search" do - let(:search) do - { - condition: condition, - ifNotFound: if_not_found - } - end - - let(:if_not_found) { nil } - - shared_examples "reused partition" do - context "and the partition is set to be deleted" do - let(:delete) { true } - - it "returns true" do - expect(subject.supported?).to eq(true) - end - end - - context "and the partition is set to be deleted if needed" do - let(:deleteIfNeeded) { true } - - it "returns true" do - expect(subject.supported?).to eq(true) - end - end - - context "and the partition is not set to be deleted" do - let(:delete) { false } - let(:deleteIfNeeded) { false } - - context "and the partition has encryption" do - let(:encryption) do - { luks1: { password: "12345" } } - end - - it "returns false" do - expect(subject.supported?).to eq(false) - end - end - - context "and the partition has filesystem" do - let(:filesystem) { { type: "xfs" } } - - it "returns false" do - expect(subject.supported?).to eq(false) - end - end - - context "and the partition has a size" do - let(:size) do - { - default: false, - min: 1.GiB, - max: 10.GiB - } - end - - it "returns false" do - expect(subject.supported?).to eq(false) - end - end - - context "and the partition is only set to be resized if needed" do - let(:encryption) { nil } - let(:filesystem) { nil } - let(:size) do - { - default: false, - min: Y2Storage::DiskSize.zero - } - end - - it "returns true" do - expect(subject.supported?).to eq(true) - end - end - end - end - - context "and the partition is found" do - let(:condition) { { name: "/dev/vda1" } } - - include_examples "reused partition" - end - - context "and the partition is not found" do - let(:condition) { { name: "/no/found" } } - - context "and the partition can be skipped" do - let(:if_not_found) { "skip" } - - it "returns true" do - expect(subject.supported?).to eq(true) - end - end - - context "and the partition cannot be skipped" do - let(:if_not_found) { "error" } - - include_examples "reused partition" - end - - context "and the partition can be created" do - let(:if_not_found) { "create" } - - it "returns false" do - expect(subject.supported?).to eq(false) - end - end - end - end + include_examples "volume without mount path", "/dev/vda1" end context "if there is a LVM logical volume without mount path" do + let(:scenario) { "several_vgs.yaml" } + let(:config_json) do { volumeGroups: [ { name: "system", - logicalVolumes: [{}] + logicalVolumes: [ + { + search: search, + delete: delete, + deleteIfNeeded: deleteIfNeeded, + filesystem: filesystem, + encryption: encryption, + size: size + } + ] } ] } end - it "returns false" do - expect(subject.supported?).to eq(false) - end + let(:search) { nil } + let(:delete) { nil } + let(:deleteIfNeeded) { nil } + let(:filesystem) { nil } + let(:encryption) { nil } + let(:size) { nil } + + include_examples "volume without mount path", "/dev/system/root" + + # it "returns false" do + # expect(subject.supported?).to eq(false) + # end end context "if there is a LVM logical volume with encryption" do diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb index 2a2df340ed..aecafe19a9 100644 --- a/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -51,6 +51,15 @@ end end + context "if #md_raids is not configured" do + let(:md_raids) { nil } + + it "generates the expected JSON" do + config_model = subject.convert + expect(config_model[:mdRaids]).to eq([]) + end + end + context "if #volume_groups is not configured" do let(:volume_groups) { nil } @@ -190,14 +199,19 @@ [ { vgName: "vg0", + spacePolicy: "keep", targetDevices: [], logicalVolumes: [ { - filesystem: { + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + filesystem: { reuse: false }, - mountPath: "/", - size: { + mountPath: "/", + size: { default: true, min: 0 } diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb b/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb index b35556e29e..b43380cf53 100644 --- a/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb +++ b/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -19,9 +19,14 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "agama/config" +require "agama/storage/configs/logical_volume" +require "agama/storage/configs/volume_group" +require "agama/storage/volume_templates_builder" require "y2storage/blk_device" +require "y2storage/lvm_lv" +require "y2storage/lvm_vg" require "y2storage/refinements" -require "agama/config" using Y2Storage::Refinements::SizeCasts @@ -76,6 +81,28 @@ end end +shared_examples "without delete" do + context "if #delete is not configured" do + let(:delete) { nil } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:delete]).to eq(false) + end + end +end + +shared_examples "without delete_if_needed" do + context "if #delete_if_needed is not configured" do + let(:delete_if_needed) { nil } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:deleteIfNeeded]).to eq(false) + end + end +end + shared_examples "with filesystem" do context "if #filesystem is configured" do let(:filesystem) do @@ -212,11 +239,16 @@ context "if #partitions is configured" do let(:partitions) do [ - { size: "10 GiB" }, + { + search: search, + size: "10 GiB" + }, { filesystem: { path: "/" } } ] end + let(:search) { nil } + it "generates the expected JSON" do model_json = subject.convert expect(model_json[:partitions]).to eq( @@ -249,11 +281,78 @@ ] ) end + + context "if there are skipped partitions" do + let(:search) do + { + condition: { name: "not-found" }, + ifNotFound: "skip" + } + end + + before do + config.partitions.first.search.solve + end + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:partitions]).to eq( + [ + { + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + filesystem: { + reuse: false + }, + mountPath: "/", + size: { + default: true, + min: 0 + } + } + ] + ) + end + end + end +end + +shared_examples "with delete" do + context "if #delete is configured" do + let(:delete) { true } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:delete]).to eq(true) + end end end -shared_examples "device name" do +shared_examples "with delete_if_needed" do + context "if #delete_if_needed is not configured" do + let(:delete_if_needed) { true } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:deleteIfNeeded]).to eq(true) + end + end +end + +shared_examples "device name" do |device_config_fn = nil| context "for the 'name' property" do + let(:device_config) { device_config_fn ? device_config_fn.call(config) : config } + + let(:device) do + if device_config.is_a?(Agama::Storage::Configs::VolumeGroup) + instance_double(Y2Storage::LvmVg, name: "/dev/test") + else + instance_double(Y2Storage::BlkDevice, name: "/dev/test") + end + end + context "if #search is not configured" do let(:search) { nil } @@ -278,7 +377,7 @@ let(:condition) { { name: "/dev/test" } } context "if the device is not found" do - before { config.search.solve } + before { device_config.search.solve } context "and the device does not have to be created" do let(:if_not_found) { "error" } @@ -300,13 +399,11 @@ end context "if the device is found" do - before { config.search.solve(device) } - - let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/test") } + before { device_config.search.solve(device) } it "generates the expected JSON" do model_json = subject.convert - expect(model_json[:name]).to eq("/dev/test") + expect(model_json[:name]).to eq(device.name) end end end @@ -315,7 +412,7 @@ let(:condition) { nil } context "if the device is not found" do - before { config.search.solve } + before { device_config.search.solve } context "and the device does not have to be created" do let(:if_not_found) { "error" } @@ -337,13 +434,11 @@ end context "if the device is found" do - before { config.search.solve(device) } - - let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/test") } + before { device_config.search.solve(device) } it "generates the expected JSON" do model_json = subject.convert - expect(model_json[:name]).to eq("/dev/test") + expect(model_json[:name]).to eq(device.name) end end end @@ -351,12 +446,31 @@ end end -shared_examples "space policy" do +shared_examples "space policy" do |device_config_fn = nil| context "for the 'spacePolicy' property" do - let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/test") } + let(:device_config) { device_config_fn ? device_config_fn.call(config) : config } + + let(:volumes_config) do + if device_config.is_a?(Agama::Storage::Configs::VolumeGroup) + device_config.logical_volumes + else + device_config.partitions + end + end - context "if there is a 'delete all' partition" do - let(:partitions) do + let(:device) do + if device_config.is_a?(Agama::Storage::Configs::VolumeGroup) + instance_double(Y2Storage::LvmVg, name: "/dev/test") + else + instance_double(Y2Storage::BlkDevice, name: "/dev/test") + end + end + + let(:partitions) { volumes_json } + let(:logical_volumes) { volumes_json } + + context "if there is a 'delete all' volume" do + let(:volumes_json) do [ { search: "*", delete: true }, { size: "2 GiB" } @@ -369,8 +483,8 @@ end end - context "if there is a 'resize all' partition" do - let(:partitions) do + context "if there is a 'resize all' volume" do + let(:volumes_json) do [ { search: "*", size: { min: 0, max: "current" } }, { size: "2 GiB" } @@ -383,15 +497,15 @@ end end - context "if there is a 'delete' partition" do - let(:partitions) do + context "if there is a 'delete' volume" do + let(:volumes_json) do [ { search: { max: 1 }, delete: true }, { filesystem: { path: "/" } } ] end - before { config.partitions.first.search.solve(device) } + before { volumes_config.first.search.solve(device) } it "generates the expected JSON" do model_json = subject.convert @@ -399,15 +513,15 @@ end end - context "if there is a 'delete if needed' partition" do - let(:partitions) do + context "if there is a 'delete if needed' volume" do + let(:volumes_json) do [ { search: { max: 1 }, deleteIfNeeded: true }, { filesystem: { path: "/" } } ] end - before { config.partitions.first.search.solve(device) } + before { volumes_config.first.search.solve(device) } it "generates the expected JSON" do model_json = subject.convert @@ -415,15 +529,15 @@ end end - context "if there is a 'resize' partition" do - let(:partitions) do + context "if there is a 'resize' volume" do + let(:volumes_json) do [ { search: { max: 1 }, size: "1 GiB" }, { filesystem: { path: "/" } } ] end - before { config.partitions.first.search.solve(device) } + before { volumes_config.first.search.solve(device) } it "generates the expected JSON" do model_json = subject.convert @@ -431,15 +545,15 @@ end end - context "if there is a 'resize if needed' partition" do - let(:partitions) do + context "if there is a 'resize if needed' volume" do + let(:volumes_json) do [ { search: { max: 1 }, size: { min: 0, max: "1 GiB" } }, { filesystem: { path: "/" } } ] end - before { config.partitions.first.search.solve(device) } + before { volumes_config.first.search.solve(device) } it "generates the expected JSON" do model_json = subject.convert @@ -447,8 +561,8 @@ end end - context "if there is neither 'delete' nor 'resize' partition" do - let(:partitions) do + context "if there is neither 'delete' nor 'resize' volume" do + let(:volumes_json) do [ { size: { min: "1 GiB" } }, { filesystem: { path: "/" } } @@ -462,3 +576,101 @@ end end end + +shared_examples "resize" do + let(:device) do + if config.is_a?(Agama::Storage::Configs::LogicalVolume) + instance_double(Y2Storage::LvmLv, name: "/dev/test/lv1") + else + instance_double(Y2Storage::BlkDevice, name: "/dev/vda1") + end + end + + context "for the 'resize' property" do + let(:search) { {} } + + context "if there is not assigned device" do + before { config.search.solve } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resize]).to eq(false) + end + end + + context "if there is an assigned device" do + before { config.search.solve(device) } + + context "and the #size is not configured" do + let(:size) { nil } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resize]).to eq(false) + end + end + + context "and the min size is equal to the max size" do + let(:size) { "1 GiB" } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resize]).to eq(true) + end + end + + context "and the min size is not equal to the max size" do + let(:size) { { min: "1 GiB", max: "2 GiB" } } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resize]).to eq(false) + end + end + end + end + + context "for the 'resizeIfNeeded' property" do + let(:search) { {} } + + context "if there is not assigned device" do + before { config.search.solve } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resizeIfNeeded]).to eq(false) + end + end + + context "if there is an assigned device" do + before { config.search.solve(device) } + + context "and the #size is not configured" do + let(:size) { nil } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resizeIfNeeded]).to eq(false) + end + end + + context "and the min size is equal to the max size" do + let(:size) { "1 GiB" } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resizeIfNeeded]).to eq(false) + end + end + + context "and the min size is not equal to the max size" do + let(:size) { { min: "1 GiB", max: "2 GiB" } } + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:resizeIfNeeded]).to eq(true) + end + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb index 5ddd7eef56..a714019c85 100644 --- a/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -39,21 +39,27 @@ let(:config_json) do { - filesystem: filesystem, - size: size, - name: name, - stripes: stripes, - stripeSize: stripe_size + search: search, + filesystem: filesystem, + size: size, + name: name, + stripes: stripes, + stripeSize: stripe_size, + delete: delete, + deleteIfNeeded: delete_if_needed } end let(:volumes) { Agama::Storage::VolumeTemplatesBuilder.new([]) } + let(:search) { nil } let(:filesystem) { nil } let(:size) { nil } let(:name) { nil } let(:stripes) { nil } let(:stripe_size) { nil } + let(:delete) { nil } + let(:delete_if_needed) { nil } describe "#convert" do context "if #name is not configured" do @@ -83,6 +89,8 @@ end end + include_examples "without delete" + include_examples "without delete_if_needed" include_examples "without filesystem" include_examples "without size" @@ -113,7 +121,11 @@ end end + include_examples "with delete" + include_examples "with delete_if_needed" include_examples "with filesystem" include_examples "with size" + include_examples "device name" + include_examples "resize" end end diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb index fd3c936088..bdbf5895fd 100644 --- a/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb @@ -24,6 +24,7 @@ require "agama/storage/config_conversions/from_json_conversions/md_raid" require "agama/storage/config_conversions/to_model_conversions/md_raid" require "agama/storage/volume_templates_builder" +require "y2storage/md" describe Agama::Storage::ConfigConversions::ToModelConversions::MdRaid do subject { described_class.new(config, volumes) } diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb index 7622b8ce40..fe36526f79 100644 --- a/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -64,24 +64,8 @@ end end - context "if #delete is not configured" do - let(:delete) { nil } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:delete]).to eq(false) - end - end - - context "if #delete_if_needed is not configured" do - let(:delete_if_needed) { nil } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:deleteIfNeeded]).to eq(false) - end - end - + include_examples "without delete" + include_examples "without delete_if_needed" include_examples "without filesystem" include_examples "without size" @@ -94,119 +78,11 @@ end end - context "if #delete is configured" do - let(:delete) { true } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:delete]).to eq(true) - end - end - - context "if #delete_if_needed is not configured" do - let(:delete_if_needed) { true } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:deleteIfNeeded]).to eq(true) - end - end - + include_examples "with delete" + include_examples "with delete_if_needed" include_examples "with filesystem" include_examples "with size" - include_examples "device name" - - context "for the 'resize' property" do - let(:search) { {} } - - context "if there is not assigned device" do - before { config.search.solve } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resize]).to eq(false) - end - end - - context "if there is an assigned device" do - before { config.search.solve(device) } - - let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/vda1") } - - context "and the #size is not configured" do - let(:size) { nil } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resize]).to eq(false) - end - end - - context "and the min size is equal to the max size" do - let(:size) { "1 GiB" } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resize]).to eq(true) - end - end - - context "and the min size is not equal to the max size" do - let(:size) { { min: "1 GiB", max: "2 GiB" } } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resize]).to eq(false) - end - end - end - end - - context "for the 'resizeIfNeeded' property" do - let(:search) { {} } - - context "if there is not assigned device" do - before { config.search.solve } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resizeIfNeeded]).to eq(false) - end - end - - context "if there is an assigned device" do - before { config.search.solve(device) } - - let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/vda1") } - - context "and the #size is not configured" do - let(:size) { nil } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resizeIfNeeded]).to eq(false) - end - end - - context "and the min size is equal to the max size" do - let(:size) { "1 GiB" } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resizeIfNeeded]).to eq(false) - end - end - - context "and the min size is not equal to the max size" do - let(:size) { { min: "1 GiB", max: "2 GiB" } } - - it "generates the expected JSON" do - model_json = subject.convert - expect(model_json[:resizeIfNeeded]).to eq(true) - end - end - end - end + include_examples "resize" end end diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb index e35cd7d8e6..69eb042d11 100644 --- a/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -20,8 +20,10 @@ # find current contact information at www.suse.com. require_relative "../../storage_helpers" +require_relative "./examples" require "agama/storage/config_conversions/from_json" require "agama/storage/config_conversions/to_model_conversions/volume_group" +require "y2storage/lvm_vg" require "y2storage/refinements" using Y2Storage::Refinements::SizeCasts @@ -42,6 +44,7 @@ drives: drives, volumeGroups: [ { + search: search, name: name, extentSize: extent_size, physicalVolumes: physical_volumes, @@ -54,12 +57,17 @@ let(:volumes) { Agama::Storage::VolumeTemplatesBuilder.new([]) } let(:drives) { nil } + let(:search) { nil } let(:name) { nil } let(:extent_size) { nil } let(:physical_volumes) { nil } let(:logical_volumes) { nil } describe "#convert" do + include_examples "device name", ->(c) { c.volume_groups.first } + + include_examples "space policy", ->(c) { c.volume_groups.first } + context "if #name is not configured" do let(:name) { nil } @@ -133,28 +141,41 @@ context "if #logical_volumes is configured" do let(:logical_volumes) do [ - { size: "10 GiB" }, + { + search: search, + size: "10 GiB" + }, { filesystem: { path: "/" } } ] end + let(:search) { nil } + it "generates the expected JSON" do model_json = subject.convert expect(model_json[:logicalVolumes]).to eq( [ { - size: { + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } }, { - filesystem: { + filesystem: { reuse: false }, - mountPath: "/", - size: { + mountPath: "/", + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 0 } @@ -162,6 +183,41 @@ ] ) end + + context "if there are skipped logical volumes" do + let(:search) do + { + condition: { name: "not-found" }, + ifNotFound: "skip" + } + end + + before do + config.logical_volumes.first.search.solve + end + + it "generates the expected JSON" do + model_json = subject.convert + expect(model_json[:logicalVolumes]).to eq( + [ + { + filesystem: { + reuse: false + }, + mountPath: "/", + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { + default: true, + min: 0 + } + } + ] + ) + end + end end end end diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index 0ccda4499d..38d31793cd 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -54,10 +54,12 @@ ], volumeGroups: [ { + search: "/dev/test", name: "test", physicalVolumes: [{ generate: ["disk1"] }], logicalVolumes: [ { + search: "/dev/test/lv1", filesystem: { path: "/" } } ] @@ -112,17 +114,24 @@ ], volumeGroups: [ { + name: "/dev/test", vgName: "test", targetDevices: ["/dev/vda"], + spacePolicy: "keep", logicalVolumes: [ { - filesystem: { + name: "/dev/test/lv1", + filesystem: { reuse: false, default: true, type: "btrfs" }, - mountPath: "/", - size: { + mountPath: "/", + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 0 } diff --git a/service/test/agama/storage/config_solvers/md_raids_search_test.rb b/service/test/agama/storage/config_solvers/md_raids_search_test.rb index abb8eaf699..6ce469d01c 100644 --- a/service/test/agama/storage/config_solvers/md_raids_search_test.rb +++ b/service/test/agama/storage/config_solvers/md_raids_search_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -68,7 +68,7 @@ context "and any of the devices is not available" do before do - allow(storage_system.analyzer).to receive(:available_device?) do |dev| + allow(storage_system).to receive(:available?) do |dev| dev.name != "/dev/md0" end end diff --git a/service/test/agama/storage/config_test.rb b/service/test/agama/storage/config_test.rb index 428b349cc5..d531aa4a9c 100644 --- a/service/test/agama/storage/config_test.rb +++ b/service/test/agama/storage/config_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -558,13 +558,17 @@ it "returns all configs with configurable search" do configs = subject.supporting_search - expect(configs.size).to eq(6) + expect(configs.size).to eq(8) end it "includes all drives" do expect(subject.supporting_search).to include(*subject.drives) end + it "includes all volume groups" do + expect(subject.supporting_search).to include(*subject.volume_groups) + end + it "includes all MD RAIDs" do expect(subject.supporting_search).to include(*subject.md_raids) end @@ -813,13 +817,17 @@ it "returns all configs with configurable delete" do configs = subject.supporting_delete - expect(configs.size).to eq(2) + expect(configs.size).to eq(3) end it "includes all partitions" do expect(subject.supporting_delete).to include(*subject.partitions) end + it "includes all logical volumes" do + expect(subject.supporting_delete).to include(*subject.logical_volumes) + end + it "does not include drives" do expect(subject.supporting_delete).to_not include(*subject.drives) end @@ -831,10 +839,6 @@ it "does not include volume groups" do expect(subject.supporting_delete).to_not include(*subject.volume_groups) end - - it "does not include logical volumes" do - expect(subject.supporting_delete).to_not include(*subject.logical_volumes) - end end describe "#potential_for_md_device" do diff --git a/service/test/agama/storage/system_test.rb b/service/test/agama/storage/system_test.rb index d1cb0a36b4..933a08bd36 100644 --- a/service/test/agama/storage/system_test.rb +++ b/service/test/agama/storage/system_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -67,14 +67,18 @@ end describe "#available_md_raids" do - let(:scenario) { "md_raids.yaml" } + let(:scenario) { "available_md_raids.yaml" } - before do - allow(disk_analyzer).to receive(:available_device?) { |d| d.name != "/dev/md0" } + it "includes all software RAIDs that are not in use" do + expect(subject.available_md_raids.map(&:name)).to contain_exactly("/dev/md0", "/dev/md1") end - it "includes all software RAIDs that are not in use" do - expect(subject.available_md_raids.map(&:name)).to contain_exactly("/dev/md1", "/dev/md2") + it "does not include software RAIDs in use" do + expect(subject.available_md_raids.map(&:name)).to_not include("/dev/md2") + end + + it "does not include software RAIDs over devices in use" do + expect(subject.available_md_raids.map(&:name)).to_not include("/dev/md3") end end @@ -85,4 +89,20 @@ expect(subject.candidate_md_raids).to be_empty end end + + describe "#available_volume_groups " do + let(:scenario) { "available_volume_groups.yaml" } + + it "includes all volume groups that are not in use" do + expect(subject.available_volume_groups.map(&:name)).to contain_exactly("/dev/vg0", "/dev/vg1") + end + + it "does not include volume groups in use" do + expect(subject.available_volume_groups.map(&:name)).to_not include("/dev/vg2") + end + + it "does not include volume groups over devices in use" do + expect(subject.available_volume_groups.map(&:name)).to_not include("/dev/vg3") + end + end end diff --git a/service/test/fixtures/available_md_raids.yaml b/service/test/fixtures/available_md_raids.yaml new file mode 100644 index 0000000000..f61f07d918 --- /dev/null +++ b/service/test/fixtures/available_md_raids.yaml @@ -0,0 +1,108 @@ +--- +- disk: + name: /dev/vda + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vda1 + - partition: + size: 10 GiB + name: /dev/vda2 +- disk: + name: /dev/vdb + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdb1 + - partition: + size: 10 GiB + name: /dev/vdb2 +- disk: + name: /dev/vdc + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdc1 +- disk: + name: /dev/vdd + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdd1 +- disk: + name: /dev/vde + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vde1 +- disk: + name: /dev/vdf + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdf1 + - partition: + size: 10 GiB + name: /dev/vdf2 + file_system: ext4 + mount_point: /test1 + +- md: + name: "/dev/md0" + chunk_size: 16 KiB + partition_table: gpt + partitions: + - partition: + size: 1 GiB + name: /dev/md0p1 + - partition: + size: 1 GiB + name: /dev/md0p2 + md_devices: + - md_device: + blk_device: /dev/vda1 + - md_device: + blk_device: /dev/vdb1 +- md: + name: "/dev/md1" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vda2 + - md_device: + blk_device: /dev/vdb2 +- md: + name: "/dev/md2" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vdc1 + - md_device: + blk_device: /dev/vdd1 + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/md2p1 + file_system: ext4 + mount_point: /test1 +- md: + name: "/dev/md3" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vde1 + - md_device: + blk_device: /dev/vdf1 diff --git a/service/test/fixtures/available_volume_groups.yaml b/service/test/fixtures/available_volume_groups.yaml new file mode 100644 index 0000000000..c3f43fc3f3 --- /dev/null +++ b/service/test/fixtures/available_volume_groups.yaml @@ -0,0 +1,72 @@ +--- +- disk: + name: /dev/vda + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vda1 + - partition: + size: 10 GiB + name: /dev/vda2 +- disk: + name: /dev/vdb + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdb1 +- disk: + name: /dev/vdc + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdc1 + - partition: + size: 10 GiB + name: /dev/vdc2 + file_system: ext4 + mount_point: /test1 +- lvm_vg: + vg_name: vg0 + lvm_pvs: + - lvm_pv: + blk_device: /dev/vda1 + lvm_lvs: + - lvm_lv: + size: 10 GiB + lv_name: lv1 +- lvm_vg: + vg_name: vg1 + lvm_pvs: + - lvm_pv: + blk_device: /dev/vda2 + lvm_lvs: + - lvm_lv: + size: 10 GiB + lv_name: lv1 + file_system: btrfs +- lvm_vg: + vg_name: vg2 + lvm_pvs: + - lvm_pv: + blk_device: /dev/vdb1 + lvm_lvs: + - lvm_lv: + size: 10 GiB + lv_name: lv1 + file_system: btrfs + mount_point: /test2 +- lvm_vg: + vg_name: vg3 + lvm_pvs: + - lvm_pv: + blk_device: /dev/vdc1 + lvm_lvs: + - lvm_lv: + size: 10 GiB + lv_name: lv1 diff --git a/service/test/y2storage/agama_proposal_lvm_test.rb b/service/test/y2storage/agama_proposal_lvm_test.rb index ae5ec88034..de26496170 100644 --- a/service/test/y2storage/agama_proposal_lvm_test.rb +++ b/service/test/y2storage/agama_proposal_lvm_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024-2025] SUSE LLC +# Copyright (c) [2024-2026] SUSE LLC # # All Rights Reserved. # @@ -784,5 +784,47 @@ expect(lv.size).to be > Y2Storage::DiskSize.GiB(50) end end + + context "when deleting volumes in a new volume group" do + let(:config_json) do + { + boot: { configure: false }, + drives: [ + { + partitions: [ + { + alias: "system-pv", + size: "40 GiB" + } + ] + } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: ["system-pv"], + logicalVolumes: [ + { search: "*", delete: true }, + { + name: "root", + size: "5 GiB", + filesystem: { + path: "/", + type: "btrfs" + } + } + ] + } + ] + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + vg = devicegraph.find_by_name("/dev/system") + expect(vg.lvm_lvs.map { |lv| lv.mount_point.path }).to contain_exactly("/") + end + end end end diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 3c8cb36172..018fffdee4 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Apr 13 15:25:11 UTC 2026 - José Iván López González + +- Extend storage UI to reuse LVM volume groups + (gh#agama-project/agama#3380). + ------------------------------------------------------------------- Tue Apr 7 14:19:47 UTC 2026 - Imobach Gonzalez Sosa diff --git a/web/src/components/core/Annotation.test.tsx b/web/src/components/core/Annotation.test.tsx index 67436501f0..75c3bab92e 100644 --- a/web/src/components/core/Annotation.test.tsx +++ b/web/src/components/core/Annotation.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -48,4 +48,9 @@ describe("Annotation", () => { const content = screen.getByText("Configured for installation only"); expect(content.tagName).toBe("STRONG"); }); + + it("renders nothing when children is empty", () => { + const { container } = plainRender({undefined}); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/web/src/components/core/Annotation.tsx b/web/src/components/core/Annotation.tsx index a5f27e4370..46b5a5f7ef 100644 --- a/web/src/components/core/Annotation.tsx +++ b/web/src/components/core/Annotation.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -45,6 +45,8 @@ type AnnotationProps = React.PropsWithChildren<{ * ``` */ export default function Annotation({ icon = "emergency", children }: AnnotationProps) { + if (!children) return null; + return ( {children} diff --git a/web/src/components/core/Popup.test.tsx b/web/src/components/core/Popup.test.tsx index 8e11362f70..953eab3a74 100644 --- a/web/src/components/core/Popup.test.tsx +++ b/web/src/components/core/Popup.test.tsx @@ -172,6 +172,13 @@ describe("Popup.SecondaryAction", () => { const button = screen.queryByRole("button", { name: "Do something" }); expect(button.classList.contains("pf-m-secondary")).toBe(true); }); + + it("renders a 'link' button when asLink is set", async () => { + installerRender(Do something); + + const button = screen.queryByRole("button", { name: "Do something" }); + expect(button.classList.contains("pf-m-link")).toBe(true); + }); }); describe("Popup.AncillaryAction", () => { @@ -234,4 +241,14 @@ describe("Popup.Cancel", () => { expect(button.classList.contains("pf-m-secondary")).toBe(true); }); }); + + describe("when asLink is set", () => { + it("renders a 'link' button", async () => { + installerRender(); + + const button = screen.queryByRole("button", { name: "Cancel" }); + expect(button).not.toBeNull(); + expect(button.classList.contains("pf-m-link")).toBe(true); + }); + }); }); diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index 91ed9906e2..b5839b1900 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -36,7 +36,7 @@ import { fork } from "radashi"; import { _, TranslatedString } from "~/i18n"; type ButtonWithoutVariantProps = Omit; -type PredefinedAction = React.PropsWithChildren; +type PredefinedAction = React.PropsWithChildren; export type PopupProps = { /** The dialog title */ title?: ModalHeaderProps["title"]; @@ -122,8 +122,8 @@ const Confirm = ({ children = _("Confirm"), ...actionProps }: PredefinedAction) * Dismiss * */ -const SecondaryAction = ({ children, ...actionProps }: PredefinedAction) => ( - +const SecondaryAction = ({ children, asLink, ...actionProps }: PredefinedAction) => ( + {children} ); diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index 08e3c5a3a8..b1fd3004cb 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -60,6 +60,7 @@ import MoreVert from "@bolderIcons/more_vert.svg?component"; import NetworkWifi from "@icons/network_wifi.svg?component"; import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component"; import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component"; +import NotificationsActive from "@icons/notifications_active.svg?component"; import Report from "@icons/report.svg?component"; import RestartAlt from "@icons/restart_alt.svg?component"; import SearchOff from "@icons/search_off.svg?component"; @@ -109,6 +110,7 @@ const icons = { network_wifi: NetworkWifi, network_wifi_1_bar: NetworkWifi1Bar, network_wifi_3_bar: NetworkWifi3Bar, + notifications_ative: NotificationsActive, report: Report, restart_alt: RestartAlt, search_off: SearchOff, diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index eeb604e971..5cfd721246 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -58,6 +58,8 @@ const NoDevicesConfiguredAlert = () => { export default function ConfigEditor() { const config = useConfigModel(); + if (!config) return; + const drives = config.drives; const mdRaids = config.mdRaids; const volumeGroups = config.volumeGroups; @@ -70,8 +72,8 @@ export default function ConfigEditor() { <> {/* FIXME add arial label */} - {volumeGroups.map((vg, i) => { - return ; + {volumeGroups.map((_, i) => { + return ; })} {mdRaids.map((_, i) => ( diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx index b7e7c929ba..d724e8ebd0 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -53,6 +53,22 @@ const vdb: Storage.Device = { }, }; +const md0: Storage.Device = { + sid: 61, + class: "mdRaid", + name: "/dev/md0", + description: "MD RAID 0", + md: { level: "raid1", devices: [59, 60] }, + block: { + start: 0, + size: 2e12, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + const vdaDrive: ConfigModel.Drive = { name: "/dev/vda", spacePolicy: "delete", @@ -68,10 +84,13 @@ const vdbDrive: ConfigModel.Drive = { const mockAddDrive = jest.fn(); const mockAddReusedMdRaid = jest.fn(); const mockUseModel = jest.fn(); +const mockUseAvailableDevices = jest.fn(); jest.mock("~/hooks/model/system/storage", () => ({ ...jest.requireActual("~/hooks/model/system/storage"), - useAvailableDevices: () => [vda, vdb], + useAvailableDevices: () => mockUseAvailableDevices(), + useDevices: () => [], + useFlattenDevices: () => [], })); jest.mock("~/hooks/model/storage/config-model", () => ({ @@ -79,11 +98,13 @@ jest.mock("~/hooks/model/storage/config-model", () => ({ useConfigModel: () => mockUseModel(), useAddDrive: () => mockAddDrive, useAddMdRaid: () => mockAddReusedMdRaid, + useAddVolumeGroup: () => jest.fn(), })); describe("ConfigureDeviceMenu", () => { beforeEach(() => { - mockUseModel.mockReturnValue({ drives: [], mdRaids: [] }); + mockUseModel.mockReturnValue({ drives: [], mdRaids: [], volumeGroups: [] }); + mockUseAvailableDevices.mockReturnValue([vda, vdb]); }); it("renders an initially closed menu ", async () => { @@ -113,18 +134,36 @@ describe("ConfigureDeviceMenu", () => { const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" }); await user.click(disksMenuItem); const dialog = screen.getByRole("dialog", { name: /Select a disk/ }); - const confirmButton = screen.getByRole("button", { name: "Confirm" }); - const vdaItemRow = within(dialog).getByRole("row", { name: /\/dev\/vda/ }); + const confirmButton = screen.getByRole("button", { name: /Add/ }); + const vdaItemRow = within(dialog).getByRole("row", { name: /vda/ }); const vdaItemRadio = within(vdaItemRow).getByRole("radio"); await user.click(vdaItemRadio); await user.click(confirmButton); expect(mockAddDrive).toHaveBeenCalledWith({ name: "/dev/vda", spacePolicy: "keep" }); }); + + it("shows intro text in the device selector", async () => { + const { user } = installerRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + await user.click(screen.getByRole("menuitem", { name: "Add device menu" })); + within(screen.getByRole("dialog")).getByText("Start configuring a basic installation"); + }); + + it("allows canceling the device selector without adding any device", async () => { + const { user } = installerRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + await user.click(screen.getByRole("menuitem", { name: "Add device menu" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(mockAddDrive).not.toHaveBeenCalled(); + }); }); describe("but some disks are already configured", () => { beforeEach(() => { - mockUseModel.mockReturnValue({ drives: [vdaDrive], mdRaids: [] }); + mockUseModel.mockReturnValue({ drives: [vdaDrive], mdRaids: [], volumeGroups: [] }); }); it("allows users to add a new drive to an unused disk", async () => { @@ -134,11 +173,11 @@ describe("ConfigureDeviceMenu", () => { const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" }); await user.click(disksMenuItem); const dialog = screen.getByRole("dialog", { name: /Select another disk/ }); - const confirmButton = screen.getByRole("button", { name: "Confirm" }); - expect(screen.queryByRole("row", { name: /vda$/ })).toBeNull(); - const vdaItemRow = within(dialog).getByRole("row", { name: /\/dev\/vdb/ }); - const vdaItemRadio = within(vdaItemRow).getByRole("radio"); - await user.click(vdaItemRadio); + const confirmButton = screen.getByRole("button", { name: /Add/ }); + expect(screen.queryByRole("row", { name: /vda/ })).toBeNull(); + const vdbItemRow = within(dialog).getByRole("row", { name: /vdb/ }); + const vdbItemRadio = within(vdbItemRow).getByRole("radio"); + await user.click(vdbItemRadio); await user.click(confirmButton); expect(mockAddDrive).toHaveBeenCalledWith({ name: "/dev/vdb", spacePolicy: "keep" }); }); @@ -147,7 +186,7 @@ describe("ConfigureDeviceMenu", () => { describe("when there are no more unused disks", () => { beforeEach(() => { - mockUseModel.mockReturnValue({ drives: [vdaDrive, vdbDrive], mdRaids: [] }); + mockUseModel.mockReturnValue({ drives: [vdaDrive, vdbDrive], mdRaids: [], volumeGroups: [] }); }); it("renders the disks menu as disabled with an informative label", async () => { @@ -156,6 +195,24 @@ describe("ConfigureDeviceMenu", () => { await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" }); expect(disksMenuItem).toBeDisabled(); + within(disksMenuItem).getByText("Already using all available disks"); + }); + }); + + describe("when there are MD RAID devices available", () => { + beforeEach(() => { + mockUseAvailableDevices.mockReturnValue([vda, md0]); + }); + + it("allows adding an MD RAID device", async () => { + const { user } = installerRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + await user.click(screen.getByRole("menuitem", { name: "Add device menu" })); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByRole("tab", { name: "RAID" })); + await user.click(screen.getByRole("button", { name: /Add/ })); + expect(mockAddReusedMdRaid).toHaveBeenCalledWith({ name: "/dev/md0", spacePolicy: "keep" }); }); }); }); diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index 5594776e56..edec2da052 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -25,42 +25,50 @@ import { useNavigate } from "react-router"; import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton"; import { Divider, Flex, MenuItemProps } from "@patternfly/react-core"; import { useAvailableDevices } from "~/hooks/model/system/storage"; -import { useConfigModel, useAddDrive, useAddMdRaid } from "~/hooks/model/storage/config-model"; +import { + useConfigModel, + useAddDrive, + useAddMdRaid, + useAddVolumeGroup, +} from "~/hooks/model/storage/config-model"; import { STORAGE as PATHS } from "~/routes/paths"; import { sprintf } from "sprintf-js"; import { _, n_ } from "~/i18n"; import DeviceSelectorModal from "./DeviceSelectorModal"; -import { isDrive } from "~/model/storage/device"; +import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device"; +import configModel from "~/model/storage/config-model"; import { Icon } from "../layout"; import type { Storage } from "~/model/system"; type AddDeviceMenuItemProps = { /** Whether some of the available devices is an MD RAID */ withRaids: boolean; + /** Whether some of the available devices is an LVM volume group */ + withLvm: boolean; /** Available devices to be chosen */ devices: Storage.Device[]; - /** The total amount of drives and RAIDs already configured */ + /** The total amount of devices (drives, RAIDs and VGs) already configured */ usedCount: number; } & MenuItemProps; -const AddDeviceTitle = ({ withRaids, usedCount }) => { - if (withRaids) { - if (usedCount === 0) return _("Select a device to define partitions or to mount"); - return _("Select another device to define partitions or to mount"); +const AddDeviceTitle = ({ withRaids, withLvm, usedCount }) => { + if (withRaids || withLvm) { + if (usedCount === 0) return _("Select an existing device"); + return _("Select another existing device"); } - if (usedCount === 0) return _("Select a disk to define partitions or to mount"); - return _("Select another disk to define partitions or to mount"); + if (usedCount === 0) return _("Select a disk"); + return _("Select another disk"); }; -const AddDeviceDescription = ({ withRaids, usedCount, isDisabled = false }) => { +const AddDeviceDescription = ({ withRaids, withLvm, usedCount, isDisabled = false }) => { if (isDisabled) { - if (withRaids) return _("Already using all available devices"); + if (withRaids || withLvm) return _("Already using all available devices"); return _("Already using all available disks"); } if (usedCount) { - if (withRaids) + if (withRaids || withLvm) return sprintf( n_( "Extend the installation beyond the currently selected device", @@ -88,6 +96,7 @@ const AddDeviceDescription = ({ withRaids, usedCount, isDisabled = false }) => { */ const AddDeviceMenuItem = ({ withRaids, + withLvm, usedCount, devices, onClick, @@ -101,13 +110,14 @@ const AddDeviceMenuItem = ({ description={ } onClick={onClick} > - + ); @@ -126,17 +136,25 @@ export default function ConfigureDeviceMenu(): React.ReactNode { const config = useConfigModel(); const addDrive = useAddDrive(); - const addReusedMdRaid = useAddMdRaid(); + const addMdRaid = useAddMdRaid(); + const addVolumeGroup = useAddVolumeGroup(); const allDevices = useAvailableDevices(); - const usedDevicesNames = config.drives.concat(config.mdRaids).map((d) => d.name); + const usedDevicesNames = configModel.devices(config).map((d) => d.name); const usedDevicesCount = usedDevicesNames.length; - const devices = allDevices.filter((d) => !usedDevicesNames.includes(d.name)); - const withRaids = !!allDevices.filter((d) => !isDrive(d)).length; + const availableDevices = allDevices.filter((d) => !usedDevicesNames.includes(d.name)); + const disks = availableDevices.filter(isDrive); + const mdRaids = availableDevices.filter(isMd); + const volumeGroups = availableDevices.filter(isVolumeGroup); + const withRaids = !!allDevices.filter((d) => isMd(d)).length; + const withLvm = !!allDevices.filter((d) => isVolumeGroup(d)).length; const addDevice = (device: Storage.Device) => { - const hook = isDrive(device) ? addDrive : addReusedMdRaid; - hook({ name: device.name, spacePolicy: "keep" }); + if (isDrive(device)) addDrive({ name: device.name, spacePolicy: "keep" }); + + if (isMd(device)) addMdRaid({ name: device.name, spacePolicy: "keep" }); + + if (isVolumeGroup(device)) addVolumeGroup({ name: device.name, spacePolicy: "keep" }, false); }; const lvmDescription = allDevices.length @@ -157,8 +175,9 @@ export default function ConfigureDeviceMenu(): React.ReactNode { , , @@ -172,15 +191,29 @@ export default function ConfigureDeviceMenu(): React.ReactNode { ]} > - {/** TODO: choose one, "add" or "add_circle", and remove the other at Icon.tsx */} {_("More devices")} {deviceSelectorOpen && ( } - description={} + disks={disks} + mdRaids={mdRaids} + volumeGroups={volumeGroups} + title={ + + } + intro={ + + } + tabIntros={{ + disks: _("Choose a disk to define partitions or to mount"), + mdRaids: _("Choose a RAID device to define partitions or to mount"), + volumeGroups: _("Choose a volume group to define logical volumes"), + }} onCancel={closeDeviceSelector} onConfirm={([device]) => { addDevice(device); diff --git a/web/src/components/storage/DeviceContent.test.tsx b/web/src/components/storage/DeviceContent.test.tsx new file mode 100644 index 0000000000..006c77c816 --- /dev/null +++ b/web/src/components/storage/DeviceContent.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import type { Storage } from "~/model/system"; +import DeviceContent from "./DeviceContent"; + +const disk: Storage.Device = { + sid: 1, + class: "drive", + name: "/dev/sda", + description: "ACME Disk", + drive: { + model: "ACME", + vendor: "", + bus: "SATA", + busId: "", + transport: "", + driver: [], + info: { dellBoss: false, sdCard: false }, + }, + block: { + start: 0, + size: 512e9, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + +describe("DeviceContent", () => { + it("renders the content description", () => { + plainRender(); + screen.getByText("ACME Disk"); + }); + + it("renders installed system names as labels", () => { + const device: Storage.Device = { + ...disk, + block: { ...disk.block, systems: ["Windows 11", "openSUSE Leap 15.6"] }, + }; + plainRender(); + screen.getByText("Windows 11"); + screen.getByText("openSUSE Leap 15.6"); + }); + + it("renders filesystem labels", () => { + const device: Storage.Device = { + ...disk, + filesystem: { sid: 100, type: "ext4", label: "root" }, + }; + plainRender(); + screen.getByText("root"); + }); +}); diff --git a/web/src/components/storage/DeviceContent.tsx b/web/src/components/storage/DeviceContent.tsx new file mode 100644 index 0000000000..42e7429b31 --- /dev/null +++ b/web/src/components/storage/DeviceContent.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Flex, Label } from "@patternfly/react-core"; +import { deviceSystems } from "~/model/storage/device"; +import { contentDescription, filesystemLabels } from "~/components/storage/utils/device"; + +import type { Storage } from "~/model/system"; + +/** + * Displays a summary of a storage device's current content: a textual + * description (e.g. partition table info or filesystem type), installed + * system names, and filesystem labels. + */ +export default function DeviceContent({ device }: { device: Storage.Device }) { + return ( + + {contentDescription(device)} + {deviceSystems(device).map((s, i) => ( + + ))} + {filesystemLabels(device).map((s, i) => ( + + ))} + + ); +} diff --git a/web/src/components/storage/DeviceEditorContent.test.tsx b/web/src/components/storage/DeviceEditorContent.test.tsx index 74a60ed9b8..2e14d11f97 100644 --- a/web/src/components/storage/DeviceEditorContent.test.tsx +++ b/web/src/components/storage/DeviceEditorContent.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -42,6 +42,8 @@ const mockConfigModel = jest.fn(); jest.mock("~/hooks/model/storage/config-model", () => ({ useConfigModel: () => mockConfigModel(), + usePartitionable: (_collection: string, index: number) => + mockConfigModel()?.drives[index] ?? null, })); const driveWithPartitions: ConfigModel.Drive = { diff --git a/web/src/components/storage/DeviceEditorContent.tsx b/web/src/components/storage/DeviceEditorContent.tsx index 483aeeac9a..e55d1075b7 100644 --- a/web/src/components/storage/DeviceEditorContent.tsx +++ b/web/src/components/storage/DeviceEditorContent.tsx @@ -25,7 +25,7 @@ import UnusedMenu from "~/components/storage/UnusedMenu"; import FilesystemMenu from "~/components/storage/FilesystemMenu"; import PartitionsSection from "~/components/storage/PartitionsSection"; import SpacePolicyMenu from "~/components/storage/SpacePolicyMenu"; -import { useConfigModel } from "~/hooks/model/storage/config-model"; +import { useConfigModel, usePartitionable } from "~/hooks/model/storage/config-model"; import configModel from "~/model/storage/config-model"; type DeviceEditorContentProps = { @@ -38,16 +38,18 @@ export default function DeviceEditorContent({ index, }: DeviceEditorContentProps): React.ReactNode { const config = useConfigModel(); - const device = config[collection][index]; - const isUsed = configModel.partitionable.isUsed(config, device.name); + const deviceConfig = usePartitionable(collection, index); + const isUsed = + configModel.partitionable.isUsed(config, deviceConfig.name) || + configModel.boot.hasDevice(config, deviceConfig.name); if (!isUsed) return ; return ( <> - {device.filesystem && } - {!device.filesystem && } - {!device.filesystem && } + {deviceConfig.filesystem && } + {!deviceConfig.filesystem && } + {!deviceConfig.filesystem && } ); } diff --git a/web/src/components/storage/DeviceSelectorModal.test.tsx b/web/src/components/storage/DeviceSelectorModal.test.tsx index 2e83d9b329..5bbeda3cd4 100644 --- a/web/src/components/storage/DeviceSelectorModal.test.tsx +++ b/web/src/components/storage/DeviceSelectorModal.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -22,10 +22,16 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { getColumnValues, plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import type { Storage } from "~/model/system"; import DeviceSelectorModal from "./DeviceSelectorModal"; +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useDevices: () => [], + useFlattenDevices: () => [], +})); + const sda: Storage.Device = { sid: 59, class: "drive", @@ -38,10 +44,7 @@ const sda: Storage.Device = { busId: "", transport: "usb", driver: ["ahci", "mmcblk"], - info: { - dellBoss: false, - sdCard: true, - }, + info: { dellBoss: false, sdCard: true }, }, block: { start: 1, @@ -58,6 +61,14 @@ const sdb: Storage.Device = { class: "drive", name: "/dev/sdb", description: "SDB drive", + drive: { + model: "Samsung Evo 8 Pro", + vendor: "Samsung", + bus: "IDE", + busId: "", + transport: "", + info: { dellBoss: false, sdCard: false }, + }, block: { start: 1, size: 2048, @@ -66,27 +77,41 @@ const sdb: Storage.Device = { systems: [], shrinking: { supported: false }, }, - drive: { - model: "Samsung Evo 8 Pro", - vendor: "Samsung", - bus: "IDE", - busId: "", - transport: "", - info: { - dellBoss: false, - sdCard: false, - }, +}; + +const md0: Storage.Device = { + sid: 70, + class: "mdRaid", + name: "/dev/md0", + description: "MD RAID 0", + md: { level: "raid1", devices: [1, 2] }, + block: { + start: 0, + size: 10240, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, }, }; +const vg0: Storage.Device = { + sid: 80, + class: "volumeGroup", + name: "/dev/vg0", + description: "Volume group 0", + volumeGroup: { size: 51200, physicalVolumes: [1, 2] }, + logicalVolumes: [], +}; + const onCancelMock = jest.fn(); const onConfirmMock = jest.fn(); describe("DeviceSelectorModal", () => { - it("renders a modal dialog with a table for selecting a device", () => { - plainRender( + it("renders a modal dialog", () => { + installerRender( { screen.getByRole("dialog", { name: "Select a device" }); }); - it("renders type, name, content, and filesystems columns", () => { - plainRender( - , + it("shows Disks, RAID, and LVM tabs", () => { + installerRender( + , ); - const table = screen.getByRole("grid"); - within(table).getByRole("columnheader", { name: "Device" }); - within(table).getByRole("columnheader", { name: "Size" }); - within(table).getByRole("columnheader", { name: "Description" }); - within(table).getByRole("columnheader", { name: "Current content" }); + screen.getByRole("tab", { name: "Disks" }); + screen.getByRole("tab", { name: "RAID" }); + screen.getByRole("tab", { name: "LVM" }); }); - it.todo("renders type, name, content, and filesystems of each device"); - it.todo("renders corresponding control (radio or checkbox) as checked for given selected device"); + it("shows a description hinting at the tabbed layout", () => { + installerRender( + , + ); + screen.getByText(/Use the tabs to browse/); + }); - it("allows sorting by device name", async () => { - const { user } = plainRender( + it("renders the intro content above the tabs", () => { + installerRender( Introductory text

} + title="Select" onCancel={onCancelMock} onConfirm={onConfirmMock} />, ); + screen.getByText("Introductory text"); + }); - const table = screen.getByRole("grid"); - const sortByDeviceButton = within(table).getByRole("button", { name: "Device" }); + describe("initial tab", () => { + it("opens the Disks tab by default", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "Disks" })).toHaveAttribute("aria-selected", "true"); + }); - expect(getColumnValues(table, "Device")).toEqual(["/dev/sda", "/dev/sdb"]); + it("opens the tab matching initialTab", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true"); + }); - await user.click(sortByDeviceButton); + it("opens the tab containing the selected device", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "LVM" })).toHaveAttribute("aria-selected", "true"); + }); - expect(getColumnValues(table, "Device")).toEqual(["/dev/sdb", "/dev/sda"]); + it("opens the tab of the auto-selected device when no device is given", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true"); + }); }); - it("allows sorting by device size", async () => { - const { user } = plainRender( - , - ); + describe("sideEffectsAlert", () => { + it("shows the disks alert in the footer when the selection differs from the given device", async () => { + const { user } = installerRender( + Disk selection note

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + screen.getByText("Disk selection note"); + }); + + it("shows the RAID alert in the footer when the selection differs from the given device", async () => { + const { user } = installerRender( + RAID selection note

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + const mdRow = screen.getByRole("row", { name: /md0/ }); + await user.click(within(mdRow).getByRole("radio")); + screen.getByText("RAID selection note"); + }); - const table = screen.getByRole("grid"); - const sortBySizeButton = within(table).getByRole("button", { name: "Size" }); + it("shows the LVM alert in the footer when the selection differs from the given device", async () => { + const { user } = installerRender( + LVM selection note

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + const vgRow = screen.getByRole("row", { name: /vg0/ }); + await user.click(within(vgRow).getByRole("radio")); + screen.getByText("LVM selection note"); + }); + + it("does not show the alert when the selection matches the given device", () => { + installerRender( + Disk selection note

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + expect(screen.queryByText("Disk selection note")).toBeNull(); + }); + }); - // By default, table is sorted by device name. Switch sorting to size in asc direction - await user.click(sortBySizeButton); + describe("empty states", () => { + it("shows an empty state in the Disks tab when no disks are given", () => { + installerRender( + , + ); + screen.getByText("No disks found"); + }); - expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]); + it("shows an empty state in the RAID tab when no RAID devices are given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByText("No RAID devices found"); + }); - // Now keep sorting by size, but in desc direction - await user.click(sortBySizeButton); + it("shows an empty state in the LVM tab when no volume groups are given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("No LVM volume groups found"); + }); - expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]); + it("shows the create link in the empty LVM state when newVolumeGroupLinkText is given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByRole("link", { name: "Define a new LVM" }); + }); + + it("does not show a create link in the empty LVM state when newVolumeGroupLinkText is not given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + expect(screen.queryByRole("link", { name: /create/i })).toBeNull(); + }); + + it("does not show a create link in the empty RAID state", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + expect(screen.queryByRole("link", { name: /create/i })).toBeNull(); + }); }); - it("triggers onCancel callback when users selects `Cancel` action", async () => { - const { user } = plainRender( - , - ); + describe("LVM tab with volume groups", () => { + it("shows the create link when newVolumeGroupLinkText is given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByRole("link", { name: "Define a new LVM" }); + }); - const cancelAction = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelAction); - expect(onCancelMock).toHaveBeenCalled(); + it("does not show a create link when newVolumeGroupLinkText is not given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + expect(screen.queryByRole("link", { name: /create/i })).toBeNull(); + }); }); - it("triggers `onCancel` callback when users selects `Cancel` action", async () => { - const { user } = plainRender( - , - ); + describe("autoSelectOnTabChange", () => { + it("auto-selects the first device of the new tab by default", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("button", { name: /Add.*md0/ }); + }); + + it("clears the selection when switching to an empty tab by default", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("button", { name: "Change" }); + }); - const cancelAction = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelAction); - expect(onCancelMock).toHaveBeenCalled(); + it("keeps the current selection when false", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("button", { name: /Add.*sda/ }); + }); }); - it("triggers `onConfirm` callback with selected devices when users selects `Confirm` action", async () => { - const { user } = plainRender( - , - ); + describe("tabIntros", () => { + it("shows intro text in the Disks tab when devices are present", () => { + installerRender( + Disk intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + screen.getByText("Disk intro text"); + }); + + it("shows intro text in the RAID tab when devices are present", async () => { + const { user } = installerRender( + RAID intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByText("RAID intro text"); + }); + + it("shows intro text in the LVM tab when devices are present", async () => { + const { user } = installerRender( + LVM intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("LVM intro text"); + }); + + it("does not show intro text when tab is empty", async () => { + const { user } = installerRender( + RAID intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + expect(screen.queryByText("RAID intro text")).toBeNull(); + }); + }); + + describe("custom empty states", () => { + it("shows custom empty state title for Disks tab", () => { + installerRender( + , + ); + screen.getByText("Custom disk title"); + }); + + it("shows custom empty state body for RAID tab", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByText("Custom RAID body text"); + }); + + it("shows custom empty state for LVM tab", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("No VGs available"); + screen.getByText("Cannot format volume groups"); + }); + + it("falls back to default empty state when custom not provided", () => { + installerRender( + , + ); + screen.getByText("No disks found"); + screen.getByText("No disks are available for selection."); + }); + }); + + describe("newDeviceLinkTexts", () => { + // RAID device creation is not yet implemented (no STORAGE.mdRaid.add route exists) + it.skip("shows create link for RAID in empty state", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("link", { name: "Create new RAID" }); + }); + + it("shows create link for LVM in empty state", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByRole("link", { name: "Create new LVM" }); + }); + + // RAID device creation is not yet implemented (no STORAGE.mdRaid.add route exists) + it.skip("shows create link for RAID when devices exist", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("link", { name: "Add another RAID" }); + }); + + it("shows both tabIntros and newDeviceLinkTexts together", async () => { + const { user } = installerRender( + Choose a volume group

}} + newDeviceLinkTexts={{ volumeGroups: "Create new VG" }} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("Choose a volume group"); + screen.getByRole("link", { name: "Create new VG" }); + }); + }); + + describe("actions", () => { + it("triggers onCancel when user selects Cancel", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancelMock).toHaveBeenCalled(); + }); + + it("shows 'Add' when there is no prior device", () => { + installerRender( + , + ); + screen.getByRole("button", { name: /Add/ }); + }); + + it("shows 'Keep' when the selection matches the given device", () => { + installerRender( + , + ); + screen.getByRole("button", { name: /Keep/ }); + }); + + it("shows 'Change to' when the selection differs from the given device", async () => { + const { user } = installerRender( + , + ); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + screen.getByRole("button", { name: /Change to/ }); + }); + + it("shows a 'Select a device' hint when no devices are available", () => { + installerRender( + , + ); + screen.getByText("Select a device"); + }); - const sdbRow = screen.getByRole("row", { name: /\/dev\/sdb/ }); - const sdbRadio = within(sdbRow).getByRole("radio"); - await user.click(sdbRadio); - const confirmAction = screen.getByRole("button", { name: "Confirm" }); - await user.click(confirmAction); - expect(onConfirmMock).toHaveBeenCalledWith([sdb]); + it("triggers onConfirm with the selected device when the user confirms", async () => { + const { user } = installerRender( + , + ); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + await user.click(screen.getByRole("button", { name: /Change to/ })); + expect(onConfirmMock).toHaveBeenCalledWith([sdb]); + }); }); }); diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index 9639ad8fc7..3697465bf4 100644 --- a/web/src/components/storage/DeviceSelectorModal.tsx +++ b/web/src/components/storage/DeviceSelectorModal.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -20,134 +20,400 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; -import { ButtonProps, Flex, Label } from "@patternfly/react-core"; -import Popup, { PopupProps } from "~/components/core/Popup"; -import SelectableDataTable, { - SortedBy, - SelectableDataTableProps, -} from "~/components/core/SelectableDataTable"; +import React, { useId, useState } from "react"; +import { first } from "radashi"; +import { sprintf } from "sprintf-js"; import { - typeDescription, - contentDescription, - filesystemLabels, -} from "~/components/storage/utils/device"; -import { deviceSize } from "~/components/storage/utils"; -import { sortCollection } from "~/utils"; + ButtonProps, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + Flex, + HelperText, + HelperTextItem, + PageSection, + Stack, + Tab, + Tabs, +} from "@patternfly/react-core"; +import Annotation from "~/components/core/Annotation"; +import Link from "~/components/core/Link"; +import NestedContent from "~/components/core/NestedContent"; +import Popup from "~/components/core/Popup"; +import SubtleContent from "~/components/core/SubtleContent"; +import DrivesTable from "~/components/storage/DrivesTable"; +import MdRaidsTable from "~/components/storage/MdRaidsTable"; +import VolumeGroupsTable from "~/components/storage/VolumeGroupsTable"; +import { STORAGE } from "~/routes/paths"; +import { deviceLabel } from "~/components/storage/utils"; import { _ } from "~/i18n"; -import { deviceSystems } from "~/model/storage/device"; + +import type { PopupProps } from "~/components/core/Popup"; import type { Storage } from "~/model/system"; -type DeviceSelectorProps = { - devices: Storage.Device[]; - selectedDevices?: Storage.Device[]; - onSelectionChange: SelectableDataTableProps["onSelectionChange"]; - selectionMode?: SelectableDataTableProps["selectionMode"]; -}; +/** Identifies which tab is active in {@link DeviceSelectorModal}. */ +export type TabKey = "disks" | "mdRaids" | "volumeGroups"; -const size = (device: Storage.Device) => { - return deviceSize(device.block.size); -}; +/** Tab keys for device types that can be created (excludes disks). */ +export type CreatableTabKey = "mdRaids" | "volumeGroups"; -const description = (device: Storage.Device) => { - const model = device.drive?.model; - if (model && model.length) return model; +/** Side effects shown when selecting a device from a specific tab. */ +export type SideEffects = { + [K in TabKey]?: React.ReactNode; +}; - return typeDescription(device); +/** Introductory content shown at the top of each tab. */ +export type TabIntros = { + [K in TabKey]?: React.ReactNode; }; -const details = (device: Storage.Device) => { - return ( - - {contentDescription(device)} - {deviceSystems(device).map((s, i) => ( - - ))} - {filesystemLabels(device).map((s, i) => ( - - ))} - - ); +/** Empty state titles for each tab when no devices are available. */ +export type EmptyStateTitles = { + [K in TabKey]?: React.ReactNode; }; -// TODO: document -const DeviceSelector = ({ - devices, - selectedDevices, - onSelectionChange, - selectionMode = "single", -}: DeviceSelectorProps) => { - const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" }); - - const columns = [ - { name: _("Device"), value: (device: Storage.Device) => device.name, sortingKey: "name" }, - { - name: _("Size"), - value: size, - sortingKey: (d: Storage.Device) => d.block.size, - pfTdProps: { style: { width: "10ch" } }, - }, - { name: _("Description"), value: description }, - { name: _("Current content"), value: details }, - ]; - - // Sorting - const sortingKey = columns[sortedBy.index].sortingKey; - const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey); +/** Empty state body text for each tab when no devices are available. */ +export type EmptyStateBodies = { + [K in TabKey]?: React.ReactNode; +}; - return ( - <> - - - ); +/** + * Link text for creating new devices. + * Only available for device types that can be created (excludes disks). + */ +export type NewDeviceLinkTexts = { + [K in CreatableTabKey]?: React.ReactNode; }; -type DeviceSelectorModalProps = Omit & { +/** Props for {@link DeviceSelectorModal}. */ +export type DeviceSelectorModalProps = Omit & { + /** General information shown at the top of the modal, above the tabs. */ + intro?: React.ReactNode; + /** Tab to open initially. Takes precedence over the tab derived from {@link selected}. */ + initialTab?: TabKey; + /** Currently selected device. Determines the initial tab and initial selection. */ selected?: Storage.Device; - devices: Storage.Device[]; + /** Available disks. */ + disks?: Storage.Device[]; + /** Available software RAID devices. */ + mdRaids?: Storage.Device[]; + /** Available LVM volume groups. */ + volumeGroups?: Storage.Device[]; + /** + * Side effects shown when selecting a device from a specific tab. + * Only shown when the selection differs from {@link selected}. + */ + sideEffects?: SideEffects; + /** + * Introductory content shown at the top of each tab. + * Not rendered when the tab is empty (empty state is shown instead). + */ + tabIntros?: TabIntros; + /** Custom titles for empty states. Defaults are provided for each tab. */ + emptyStateTitles?: EmptyStateTitles; + /** Custom body text for empty states. Defaults are provided for each tab. */ + emptyStateBodies?: EmptyStateBodies; + /** + * Link text for creating new devices in each tab. + * When set, a link is shown with this text. Only available for creatable device types. + */ + newDeviceLinkTexts?: NewDeviceLinkTexts; + /** + * Whether switching tabs auto-selects the first device of the new tab, + * or clears the selection when the tab is empty. Defaults to `true`. + */ + autoSelectOnTabChange?: boolean; + /** Called with the new selection when the user confirms. */ onConfirm: (selection: Storage.Device[]) => void; + /** Called when the user cancels. */ onCancel: ButtonProps["onClick"]; }; +const TABS: Record = { disks: 0, mdRaids: 1, volumeGroups: 2 }; + +/** Empty state shown in a tab when no devices of that type are available. */ +const NoDevicesFound = ({ + title, + body, + action, +}: { + title: React.ReactNode; + body: React.ReactNode; + action?: React.ReactNode; +}) => ( + + {body} + {action && ( + + {action} + + )} + +); + +/** + * Renders a link to create a new device, styled as subtle content. + * The link position and text are extracted from `text` using `[text]` markers. + */ +const CreateDeviceLink = ({ text, to }: { text: string; to: string }) => { + const [before, linkText, after] = text.split(/[[\]]/); + return ( + + {before} + + {linkText} + + {after} + + ); +}; + +/** + * Wrapper for a tab's scrollable content area. Renders `children` when given, + * or falls back to {@link NoDevicesFound} built from the `empty*` props. + */ +const TabContent = ({ + emptyTitle, + emptyBody, + emptyAction, + intro, + children, +}: { + emptyTitle: React.ReactNode; + emptyBody: React.ReactNode; + emptyAction?: React.ReactNode; + intro?: React.ReactNode; + children?: React.ReactNode; +}) => ( + + + {children ? ( + <> + {intro} + {children} + + ) : ( + + )} + + +); + +/** + * Returns the tab index to activate when the modal opens. + * + * Resolution order: + * 1. Explicit `initialTab` key. + * 2. Tab that contains `selected`. + * 3. First tab (index 0). + */ +function getInitialTabIndex( + initialTab?: TabKey, + selected?: Storage.Device, + deviceLists?: Storage.Device[][], +): number { + if (initialTab) return TABS[initialTab]; + + if (selected && deviceLists) { + const index = deviceLists.findIndex((list) => list.some((d) => d.sid === selected.sid)); + return index !== -1 ? index : 0; + } + + return 0; +} + +/** + * Modal for selecting a storage device across three categories: disks, + * software RAID devices, and LVM volume groups. + * + * The confirm button label reflects the state of the selection: + * + * - "Add X" when there is no prior device and one is selected, + * - "Keep X" when the selection matches {@link + * DeviceSelectorModalProps.selected}, + * - "Change to X" when a different device is picked, + * - "Add" or "Change" when no device is selected (e.g. after switching to an + * empty tab). + * + * An optional side-effects alert is displayed near the confirm button when the + * user switches to a different device. Both the alert and the "Select a device" + * hint are live regions linked to the confirm button via `aria-describedby` so + * assistive technologies announce changes. + */ export default function DeviceSelectorModal({ - selected = undefined, + selected: previousDevice, + initialTab, onConfirm, onCancel, - devices, + intro, + disks = [], + mdRaids = [], + volumeGroups = [], + sideEffects, + tabIntros, + emptyStateTitles, + emptyStateBodies, + newDeviceLinkTexts, + autoSelectOnTabChange = true, ...popupProps }: DeviceSelectorModalProps): React.ReactNode { - // FIXME: improve initial selection handling + const confirmHintId = useId(); + const initialDevice = previousDevice ?? first([...disks, ...mdRaids, ...volumeGroups]); const [selectedDevices, setSelectedDevices] = useState( - selected ? [selected] : [devices[0]], + initialDevice ? [initialDevice] : [], + ); + const [activeTab, setActiveTab] = useState(() => + getInitialTabIndex(initialTab, initialDevice, [disks, mdRaids, volumeGroups]), ); + const tabLists = [disks, mdRaids, volumeGroups]; + + const currentDevice = selectedDevices[0]; + const deviceSideEffectsAlert = + currentDevice && + [ + { list: disks, alert: sideEffects?.disks }, + { list: mdRaids, alert: sideEffects?.mdRaids }, + { list: volumeGroups, alert: sideEffects?.volumeGroups }, + ].find(({ list }) => list.some((d) => d.sid === currentDevice.sid))?.alert; + + const deviceInInitialTab = + currentDevice && tabLists[activeTab].some((d) => d.sid === currentDevice.sid); + + const onTabClick = (_, tabIndex: number) => { + setActiveTab(tabIndex); + if (autoSelectOnTabChange) { + const device = first(tabLists[tabIndex]); + setSelectedDevices(device ? [device] : []); + } + }; - const onAccept = () => { - selectedDevices !== Array(selected) && onConfirm(selectedDevices); + const confirmLabel = (): string => { + if (!currentDevice) return previousDevice ? _("Change") : _("Add"); + // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)" + if (!previousDevice) return sprintf(_("Add %s"), deviceLabel(currentDevice)); + // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)" + if (currentDevice.sid === previousDevice.sid) + return sprintf(_("Keep %s"), deviceLabel(currentDevice)); + // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)" + return sprintf(_("Change to %s"), deviceLabel(currentDevice)); }; return ( - - + + + {intro} + + + + + {disks.length > 0 && ( + + )} + + + + + {mdRaids.length > 0 && ( + + )} + + + + {newDeviceLinkTexts.volumeGroups} + ) + } + > + {volumeGroups.length > 0 && ( + <> + {newDeviceLinkTexts?.volumeGroups && ( + + )} + + + )} + + + + + - - + + {!currentDevice && ( + + {_("Select a device")} + + )} + {currentDevice && currentDevice.sid !== previousDevice?.sid && deviceSideEffectsAlert && ( + + + {deviceSideEffectsAlert} + + + )} + + onConfirm(selectedDevices)} + isDisabled={!currentDevice} + aria-describedby={confirmHintId} + > + {confirmLabel()} + + + + ); diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index e04b7ea246..7cc42fd47c 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -43,7 +43,8 @@ jest.mock("~/hooks/model/storage/config-model", () => ({ useAddDriveFromMdRaid: jest.fn(), useAddMdRaidFromDrive: jest.fn(), useDeleteDrive: () => mockDeleteDrive, - useAddVolumeGroupFromPartitionable: () => mockAddVolumeGroupFromPartitionable, + useConvertPartitionableToVolumeGroup: () => mockAddVolumeGroupFromPartitionable, + useConvertDevice: () => jest.fn(), })); const mockSystemDevice = jest.fn(); diff --git a/web/src/components/storage/DrivesTable.test.tsx b/web/src/components/storage/DrivesTable.test.tsx new file mode 100644 index 0000000000..ad2fa0b507 --- /dev/null +++ b/web/src/components/storage/DrivesTable.test.tsx @@ -0,0 +1,134 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { getColumnValues, plainRender } from "~/test-utils"; +import type { Storage } from "~/model/system"; +import DrivesTable from "./DrivesTable"; + +const sda: Storage.Device = { + sid: 59, + class: "drive", + name: "/dev/sda", + description: "SDA drive", + drive: { + model: "Micron 1100 SATA", + vendor: "Micron", + bus: "SATA", + busId: "", + transport: "sata", + driver: [], + info: { dellBoss: false, sdCard: false }, + }, + block: { + start: 0, + size: 1024, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + +const sdb: Storage.Device = { + sid: 62, + class: "drive", + name: "/dev/sdb", + description: "SDB drive", + drive: { + model: "Samsung Evo 8 Pro", + vendor: "Samsung", + bus: "USB", + busId: "", + transport: "usb", + driver: [], + info: { dellBoss: false, sdCard: false }, + }, + block: { + start: 0, + size: 2048, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + +const onSelectionChangeMock = jest.fn(); + +describe("DrivesTable", () => { + it("renders Device, Size, Description, and Current content columns", () => { + plainRender(); + const table = screen.getByRole("grid"); + within(table).getByRole("columnheader", { name: "Device" }); + within(table).getByRole("columnheader", { name: "Size" }); + within(table).getByRole("columnheader", { name: "Description" }); + within(table).getByRole("columnheader", { name: "Current content" }); + }); + + it("renders a row per device", () => { + plainRender(); + screen.getByRole("row", { name: /sda/ }); + screen.getByRole("row", { name: /sdb/ }); + }); + + it("allows sorting by device name", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sortButton = within(table).getByRole("button", { name: "Device" }); + + expect(getColumnValues(table, "Device")).toEqual(["sda", "sdb"]); + + await user.click(sortButton); + + expect(getColumnValues(table, "Device")).toEqual(["sdb", "sda"]); + }); + + it("allows sorting by size", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sortButton = within(table).getByRole("button", { name: "Size" }); + + await user.click(sortButton); + expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]); + + await user.click(sortButton); + expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]); + }); + + it("calls onSelectionChange when a device is selected", async () => { + const { user } = plainRender( + , + ); + + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + expect(onSelectionChangeMock).toHaveBeenCalledWith([sdb]); + }); +}); diff --git a/web/src/components/storage/DrivesTable.tsx b/web/src/components/storage/DrivesTable.tsx new file mode 100644 index 0000000000..99d6bd6e70 --- /dev/null +++ b/web/src/components/storage/DrivesTable.tsx @@ -0,0 +1,101 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import SelectableDataTable from "~/components/core/SelectableDataTable"; +import DeviceContent from "~/components/storage/DeviceContent"; +import { deviceBaseName, deviceSize } from "~/components/storage/utils"; +import { typeDescription } from "~/components/storage/utils/device"; +import { sortCollection } from "~/utils"; +import { _ } from "~/i18n"; + +import type { Storage } from "~/model/system"; +import type { SortedBy, SelectableDataTableProps } from "~/components/core/SelectableDataTable"; + +/** Props for {@link DrivesTable}. */ +type DrivesTableProps = { + /** Available drives. */ + devices: Storage.Device[]; + /** Currently selected drives. */ + selectedDevices?: Storage.Device[]; + /** Called when the selection changes. */ + onSelectionChange: SelectableDataTableProps["onSelectionChange"]; + /** Selection mode. Defaults to `"single"`. */ + selectionMode?: SelectableDataTableProps["selectionMode"]; +}; + +const size = (device: Storage.Device) => { + const bytes = device.volumeGroup?.size || device.block?.size || 0; + return deviceSize(bytes); +}; + +const description = (device: Storage.Device) => { + const model = device.drive?.model; + if (model && model.length) return model; + + return typeDescription(device); +}; + +/** + * Table for selecting among available drives. + */ +export default function DrivesTable({ + devices, + selectedDevices, + onSelectionChange, + selectionMode = "single", +}: DrivesTableProps) { + const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" }); + + const columns = [ + { + name: _("Device"), + value: (device: Storage.Device) => deviceBaseName(device), + sortingKey: "name", + pfTdProps: { style: { width: "15ch" } }, + }, + { + name: _("Size"), + value: size, + sortingKey: (d: Storage.Device) => d.block.size, + pfTdProps: { style: { width: "10ch" } }, + }, + { name: _("Description"), value: description }, + { name: _("Current content"), value: (d: Storage.Device) => }, + ]; + + const sortingKey = columns[sortedBy.index].sortingKey; + const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey); + + return ( + + ); +} diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index 697d476f49..ee742c3b9e 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -20,16 +20,11 @@ * find current contact information at www.suse.com. */ -/** - * @fixme This code was done in a hurry for including LVM managent in SLE16 beta3. It must be - * completely refactored. There are a lot of duplications with PartitionPage. Both PartitionPage - * and LogicalVolumePage should be adapted to share as much functionality as possible. - */ - -import React, { useCallback, useEffect, useId, useMemo, useState } from "react"; -import { useParams, useNavigate } from "react-router"; +import React, { useState } from "react"; +import { useParams, useNavigate, useLocation } from "react-router"; import { ActionGroup, + Divider, Flex, FlexItem, Form, @@ -37,44 +32,50 @@ import { FormHelperText, HelperText, HelperTextItem, + Label, SelectGroup, SelectList, SelectOption, SelectOptionProps, + Split, + SplitItem, Stack, - StackItem, TextInput, } from "@patternfly/react-core"; import { Page, SelectWrapper as Select, SubtleContent } from "~/components/core/"; import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; import AutoSizeText from "~/components/storage/AutoSizeText"; -import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils"; +import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect"; +import ResourceNotFound from "~/components/core/ResourceNotFound"; import configModel from "~/model/storage/config-model"; +import { useVolumeTemplate, useDevice } from "~/hooks/model/system/storage"; import { - useSolvedConfigModel, useConfigModel, + useSolvedConfigModel, useMissingMountPaths, - useVolumeGroup, + useVolumeGroup as useConfigModelVolumeGroup, useAddLogicalVolume, useEditLogicalVolume, } from "~/hooks/model/storage/config-model"; -import { useVolumeTemplate } from "~/hooks/model/system/storage"; +import { deviceSize, deviceLabel, filesystemLabel, parseToBytes } from "~/components/storage/utils"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; import { STORAGE as PATHS, STORAGE } from "~/routes/paths"; import { unique } from "radashi"; import { compact } from "~/utils"; -import { sprintf } from "sprintf-js"; -import { _ } from "~/i18n"; -import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect"; -import type { ConfigModel, Data } from "~/model/storage/config-model"; +import type { ConfigModel } from "~/model/storage/config-model"; import type { Storage as System } from "~/model/system"; const NO_VALUE = ""; +const NEW_LOGICAL_VOLUME = "new"; +const REUSE_FILESYSTEM = "reuse"; type SizeOptionValue = "" | SizeMode; type FormValue = { mountPoint: string; name: string; + target: string; filesystem: string; filesystemLabel: string; sizeOption: SizeOptionValue; @@ -93,7 +94,26 @@ type ErrorsHandler = { getVisibleError: (id: string) => Error | undefined; }; -function toData(value: FormValue): Data.LogicalVolume { +function configuredLogicalVolumes( + volumeGroupConfig: ConfigModel.VolumeGroup, +): ConfigModel.LogicalVolume[] { + if (volumeGroupConfig.spacePolicy === "custom") + return volumeGroupConfig.logicalVolumes.filter( + (l) => + !configModel.volume.isNew(l) && + (configModel.volume.isUsed(l) || configModel.volume.isUsedBySpacePolicy(l)), + ); + + return volumeGroupConfig.logicalVolumes.filter(configModel.volume.isReused); +} + +function createLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume { + const name = (): string | undefined => { + if (value.target === NO_VALUE || value.target === NEW_LOGICAL_VOLUME) return undefined; + + return value.target; + }; + const filesystemType = (): ConfigModel.FilesystemType | undefined => { if (value.filesystem === NO_VALUE) return undefined; @@ -107,11 +127,14 @@ function toData(value: FormValue): Data.LogicalVolume { return value.filesystem as ConfigModel.FilesystemType; }; - const filesystem = (): Data.Filesystem | undefined => { + const filesystem = (): ConfigModel.Filesystem | undefined => { + if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true, default: true }; + const type = filesystemType(); if (type === undefined) return undefined; return { + default: false, type, label: value.filesystemLabel, }; @@ -131,26 +154,32 @@ function toData(value: FormValue): Data.LogicalVolume { return { mountPath: value.mountPoint, lvName: value.name, + name: name(), filesystem: filesystem(), size: size(), }; } -function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue { - const mountPoint = (): string => logicalVolume.mountPath || NO_VALUE; +function createFormValue(logicalVolumeConfig: ConfigModel.LogicalVolume): FormValue { + const mountPoint = (): string => logicalVolumeConfig.mountPath || NO_VALUE; + + const target = (): string => logicalVolumeConfig.name || NEW_LOGICAL_VOLUME; const filesystem = (): string => { - const fs = logicalVolume.filesystem; - if (!fs.type) return NO_VALUE; + const fsConfig = logicalVolumeConfig.filesystem; + if (fsConfig.reuse) return REUSE_FILESYSTEM; + if (!fsConfig.type) return NO_VALUE; - return fs.type; + return fsConfig.type; }; - const filesystemLabel = (): string => logicalVolume.filesystem?.label || NO_VALUE; + const filesystemLabel = (): string => logicalVolumeConfig.filesystem?.label || NO_VALUE; const sizeOption = (): SizeOptionValue => { - const size = logicalVolume.size; - if (!size || size.default) return "auto"; + const reuse = logicalVolumeConfig.name !== undefined; + const sizeConfig = logicalVolumeConfig.size; + if (reuse) return NO_VALUE; + if (!sizeConfig || sizeConfig.default) return "auto"; return "custom"; }; @@ -160,24 +189,49 @@ function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue { return { mountPoint: mountPoint(), - name: logicalVolume.lvName, + name: logicalVolumeConfig.lvName, + target: target(), filesystem: filesystem(), filesystemLabel: filesystemLabel(), sizeOption: sizeOption(), - minSize: size(logicalVolume.size?.min), - maxSize: size(logicalVolume.size?.max), + minSize: size(logicalVolumeConfig.size?.min), + maxSize: size(logicalVolumeConfig.size?.max), }; } +function useVolumeGroupConfig(): ConfigModel.VolumeGroup | null { + const { id: index } = useParams(); + + return useConfigModelVolumeGroup(Number(index)) ?? null; +} + +function useVolumeGroup(): System.Device { + const volumeGroupConfig = useVolumeGroupConfig(); + return useDevice(volumeGroupConfig.name); +} + +function useLogicalVolume(target: string): System.Device | null { + const volumeGroup = useVolumeGroup(); + + if (target === NEW_LOGICAL_VOLUME) return null; + + const logicalVolumes = volumeGroup.logicalVolumes || []; + return logicalVolumes.find((p: System.Device) => p.name === target); +} + +function useLogicalVolumeFilesystem(target: string): string | null { + const logicalVolume = useLogicalVolume(target); + return logicalVolume?.filesystem?.type || null; +} + function useDefaultFilesystem(mountPoint: string): string { const volume = useVolumeTemplate(mountPoint); return volume.fsType; } -function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null { - const { id: vgName, logicalVolumeId: mountPath } = useParams(); - const volumeGroup = useVolumeGroup(vgName); - +function useInitialLogicalVolumeConfig(): ConfigModel.LogicalVolume | null { + const { logicalVolumeId: mountPath } = useParams(); + const volumeGroup = useVolumeGroupConfig(); if (!volumeGroup || !mountPath) return null; const logicalVolume = volumeGroup.logicalVolumes.find((l) => l.mountPath === mountPath); @@ -185,23 +239,41 @@ function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null { } function useInitialFormValue(): FormValue | null { - const logicalVolume = useInitialLogicalVolume(); - const value = useMemo(() => (logicalVolume ? toFormValue(logicalVolume) : null), [logicalVolume]); + const logicalVolumeConfig = useInitialLogicalVolumeConfig(); + + const value = React.useMemo( + () => (logicalVolumeConfig ? createFormValue(logicalVolumeConfig) : null), + [logicalVolumeConfig], + ); + return value; } /** Unused predefined mount points. Includes the currently used mount point when editing. */ function useUnusedMountPoints(): string[] { - const missingMountPaths = useMissingMountPaths(); - const initialLogicalVolume = useInitialLogicalVolume(); - return compact([initialLogicalVolume?.mountPath, ...missingMountPaths]); + const unusedMountPaths = useMissingMountPaths(); + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); + return compact([initialLogicalVolumeConfig?.mountPath, ...unusedMountPaths]); +} + +/** Unused logical volumes. Includes the currently used logical volume when editing (if any). */ +function useUnusedLogicalVolumes(): System.Device[] { + const volumeGroup = useVolumeGroup(); + const allLogicalVolumes = volumeGroup.logicalVolumes || []; + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); + const volumeGroupConfig = useVolumeGroupConfig(); + const configuredNames = configuredLogicalVolumes(volumeGroupConfig) + .filter((l) => l.name !== initialLogicalVolumeConfig?.name) + .map((l) => l.name); + + return allLogicalVolumes.filter((l) => !configuredNames.includes(l.name)); } function useUsableFilesystems(mountPoint: string): string[] { const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); - const usableFilesystems = useMemo(() => { + const usableFilesystems = React.useMemo(() => { const volumeFilesystems = (): string[] => { return volume.outline.fsTypes; }; @@ -215,7 +287,7 @@ function useUsableFilesystems(mountPoint: string): string[] { function useMountPointError(value: FormValue): Error | undefined { const config = useConfigModel(); const mountPoints = config ? configModel.usedMountPaths(config) : []; - const initialLogicalVolume = useInitialLogicalVolume(); + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); const mountPoint = value.mountPoint; if (mountPoint === NO_VALUE) { @@ -235,7 +307,7 @@ function useMountPointError(value: FormValue): Error | undefined { } // Exclude itself when editing - const initialMountPoint = initialLogicalVolume?.mountPath; + const initialMountPoint = initialLogicalVolumeConfig?.mountPath; if (mountPoint !== initialMountPoint && mountPoints.includes(mountPoint)) { return { id: "mountPoint", @@ -246,7 +318,7 @@ function useMountPointError(value: FormValue): Error | undefined { } function checkLogicalVolumeName(value: FormValue): Error | undefined { - if (value.name?.length) return; + if (value.target !== NEW_LOGICAL_VOLUME || value.name?.length) return; return { id: "logicalVolumeName", @@ -255,7 +327,7 @@ function checkLogicalVolumeName(value: FormValue): Error | undefined { }; } -function checkSize(value: FormValue): Error | undefined { +function checkSizeError(value: FormValue): Error | undefined { if (value.sizeOption !== "custom") return; const min = value.minSize; @@ -268,7 +340,7 @@ function checkSize(value: FormValue): Error | undefined { }; } - const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$/; + const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])$/; const validMin = regexp.test(min); const validMax = max ? regexp.test(max) : true; @@ -285,7 +357,7 @@ function checkSize(value: FormValue): Error | undefined { if (validMin) { return { id: "customSize", - message: _("The maximum must be a number optionally followed by a unit like GiB or GB"), + message: _("The maximum must be a number followed by a unit like GiB or GB"), isVisible: true, }; } @@ -293,14 +365,14 @@ function checkSize(value: FormValue): Error | undefined { if (validMax) { return { id: "customSize", - message: _("The minimum must be a number optionally followed by a unit like GiB or GB"), + message: _("The minimum must be a number followed by a unit like GiB or GB"), isVisible: true, }; } return { id: "customSize", - message: _("Size limits must be numbers optionally followed by a unit like GiB or GB"), + message: _("Size limits must be numbers followed by a unit like GiB or GB"), isVisible: true, }; } @@ -308,7 +380,7 @@ function checkSize(value: FormValue): Error | undefined { function useErrors(value: FormValue): ErrorsHandler { const mountPointError = useMountPointError(value); const nameError = checkLogicalVolumeName(value); - const sizeError = checkSize(value); + const sizeError = checkSizeError(value); const errors = compact([mountPointError, nameError, sizeError]); const getError = (id: string): Error | undefined => errors.find((e) => e.id === id); @@ -321,24 +393,36 @@ function useErrors(value: FormValue): ErrorsHandler { return { errors, getError, getVisibleError }; } -function useSolvedModel(value: FormValue): ConfigModel.Config | null { - const { id: vgName, logicalVolumeId: mountPath } = useParams(); +function useSolvedConfig(value: FormValue): ConfigModel.Config | null { + const { id: index } = useParams(); + const volumeGroupConfig = useVolumeGroupConfig(); const config = useConfigModel(); - const { getError } = useErrors(value); - const mountPointError = getError("mountPoint"); - const data = toData(value); + const { errors } = useErrors(value); + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); + const logicalVolumeConfig = createLogicalVolumeConfig(value); + logicalVolumeConfig.size = undefined; // Avoid recalculating the solved model because changes in label. - if (data.filesystem) data.filesystem.label = undefined; + if (logicalVolumeConfig.filesystem) logicalVolumeConfig.filesystem.label = undefined; // Avoid recalculating the solved model because changes in name. - data.lvName = undefined; + logicalVolumeConfig.lvName = undefined; let sparseModel: ConfigModel.Config | undefined; - if (data.filesystem && !mountPointError) { - if (mountPath) { - sparseModel = configModel.logicalVolume.edit(config, vgName, mountPath, data); + if ( + volumeGroupConfig && + !errors.length && + value.target === NEW_LOGICAL_VOLUME && + value.filesystem !== NO_VALUE + ) { + if (initialLogicalVolumeConfig) { + sparseModel = configModel.logicalVolume.edit( + config, + Number(index), + initialLogicalVolumeConfig.mountPath, + logicalVolumeConfig, + ); } else { - sparseModel = configModel.logicalVolume.add(config, vgName, data); + sparseModel = configModel.logicalVolume.add(config, Number(index), logicalVolumeConfig); } } @@ -346,11 +430,17 @@ function useSolvedModel(value: FormValue): ConfigModel.Config | null { return solvedModel; } -function useSolvedLogicalVolume(value: FormValue): ConfigModel.LogicalVolume | undefined { - const { id: vgName } = useParams(); - const config = useSolvedModel(value); - const volumeGroup = config?.volumeGroups?.find((v) => v.vgName === vgName); - return volumeGroup?.logicalVolumes?.find((l) => l.mountPath === value.mountPoint); +function useSolvedLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume | undefined { + const volumeGroupConfig = useVolumeGroupConfig(); + const solvedConfig = useSolvedConfig(value); + if (!solvedConfig) return; + + const solvedVolumeGroupConfig = configModel.volumeGroup.findByName( + solvedConfig, + volumeGroupConfig.vgName, + ); + + return configModel.device.findVolumeByMountPath(solvedVolumeGroupConfig, value.mountPoint); } function useSolvedSizes(value: FormValue): SizeRange { @@ -362,45 +452,123 @@ function useSolvedSizes(value: FormValue): SizeRange { maxSize: NO_VALUE, }; - const logicalVolume = useSolvedLogicalVolume(valueWithoutSizes); + const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(valueWithoutSizes); - const solvedSizes = useMemo(() => { - const min = logicalVolume?.size?.min; - const max = logicalVolume?.size?.max; + const solvedSizes = React.useMemo(() => { + const min = solvedLogicalVolumeConfig?.size?.min; + const max = solvedLogicalVolumeConfig?.size?.max; return { min: min ? deviceSize(min) : NO_VALUE, max: max ? deviceSize(max) : NO_VALUE, }; - }, [logicalVolume]); + }, [solvedLogicalVolumeConfig]); return solvedSizes; } function useAutoRefreshFilesystem(handler, value: FormValue) { - const { mountPoint } = value; + const { mountPoint, target } = value; const defaultFilesystem = useDefaultFilesystem(mountPoint); + const usableFilesystems = useUsableFilesystems(mountPoint); + const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target); - useEffect(() => { + React.useEffect(() => { // Reset filesystem if there is no mount point yet. if (mountPoint === NO_VALUE) handler(NO_VALUE); // Select default filesystem for the mount point. - if (mountPoint !== NO_VALUE) handler(defaultFilesystem); - }, [handler, mountPoint, defaultFilesystem]); + if (mountPoint !== NO_VALUE && target === NEW_LOGICAL_VOLUME) handler(defaultFilesystem); + // Select default filesystem for the mount point if the logical volume has no filesystem. + if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && !logicalVolumeFilesystem) + handler(defaultFilesystem); + // Reuse the filesystem from the logical volume if possible. + if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && logicalVolumeFilesystem) { + const reuse = usableFilesystems.includes(logicalVolumeFilesystem); + handler(reuse ? REUSE_FILESYSTEM : defaultFilesystem); + } + }, [handler, mountPoint, target, defaultFilesystem, usableFilesystems, logicalVolumeFilesystem]); } function useAutoRefreshSize(handler, value: FormValue) { + const target = value.target; const solvedSizes = useSolvedSizes(value); - useEffect(() => { - handler("auto", solvedSizes.min, solvedSizes.max); - }, [handler, solvedSizes]); + React.useEffect(() => { + const sizeOption = target === NEW_LOGICAL_VOLUME ? "auto" : ""; + handler(sizeOption, solvedSizes.min, solvedSizes.max); + }, [handler, target, solvedSizes]); } function mountPointSelectOptions(mountPoints: string[]): SelectOptionProps[] { return mountPoints.map((p) => ({ value: p, children: p })); } +type TargetOptionLabelProps = { + value: string; +}; + +function TargetOptionLabel({ value }: TargetOptionLabelProps): React.ReactNode { + const device = useVolumeGroup(); + const logicalVolume = useLogicalVolume(value); + + if (value === NEW_LOGICAL_VOLUME) { + // TRANSLATORS: %s is a disk name with its size (eg. "sda, 10 GiB" + return sprintf(_("As a new logical volume on %s"), deviceLabel(device, true)); + } else { + return sprintf(_("Using logical volume %s"), deviceLabel(logicalVolume, true)); + } +} + +type LogicalVolumeDescriptionProps = { + logicalVolume: System.Device; +}; + +function LogicalVolumeDescription({ + logicalVolume, +}: LogicalVolumeDescriptionProps): React.ReactNode { + const label = logicalVolume.filesystem?.label; + + return ( + + {logicalVolume.description} + {label && ( + + + + )} + + ); +} + +function TargetOptions(): React.ReactNode { + const logicalVolumes = useUnusedLogicalVolumes(); + + return ( + + + + + + + {logicalVolumes.map((logicalVolume, index) => ( + } + > + {deviceLabel(logicalVolume)} + + ))} + {logicalVolumes.length === 0 && ( + {_("There are not usable logical volumes")} + )} + + + ); +} + type LogicalVolumeNameProps = { id?: string; value: FormValue; @@ -444,37 +612,58 @@ function LogicalVolumeName({ type FilesystemOptionLabelProps = { value: string; + target: string; volume: System.Volume; }; -function FilesystemOptionLabel({ value }: FilesystemOptionLabelProps): React.ReactNode { +function FilesystemOptionLabel({ value, target }: FilesystemOptionLabelProps): React.ReactNode { + const logicalVolume = useLogicalVolume(target); + const filesystem = logicalVolume?.filesystem?.type; + if (value === NO_VALUE) return _("Waiting for a mount point"); + // TRANSLATORS: %s is a filesystem type, like Btrfs + if (value === REUSE_FILESYSTEM && filesystem) + return sprintf(_("Current %s"), filesystemLabel(filesystem)); + return filesystemLabel(value); } type FilesystemOptionsProps = { mountPoint: string; + target: string; }; -function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { +function FilesystemOptions({ mountPoint, target }: FilesystemOptionsProps): React.ReactNode { + const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = useUsableFilesystems(mountPoint); - const volume = useVolumeTemplate(mountPoint); - - const defaultOptText = - mountPoint !== NO_VALUE && volume.mountPath - ? sprintf(_("Default file system for %s"), mountPoint) - : _("Default file system for generic logical volumes"); + const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target); + const canReuse = logicalVolumeFilesystem && usableFilesystems.includes(logicalVolumeFilesystem); - const formatText = _("Format logical volume as"); + const defaultOptText = volume.mountPath + ? sprintf(_("Default file system for %s"), mountPoint) + : _("Default file system for generic logical volume"); + const formatText = logicalVolumeFilesystem + ? _("Destroy current data and format logical volume as") + : _("Format logical volume as"); return ( {mountPoint === NO_VALUE && ( - + )} + {mountPoint !== NO_VALUE && canReuse && ( + + + + )} + {mountPoint !== NO_VALUE && canReuse && usableFilesystems.length && } {mountPoint !== NO_VALUE && ( {usableFilesystems.map((fsType, index) => ( @@ -483,7 +672,7 @@ function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactN value={fsType} description={fsType === defaultFilesystem && defaultOptText} > - + ))} @@ -496,6 +685,7 @@ type FilesystemSelectProps = { id?: string; value: string; mountPoint: string; + target: string; onChange: SelectProps["onChange"]; }; @@ -503,6 +693,7 @@ function FilesystemSelect({ id, value, mountPoint, + target, onChange, }: FilesystemSelectProps): React.ReactNode { const volume = useVolumeTemplate(mountPoint); @@ -512,11 +703,11 @@ function FilesystemSelect({ ); } @@ -546,46 +737,63 @@ type AutoSizeInfoProps = { function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { const volume = useVolumeTemplate(value.mountPoint); - const logicalVolume = useSolvedLogicalVolume(value); - const size = logicalVolume?.size; + const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(value); + const size = solvedLogicalVolumeConfig?.size; if (!size) return; return ( - + ); } -export default function LogicalVolumePage() { +const LogicalVolumeForm = () => { + const { id: index } = useParams(); const navigate = useNavigate(); - const headingId = useId(); - const { id: vgName } = useParams(); - const addLogicalVolume = useAddLogicalVolume(); - const editLogicalVolume = useEditLogicalVolume(); + const location = useLocation(); const [mountPoint, setMountPoint] = useState(NO_VALUE); const [name, setName] = useState(NO_VALUE); + const [target, setTarget] = useState(NEW_LOGICAL_VOLUME); const [filesystem, setFilesystem] = useState(NO_VALUE); const [filesystemLabel, setFilesystemLabel] = useState(NO_VALUE); const [sizeOption, setSizeOption] = useState(NO_VALUE); const [minSize, setMinSize] = useState(NO_VALUE); const [maxSize, setMaxSize] = useState(NO_VALUE); - // Filesystem and size selectors should not be auto refreshed before the user interacts with the - // mount point selector. + // Filesystem and size selectors should not be auto refreshed before the user interacts with other + // selectors like the mount point or the target selectors. const [autoRefreshFilesystem, setAutoRefreshFilesystem] = useState(false); const [autoRefreshSize, setAutoRefreshSize] = useState(false); const initialValue = useInitialFormValue(); - const value = { mountPoint, name, filesystem, filesystemLabel, sizeOption, minSize, maxSize }; + const value = { + mountPoint, + name, + target, + filesystem, + filesystemLabel, + sizeOption, + minSize, + maxSize, + }; const { errors, getVisibleError } = useErrors(value); + + const volumeGroupConfig = useVolumeGroupConfig(); + const volumeGroup = useVolumeGroup(); + const logicalVolume = useLogicalVolume(target); + const unusedMountPoints = useUnusedMountPoints(); + const addLogicalVolume = useAddLogicalVolume(); + const editLogicalVolume = useEditLogicalVolume(); + // Initializes the form values if there is an initial value (i.e., when editing a logical volume). React.useEffect(() => { if (initialValue) { setMountPoint(initialValue.mountPoint); setName(initialValue.name); + setTarget(initialValue.target); setFilesystem(initialValue.filesystem); setFilesystemLabel(initialValue.filesystemLabel); setSizeOption(initialValue.sizeOption); @@ -595,6 +803,7 @@ export default function LogicalVolumePage() { }, [ initialValue, setMountPoint, + setTarget, setFilesystem, setFilesystemLabel, setSizeOption, @@ -602,14 +811,14 @@ export default function LogicalVolumePage() { setMaxSize, ]); - const refreshFilesystemHandler = useCallback( + const refreshFilesystemHandler = React.useCallback( (filesystem: string) => autoRefreshFilesystem && setFilesystem(filesystem), [autoRefreshFilesystem, setFilesystem], ); useAutoRefreshFilesystem(refreshFilesystemHandler, value); - const refreshSizeHandler = useCallback( + const refreshSizeHandler = React.useCallback( (sizeOption: SizeOptionValue, minSize: string, maxSize: string) => { if (autoRefreshSize) { setSizeOption(sizeOption); @@ -627,10 +836,15 @@ export default function LogicalVolumePage() { setAutoRefreshFilesystem(true); setAutoRefreshSize(true); setMountPoint(value); - setName(configModel.logicalVolume.generateName(value)); } }; + const changeTarget = (value: string) => { + setAutoRefreshFilesystem(true); + setAutoRefreshSize(true); + setTarget(value); + }; + const changeFilesystem = (value: string) => { setAutoRefreshFilesystem(false); setAutoRefreshSize(false); @@ -649,18 +863,19 @@ export default function LogicalVolumePage() { }; const onSubmit = () => { - const data = toData(value); + const logicalVolumeConfig = createLogicalVolumeConfig(value); - if (initialValue) editLogicalVolume(vgName, initialValue.mountPoint, data); - else addLogicalVolume(vgName, data); + if (initialValue) + editLogicalVolume(Number(index), initialValue.mountPoint, logicalVolumeConfig); + else addLogicalVolume(Number(index), logicalVolumeConfig); - navigate(PATHS.root); + navigate({ pathname: PATHS.root, search: location.search }); }; const isFormValid = errors.length === 0; const mountPointError = getVisibleError("mountPoint"); const usedMountPt = mountPointError ? NO_VALUE : mountPoint; - const showLabel = filesystem !== NO_VALUE && usedMountPt !== NO_VALUE; + const showLabel = filesystem !== NO_VALUE && filesystem !== REUSE_FILESYSTEM; const sizeMode: SizeMode = sizeOption === "" ? "auto" : sizeOption; const sizeRange: SizeRange = { min: minSize, max: maxSize }; @@ -668,45 +883,58 @@ export default function LogicalVolumePage() { -
+ - + - - - - - - {!mountPointError && _("Select or enter a mount point")} - {mountPointError?.message} - - - - + + {volumeGroup && ( + + + + )} - - + + + + {!mountPointError && _("Select or enter a mount point")} + {mountPointError?.message} + + + + + {!logicalVolume && ( - - - - + )} + + + + + + + + {showLabel && ( - - + - {showLabel && ( - - - - - - )} - - - - + )} + + + {target === NEW_LOGICAL_VOLUME && ( {usedMountPt === NO_VALUE && (