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/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 22f335b966..aaa0a7e858 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -36,7 +36,7 @@ module Agama module DBus module Storage # D-Bus object to manage storage installation - class Manager < BaseObject + class Manager < BaseObject # rubocop:disable Metrics/ClassLength extend Yast::I18n include Yast::I18n include WithIssues @@ -384,15 +384,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 @@ -510,6 +511,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 c914a65c28..0568ec8592 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,29 +62,230 @@ 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.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 - # TRANSLATORS: %s is replaced by a device type (e.g., "drive"). - error(format(_("Mandatory %s not found"), device_type)) + 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 + + error( + format( + _( + # TRANSLATORS: %{parent_name} is replaced by a device name (e.g., "/dev/vda"), + # %{reused_type} is replaced by a device type (e.g., MD RAID) and %{reused_name} + # is replaced by a device name (e.g., "/dev/md0"). + "The device '%{parent_name}' cannot be formatted because it is part of the " \ + "%{reused_type} '%{reused_name}'" + ), + parent_name: parent_config.found_device.name, + reused_type: device_type, + reused_name: config.found_device.name + ), + kind: IssueClasses::Config::MISUSED_MEMBER_DEVICE + ) + 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? + + error( + format( + _( + # TRANSLATORS: %{member_name} is replaced by a device name (e.g., "/dev/vda"), + # %{reused_type} is replaced by a device type (e.g., MD RAID) and %{reused_name} + # is replaced by a device name (e.g., "/dev/md0"). + "The device '%{member_name}' cannot be deleted because it is part of the " \ + "%{reused_type} '%{reused_name}'" + ), + member_name: member_config.found_device.name, + reused_type: device_type, + reused_name: config.found_device.name + ), + kind: IssueClasses::Config::MISUSED_MEMBER_DEVICE + ) + 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_name} is replaced by a device name (e.g., "/dev/vda"), + # %{reused_type} is replaced by a device type (e.g., MD RAID) and %{reused_name} + # is replaced by a device name (e.g., "/dev/md0"). + "The device '%{member_name}' cannot be resized because it is part of the " \ + "%{reused_type} '%{reused_name}'" + ), + member_name: member_config.found_device.name, + reused_type: device_type, + reused_name: config.found_device.name + ), + kind: IssueClasses::Config::MISUSED_MEMBER_DEVICE + ) + 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_name} is replaced by a device name (e.g., "/dev/vda"), + # %{reused_type} is replaced by a device type (e.g., MD RAID) and %{reused_name} + # is replaced by a device name (e.g., "/dev/md0"). + "The device '%{member_name}' cannot be formatted because it is part of the " \ + "%{reused_type} '%{reused_name}'" + ), + member_name: member_config.found_device.name, + reused_type: device_type, + reused_name: config.found_device.name + ), + kind: IssueClasses::Config::MISUSED_MEMBER_DEVICE + ) + 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_name} is replaced by a device name (e.g., "/dev/vda"), + # %{reused_type} is replaced by a device type (e.g., MD RAID) and %{reused_name} + # is replaced by a device name (e.g., "/dev/md0"). + "The device '%{member_name}' cannot be partitioned because it is part of the " \ + "%{reused_type} '%{reused_name}'" + ), + member_name: member_config.found_device.name, + reused_type: device_type, + reused_name: config.found_device.name + ), + kind: IssueClasses::Config::MISUSED_MEMBER_DEVICE + ) + 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_name} is replaced by a device name (e.g., "/dev/vda"), + # %{reused_type} is replaced by a device type (e.g., MD RAID) and %{reused_name} + # is replaced by a device name (e.g., "/dev/md0"). + "The device '%{member_name}' cannot be used because it is part of the " \ + "%{reused_type} '%{reused_name}'" + ), + member_name: member_config.found_device.name, + reused_type: device_type, + reused_name: config.found_device.name + ), + kind: IssueClasses::Config::MISUSED_MEMBER_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 + + # 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] def device_type case config @@ -89,6 +297,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/to_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb index a40b756f8b..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 @@ -72,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/issue_classes.rb b/service/lib/agama/storage/issue_classes.rb index 87edb9a472..cace0ce697 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. # @@ -34,8 +34,8 @@ module Config # A device is used by several volume groups as a target for generating PVs OVERUSED_PV_TARGET = :configOverusedPvTarget - # 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 part of a reused RAID or LVM is chosen to be used with other purpose + MISUSED_MEMBER_DEVICE = :configMisusedMemberDevice # 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..06baea0356 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 "compounded" devices like MD RAIDs or volume groups, all the devices used for creating + # it has 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/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index c098c53b6c..0b29d24a7e 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 f4fa6fd328..9324bda02e 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,26 +60,360 @@ end context "and the device cannot be skipped" 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_MEMBER_DEVICE, + 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_MEMBER_DEVICE, + 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_MEMBER_DEVICE, + 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_MEMBER_DEVICE, + 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_MEMBER_DEVICE, + 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_MEMBER_DEVICE, + description: /.*vda.*cannot be formatted.*part of.* MD RAID .*md0/ + ) + end + end end + end - let(:disk) { instance_double(Y2Storage::Disk) } + 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_MEMBER_DEVICE, + description: /.*md0.*cannot be formatted.*part 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_MEMBER_DEVICE, + description: /.*md0.*cannot be partitioned.*part 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_MEMBER_DEVICE, + description: /.*md0.*cannot be used.*part 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_MEMBER_DEVICE, + description: /.*sda3.*cannot be deleted.*part 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_MEMBER_DEVICE, + description: /.*sda3.*cannot be resized.*part 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_MEMBER_DEVICE, + description: /.*sda.*cannot be formatted.*part of.* volume group .*data/ + ) + end + end + end + end + + 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/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/examples.rb b/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb index cd4b603dbf..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 @@ -239,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( @@ -276,6 +281,41 @@ ] ) 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 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 0fad2b3d27..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 @@ -141,11 +141,16 @@ 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( @@ -178,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_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/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index 9639ad8fc7..aa4a3a3c44 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. * @@ -46,7 +46,8 @@ type DeviceSelectorProps = { }; const size = (device: Storage.Device) => { - return deviceSize(device.block.size); + const bytes = device.volumeGroup?.size || device.block?.size || 0; + return deviceSize(bytes); }; const description = (device: Storage.Device) => { diff --git a/web/src/components/storage/NewVgMenuOption.tsx b/web/src/components/storage/NewVgMenuOption.tsx index c0486a430b..a3edd56dde 100644 --- a/web/src/components/storage/NewVgMenuOption.tsx +++ b/web/src/components/storage/NewVgMenuOption.tsx @@ -28,7 +28,7 @@ import { sprintf } from "sprintf-js"; import { _, n_, formatList } from "~/i18n"; import { useConfigModel, - useAddVolumeGroupFromPartitionable, + useConvertPartitionableToVolumeGroup, } from "~/hooks/model/storage/config-model"; import configModel from "~/model/storage/config-model"; import type { ConfigModel } from "~/model/storage/config-model"; @@ -37,7 +37,7 @@ export type NewVgMenuOptionProps = { device: ConfigModel.Drive | ConfigModel.MdR export default function NewVgMenuOption({ device }: NewVgMenuOptionProps): React.ReactNode { const config = useConfigModel(); - const convertToVg = useAddVolumeGroupFromPartitionable(); + const convertToVg = useConvertPartitionableToVolumeGroup(); if (device.filesystem) return; diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx index 97660e4910..71f118c020 100644 --- a/web/src/components/storage/SearchedDeviceMenu.tsx +++ b/web/src/components/storage/SearchedDeviceMenu.tsx @@ -26,8 +26,8 @@ import NewVgMenuOption from "./NewVgMenuOption"; import { useAvailableDevices } from "~/hooks/model/system/storage"; import { useConfigModel, - useAddDriveFromMdRaid, - useAddMdRaidFromDrive, + useConvertMdRaidToDrive, + useConvertDriveToMdRaid, } from "~/hooks/model/storage/config-model"; import { deviceBaseName, formattedPath } from "~/components/storage/utils"; import configModel from "~/model/storage/config-model"; @@ -324,8 +324,8 @@ export default function SearchedDeviceMenu({ deleteFn, }: SearchedDeviceMenuProps): React.ReactNode { const [isSelectorOpen, setIsSelectorOpen] = useState(false); - const switchToDrive = useAddDriveFromMdRaid(); - const switchToMdRaid = useAddMdRaidFromDrive(); + const switchToDrive = useConvertMdRaidToDrive(); + const switchToMdRaid = useConvertDriveToMdRaid(); const changeTargetFn = (device: Storage.Device) => { const hook = isDrive(device) ? switchToDrive : switchToMdRaid; hook(modelDevice.name, { name: device.name }); diff --git a/web/src/components/storage/utils/device.tsx b/web/src/components/storage/utils/device.tsx index b524b885a2..8456cf7424 100644 --- a/web/src/components/storage/utils/device.tsx +++ b/web/src/components/storage/utils/device.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2026] SUSE LLC * * All Rights Reserved. * @@ -24,6 +24,8 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { compact } from "~/utils"; import type { Storage as System } from "~/model/system"; +import { isEmpty } from "radashi"; +import { baseName } from "~/components/storage/utils"; const driveTypeDescription = (device: System.Device): string => { if (device.drive.type === "multipath") { @@ -61,12 +63,28 @@ const typeDescription = (device: System.Device): string | undefined => { } case "drive": { type = driveTypeDescription(device); + break; + } + case "volumeGroup": { + type = device.description; } } return type; }; +/** + * Description of the content of a LVM volume group. + */ +const volumeGroupContentDescription = (device: System.Device): string => { + if (isEmpty(device.logicalVolumes)) return _("No content found"); + + const lv_names = device.logicalVolumes.map((l) => baseName(l.name)); + if (lv_names.length === 1) return sprintf(_("Volume %s"), lv_names[0]); + + return sprintf(_("Volumes %s"), lv_names.join(", ")); +}; + /* * Description of the device. * @@ -74,6 +92,8 @@ const typeDescription = (device: System.Device): string | undefined => { * device.description (comes from YaST) to be way more granular */ const contentDescription = (device: System.Device): string => { + if (device.class === "volumeGroup") return volumeGroupContentDescription(device); + if (device.partitionTable) { const type = device.partitionTable.type.toUpperCase(); const numPartitions = device.partitions.length; @@ -103,6 +123,10 @@ const filesystemLabels = (device: System.Device): string[] => { return compact(device.partitions.map((p) => p.filesystem?.label)); } + if (device.class === "volumeGroup") { + return compact(device.logicalVolumes.map((l) => l.filesystem?.label)); + } + const label = device.filesystem?.label; return label ? [label] : []; }; diff --git a/web/src/hooks/model/storage/config-model.ts b/web/src/hooks/model/storage/config-model.ts index 70e26ff921..ae91c1520a 100644 --- a/web/src/hooks/model/storage/config-model.ts +++ b/web/src/hooks/model/storage/config-model.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -129,12 +129,12 @@ function useDeleteDrive(): DeleteDriveFn { }; } -type AddDriveFromMdRaidFn = (oldName: string, drive: Data.Drive) => void; +type ConvertDriveToMdRaidFn = (name: string, mdRaidData: Data.MdRaid) => void; -function useAddDriveFromMdRaid(): AddDriveFromMdRaidFn { +function useConvertDriveToMdRaid(): ConvertDriveToMdRaidFn { const config = useConfigModel(); - return (oldName: string, drive: Data.Drive) => { - putStorageModel(configModel.drive.addFromMdRaid(config, oldName, drive)); + return (driveName: string, mdRaidData: Data.MdRaid) => { + putStorageModel(configModel.partitionable.convertToMdRaid(config, driveName, mdRaidData)); }; } @@ -168,12 +168,12 @@ function useDeleteMdRaid(): DeleteMdRaidFn { }; } -type AddMdRaidFromDriveFn = (oldName: string, raid: Data.MdRaid) => void; +type ConvertMdRaidToDriveFn = (name: string, driveData: Data.Drive) => void; -function useAddMdRaidFromDrive(): AddMdRaidFromDriveFn { +function useConvertMdRaidToDrive(): ConvertMdRaidToDriveFn { const config = useConfigModel(); - return (oldName: string, raid: Data.MdRaid) => { - putStorageModel(configModel.mdRaid.addFromDrive(config, oldName, raid)); + return (name: string, driveData: Data.Drive) => { + putStorageModel(configModel.partitionable.convertToDrive(config, name, driveData)); }; } @@ -214,12 +214,12 @@ function useDeleteVolumeGroup(): DeleteVolumeGroupFn { }; } -type AddVolumeGroupFromPartitionableFn = (driveName: string) => void; +type ConvertPartitionableToVolumeGroupFn = (name: string) => void; -function useAddVolumeGroupFromPartitionable(): AddVolumeGroupFromPartitionableFn { +function useConvertPartitionableToVolumeGroup(): ConvertPartitionableToVolumeGroupFn { const config = useConfigModel(); - return (driveName: string) => { - putStorageModel(configModel.volumeGroup.addFromPartitionable(config, driveName)); + return (name: string) => { + putStorageModel(configModel.partitionable.convertToVolumeGroup(config, name)); }; } @@ -339,16 +339,16 @@ export { useDrive, useAddDrive, useDeleteDrive, - useAddDriveFromMdRaid, + useConvertMdRaidToDrive, useMdRaid, useAddMdRaid, useDeleteMdRaid, - useAddMdRaidFromDrive, + useConvertDriveToMdRaid, useVolumeGroup, useAddVolumeGroup, useEditVolumeGroup, useDeleteVolumeGroup, - useAddVolumeGroupFromPartitionable, + useConvertPartitionableToVolumeGroup, useAddLogicalVolume, useEditLogicalVolume, useDeleteLogicalVolume, diff --git a/web/src/hooks/model/system/storage.ts b/web/src/hooks/model/system/storage.ts index 294585ef0c..3582ce86c2 100644 --- a/web/src/hooks/model/system/storage.ts +++ b/web/src/hooks/model/system/storage.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -52,6 +52,7 @@ const enum DeviceGroup { CandidateDrives = "candidateDrives", AvailableMdRaids = "availableMdRaids", CandidateMdRaids = "candidateMdRaids", + AvailableVolumeGroups = "availableVolumeGroups", } function selectDeviceGroups(data: System | null, groups: DeviceGroup[]): Storage.Device[] { @@ -117,7 +118,11 @@ function useCandidateMdRaids(): Storage.Device[] { } const selectAvailableDevices = (data: System | null): Storage.Device[] => - selectDeviceGroups(data, [DeviceGroup.AvailableDrives, DeviceGroup.AvailableMdRaids]); + selectDeviceGroups(data, [ + DeviceGroup.AvailableDrives, + DeviceGroup.AvailableMdRaids, + DeviceGroup.AvailableVolumeGroups, + ]); /** * Hook that returns the list of available devices for installation. diff --git a/web/src/model/storage/config-model/drive.ts b/web/src/model/storage/config-model/drive.ts index 68d3b22dd7..2b3b3f4e2a 100644 --- a/web/src/model/storage/config-model/drive.ts +++ b/web/src/model/storage/config-model/drive.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -35,16 +35,8 @@ function add(config: ConfigModel.Config, data: Data.Drive): ConfigModel.Config { return config; } -function addFromMdRaid( - config: ConfigModel.Config, - oldName: string, - drive: Data.Drive, -): ConfigModel.Config { - return configModel.partitionable.convert(config, oldName, drive.name, "drives"); -} - function remove(config: ConfigModel.Config, index: number): ConfigModel.Config { return configModel.partitionable.remove(config, "drives", index); } -export default { find, add, remove, addFromMdRaid }; +export default { find, add, remove }; diff --git a/web/src/model/storage/config-model/md-raid.ts b/web/src/model/storage/config-model/md-raid.ts index d60a997d46..afc2e573de 100644 --- a/web/src/model/storage/config-model/md-raid.ts +++ b/web/src/model/storage/config-model/md-raid.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -35,16 +35,8 @@ function add(config: ConfigModel.Config, data: Data.MdRaid): ConfigModel.Config return config; } -function addFromDrive( - config: ConfigModel.Config, - oldName: string, - raid: Data.MdRaid, -): ConfigModel.Config { - return configModel.partitionable.convert(config, oldName, raid.name, "mdRaids"); -} - function remove(config: ConfigModel.Config, index: number): ConfigModel.Config { return configModel.partitionable.remove(config, "mdRaids", index); } -export default { find, add, addFromDrive, remove }; +export default { find, add, remove }; diff --git a/web/src/model/storage/config-model/partitionable.ts b/web/src/model/storage/config-model/partitionable.ts index 6d34825371..12bdb1793d 100644 --- a/web/src/model/storage/config-model/partitionable.ts +++ b/web/src/model/storage/config-model/partitionable.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -192,6 +192,55 @@ function convert( return config; } +function convertToDrive( + config: ConfigModel.Config, + name: string, + driveData: Data.Drive, +): ConfigModel.Config { + return convert(config, name, driveData.name, "drives"); +} + +function convertToMdRaid( + config: ConfigModel.Config, + name: string, + mdRaidData: Data.MdRaid, +): ConfigModel.Config { + return convert(config, name, mdRaidData.name, "mdRaids"); +} + +function convertPartitionsToLogicalVolumes( + device: ConfigModel.Drive | ConfigModel.MdRaid, + volumeGroup: ConfigModel.VolumeGroup, +) { + if (!device.partitions) return; + + const newPartitions = device.partitions.filter((p) => !p.name); + const reusedPartitions = device.partitions.filter((p) => p.name); + device.partitions = [...reusedPartitions]; + const logicalVolumes = volumeGroup.logicalVolumes || []; + volumeGroup.logicalVolumes = [ + ...logicalVolumes, + ...newPartitions.map(configModel.logicalVolume.createFromPartition), + ]; +} + +function convertToVolumeGroup(config: ConfigModel.Config, devName: string): ConfigModel.Config { + config = configModel.clone(config); + + const device = all(config).find((d) => d.name === devName); + if (!device) return config; + + const volumeGroup = configModel.volumeGroup.create({ + vgName: configModel.volumeGroup.generateName(config), + targetDevices: [devName], + }); + convertPartitionsToLogicalVolumes(device, volumeGroup); + config.volumeGroups ||= []; + config.volumeGroups.push(volumeGroup); + + return config; +} + function setActions(device: ConfigModel.Drive, actions: Data.SpacePolicyAction[]) { device.partitions ||= []; @@ -272,7 +321,10 @@ export default { isReusingPartitions, remove, removeIfUnused, - convert, + convertToDrive, + convertToMdRaid, + convertPartitionsToLogicalVolumes, + convertToVolumeGroup, setSpacePolicy, setFilesystem, }; diff --git a/web/src/model/storage/config-model/volume-group.ts b/web/src/model/storage/config-model/volume-group.ts index 44eb08a45c..b13e13847b 100644 --- a/web/src/model/storage/config-model/volume-group.ts +++ b/web/src/model/storage/config-model/volume-group.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -24,22 +24,6 @@ import { sift } from "radashi"; import configModel from "~/model/storage/config-model"; import type { ConfigModel, Data } from "~/model/storage/config-model"; -function movePartitions( - device: ConfigModel.Drive | ConfigModel.MdRaid, - volumeGroup: ConfigModel.VolumeGroup, -) { - if (!device.partitions) return; - - const newPartitions = device.partitions.filter((p) => !p.name); - const reusedPartitions = device.partitions.filter((p) => p.name); - device.partitions = [...reusedPartitions]; - const logicalVolumes = volumeGroup.logicalVolumes || []; - volumeGroup.logicalVolumes = [ - ...logicalVolumes, - ...newPartitions.map(configModel.logicalVolume.createFromPartition), - ]; -} - function adjustSpacePolicies(config: ConfigModel.Config, targets: string[]) { const devices = configModel.partitionable.all(config); devices @@ -92,7 +76,7 @@ function add( configModel.partitionable .all(config) .filter((d) => data.targetDevices.includes(d.name)) - .forEach((d) => movePartitions(d, volumeGroup)); + .forEach((d) => configModel.partitionable.convertPartitionsToLogicalVolumes(d, volumeGroup)); } config.volumeGroups ||= []; @@ -153,23 +137,6 @@ function generateName(config: ConfigModel.Config): string { return `system${Math.max(...numbers) + 1}`; } -function addFromPartitionable(config: ConfigModel.Config, devName: string): ConfigModel.Config { - config = configModel.clone(config); - - const device = configModel.partitionable.all(config).find((d) => d.name === devName); - if (!device) return config; - - const volumeGroup = create({ - vgName: generateName(config), - targetDevices: [devName], - }); - movePartitions(device, volumeGroup); - config.volumeGroups ||= []; - config.volumeGroups.push(volumeGroup); - - return config; -} - function convertToPartitionable(config: ConfigModel.Config, vgName: string): ConfigModel.Config { config = configModel.clone(config); @@ -194,6 +161,7 @@ function convertToPartitionable(config: ConfigModel.Config, vgName: string): Con } export default { + generateName, create, usedMountPaths, findIndex, @@ -201,6 +169,5 @@ export default { add, edit, remove, - addFromPartitionable, convertToPartitionable, }; diff --git a/web/src/model/storage/device.ts b/web/src/model/storage/device.ts index 6925273503..27dc9ed0d2 100644 --- a/web/src/model/storage/device.ts +++ b/web/src/model/storage/device.ts @@ -22,6 +22,7 @@ import type { Storage as System } from "~/model/system"; import type { Storage as Proposal } from "~/model/proposal"; +import { flat, sift } from "radashi"; type Device = System.Device | Proposal.Device; @@ -46,6 +47,9 @@ function isLogicalVolume(device: Device): boolean { } function deviceSystems(device: Device): string[] { + if (device.class === "volumeGroup") + return sift(flat(device.logicalVolumes.map((l) => l.block.systems))); + return device.block?.systems || []; } diff --git a/web/src/openapi/config/storage.ts b/web/src/openapi/config/storage.ts index e20ca9b81e..3f3402c22c 100644 --- a/web/src/openapi/config/storage.ts +++ b/web/src/openapi/config/storage.ts @@ -131,6 +131,15 @@ export type DeletePartitionSearch = SearchAll | SearchName | DeletePartitionAdva * Device base name. */ export type BaseName = string; +export type VolumeGroupSearch = SearchAll | SearchName | VolumeGroupAdvancedSearch; +export type VolumeGroupSearchCondition = SearchConditionName | SearchConditionSize; +export type VolumeGroupSearchSort = + | VolumeGroupSearchSortCriterion + | VolumeGroupSearchSortCriterion[]; +export type VolumeGroupSearchSortCriterion = + | VolumeGroupSearchSortCriterionShort + | VolumeGroupSearchSortCriterionFull; +export type VolumeGroupSearchSortCriterionShort = "name" | "size"; export type PhysicalVolumeElement = | Alias | SimplePhysicalVolumesGenerator @@ -140,11 +149,23 @@ export type LogicalVolumeElement = | AdvancedLogicalVolumesGenerator | LogicalVolume | ThinPoolLogicalVolume - | ThinLogicalVolume; + | ThinLogicalVolume + | LogicalVolumeToDelete + | LogicalVolumeToDeleteIfNeeded; /** * Number of stripes. */ export type LogicalVolumeStripes = number; +export type LogicalVolumeSearch = SearchAll | SearchName | LogicalVolumeAdvancedSearch; +export type LogicalVolumeSearchCondition = SearchConditionName | SearchConditionSize; +export type LogicalVolumeSearchSort = + | LogicalVolumeSearchSortCriterion + | LogicalVolumeSearchSortCriterion[]; +export type LogicalVolumeSearchSortCriterion = + | LogicalVolumeSearchSortCriterionShort + | LogicalVolumeSearchSortCriterionFull; +export type LogicalVolumeSearchSortCriterionShort = "name" | "size" | "number"; +export type DeleteLogicalVolumeSearch = SearchAll | SearchName | DeleteLogicalVolumeAdvancedSearch; export type MdRaidElement = NonPartitionedMdRaid | PartitionedMdRaid; export type MdRaidSearch = SearchAll | SearchName | MdRaidAdvancedSearch; export type MdRaidSearchCondition = SearchConditionName | SearchConditionSize; @@ -392,6 +413,7 @@ export interface PartitionToDeleteIfNeeded { */ export interface VolumeGroup { name: BaseName; + search?: VolumeGroupSearch; extentSize?: SizeValue; /** * Devices to use as physical volumes. @@ -399,6 +421,16 @@ export interface VolumeGroup { physicalVolumes?: PhysicalVolumeElement[]; logicalVolumes?: LogicalVolumeElement[]; } +export interface VolumeGroupAdvancedSearch { + condition?: VolumeGroupSearchCondition; + sort?: VolumeGroupSearchSort; + max?: SearchMax; + ifNotFound?: SearchCreatableActions; +} +export interface VolumeGroupSearchSortCriterionFull { + name?: SearchSortCriterionOrder; + size?: SearchSortCriterionOrder; +} /** * Automatically creates the needed physical volumes in the indicated devices. */ @@ -427,12 +459,23 @@ export interface AdvancedLogicalVolumesGenerator { } export interface LogicalVolume { name?: BaseName; + search?: LogicalVolumeSearch; size?: Size; stripes?: LogicalVolumeStripes; stripeSize?: SizeValue; encryption?: Encryption; filesystem?: Filesystem; } +export interface LogicalVolumeAdvancedSearch { + condition?: LogicalVolumeSearchCondition; + sort?: LogicalVolumeSearchSort; + max?: SearchMax; + ifNotFound?: SearchCreatableActions; +} +export interface LogicalVolumeSearchSortCriterionFull { + name?: SearchSortCriterionOrder; + size?: SearchSortCriterionOrder; +} export interface ThinPoolLogicalVolume { /** * LVM thin pool. @@ -452,6 +495,27 @@ export interface ThinLogicalVolume { encryption?: Encryption; filesystem?: Filesystem; } +export interface LogicalVolumeToDelete { + search: DeleteLogicalVolumeSearch; + /** + * Delete the logical volume. + */ + delete: true; +} +export interface DeleteLogicalVolumeAdvancedSearch { + condition?: LogicalVolumeSearchCondition; + sort?: LogicalVolumeSearchSort; + max?: SearchMax; + ifNotFound?: SearchActions; +} +export interface LogicalVolumeToDeleteIfNeeded { + search: DeleteLogicalVolumeSearch; + /** + * Delete the logical volume if needed to make space. + */ + deleteIfNeeded: true; + size?: Size; +} /** * MD RAID without a partition table (e.g., directly formatted). */ diff --git a/web/src/openapi/storage/config-model.ts b/web/src/openapi/storage/config-model.ts index 2fa306bdb1..bbdb61e9af 100644 --- a/web/src/openapi/storage/config-model.ts +++ b/web/src/openapi/storage/config-model.ts @@ -89,16 +89,23 @@ export interface MdRaid { partitions?: Partition[]; } export interface VolumeGroup { + name?: string; vgName: string; extentSize?: number; targetDevices?: string[]; + spacePolicy?: SpacePolicy; logicalVolumes?: LogicalVolume[]; } export interface LogicalVolume { + name?: string; lvName?: string; mountPath?: string; filesystem?: Filesystem; - size?: Size; stripes?: number; stripeSize?: number; + size?: Size; + delete?: boolean; + deleteIfNeeded?: boolean; + resize?: boolean; + resizeIfNeeded?: boolean; } diff --git a/web/src/openapi/system/storage.ts b/web/src/openapi/system/storage.ts index 9058335e00..b8f7aa02ab 100644 --- a/web/src/openapi/system/storage.ts +++ b/web/src/openapi/system/storage.ts @@ -45,6 +45,10 @@ export interface System { * SIDs of the available MD RAIDs */ availableMdRaids?: number[]; + /** + * SIDs of the available LVM volume groups + */ + availableVolumeGroups?: number[]; /** * SIDs of the drives that are candidate for installation */