diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json
index fb598c9f44..bd0046e355 100644
--- a/rust/agama-lib/share/storage.model.schema.json
+++ b/rust/agama-lib/share/storage.model.schema.json
@@ -94,12 +94,14 @@
"additionalProperties": false,
"required": ["vgName"],
"properties": {
+ "name": { "type": "string" },
"vgName": { "type": "string" },
"extentSize": { "type": "integer" },
"targetDevices": {
"type": "array",
"items": { "type": "string" }
},
+ "spacePolicy": { "$ref": "#/$defs/spacePolicy" },
"logicalVolumes": {
"type": "array",
"items": { "$ref": "#/$defs/logicalVolume" }
@@ -110,12 +112,17 @@
"type": "object",
"additionalProperties": false,
"properties": {
+ "name": { "type": "string" },
"lvName": { "type": "string" },
"mountPath": { "type": "string" },
"filesystem": { "$ref": "#/$defs/filesystem" },
- "size": { "$ref": "#/$defs/size" },
"stripes": { "type": "integer" },
- "stripeSize": { "type": "integer" }
+ "stripeSize": { "type": "integer" },
+ "size": { "$ref": "#/$defs/size" },
+ "delete": { "type": "boolean" },
+ "deleteIfNeeded": { "type": "boolean" },
+ "resize": { "type": "boolean" },
+ "resizeIfNeeded": { "type": "boolean" }
}
},
"spacePolicy": {
diff --git a/rust/agama-lib/share/storage.schema.json b/rust/agama-lib/share/storage.schema.json
index a95fa83dbb..9786b6dbdd 100644
--- a/rust/agama-lib/share/storage.schema.json
+++ b/rust/agama-lib/share/storage.schema.json
@@ -300,7 +300,9 @@
{ "$ref": "#/$defs/advancedLogicalVolumesGenerator" },
{ "$ref": "#/$defs/logicalVolume" },
{ "$ref": "#/$defs/thinPoolLogicalVolume" },
- { "$ref": "#/$defs/thinLogicalVolume" }
+ { "$ref": "#/$defs/thinLogicalVolume" },
+ { "$ref": "#/$defs/logicalVolumeToDelete" },
+ { "$ref": "#/$defs/logicalVolumeToDeleteIfNeeded" }
]
},
"advancedLogicalVolumesGenerator": {
@@ -366,6 +368,31 @@
"filesystem": { "$ref": "#/$defs/filesystem" }
}
},
+ "logicalVolumeToDelete": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["delete", "search"],
+ "properties": {
+ "search": { "$ref": "#/$defs/deleteLogicalVolumeSearch" },
+ "delete": {
+ "description": "Delete the logical volume.",
+ "const": true
+ }
+ }
+ },
+ "logicalVolumeToDeleteIfNeeded": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["deleteIfNeeded", "search"],
+ "properties": {
+ "search": { "$ref": "#/$defs/deleteLogicalVolumeSearch" },
+ "deleteIfNeeded": {
+ "description": "Delete the logical volume if needed to make space.",
+ "const": true
+ },
+ "size": { "$ref": "#/$defs/size" }
+ }
+ },
"logicalVolumeStripes": {
"description": "Number of stripes.",
"type": "integer",
@@ -587,6 +614,23 @@
"ifNotFound": { "$ref": "#/$defs/searchCreatableActions" }
}
},
+ "deleteLogicalVolumeSearch": {
+ "anyOf": [
+ { "$ref": "#/$defs/searchAll" },
+ { "$ref": "#/$defs/searchName" },
+ { "$ref": "#/$defs/deleteLogicalVolumeAdvancedSearch" }
+ ]
+ },
+ "deleteLogicalVolumeAdvancedSearch": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "condition": { "$ref": "#/$defs/logicalVolumeSearchCondition" },
+ "sort": { "$ref": "#/$defs/logicalVolumeSearchSort" },
+ "max": { "$ref": "#/$defs/searchMax" },
+ "ifNotFound": { "$ref": "#/$defs/searchActions" }
+ }
+ },
"logicalVolumeSearchCondition": {
"anyOf": [
{ "$ref": "#/$defs/searchConditionName" },
diff --git a/rust/package/agama.changes b/rust/package/agama.changes
index 98ab2ad486..8e5ad56be2 100644
--- a/rust/package/agama.changes
+++ b/rust/package/agama.changes
@@ -1,3 +1,8 @@
+-------------------------------------------------------------------
+Mon Apr 13 15:21:17 UTC 2026 - José Iván López González
+
+- Update storage schemas (gh#agama-project/agama#3380).
+
-------------------------------------------------------------------
Fri Apr 10 13:46:02 UTC 2026 - Ladislav Slezák
diff --git a/rust/share/system.storage.schema.json b/rust/share/system.storage.schema.json
index 8b74e16fed..975970c9f1 100644
--- a/rust/share/system.storage.schema.json
+++ b/rust/share/system.storage.schema.json
@@ -21,6 +21,11 @@
"type": "array",
"items": { "type": "integer" }
},
+ "availableVolumeGroups": {
+ "description": "SIDs of the available LVM volume groups",
+ "type": "array",
+ "items": { "type": "integer" }
+ },
"candidateDrives": {
"description": "SIDs of the drives that are candidate for installation",
"type": "array",
diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb
index 584771f79a..b8980ae16e 100644
--- a/service/lib/agama/dbus/storage/manager.rb
+++ b/service/lib/agama/dbus/storage/manager.rb
@@ -399,15 +399,16 @@ def serialize_system
return serialize_nil unless manager.probed?
json = {
- devices: devices_json(:probed),
- availableDrives: available_drives,
- availableMdRaids: available_md_raids,
- candidateDrives: candidate_drives,
- candidateMdRaids: candidate_md_raids,
- issues: system_issues_json,
- productMountPoints: product_mount_points,
- encryptionMethods: encryption_methods,
- volumeTemplates: volume_templates
+ devices: devices_json(:probed),
+ availableDrives: available_drives,
+ availableMdRaids: available_md_raids,
+ availableVolumeGroups: available_volume_groups,
+ candidateDrives: candidate_drives,
+ candidateMdRaids: candidate_md_raids,
+ issues: system_issues_json,
+ productMountPoints: product_mount_points,
+ encryptionMethods: encryption_methods,
+ volumeTemplates: volume_templates
}
JSON.pretty_generate(json)
end
@@ -525,6 +526,12 @@ def candidate_md_raids
proposal.storage_system.candidate_md_raids.map(&:sid)
end
+ # @see Storage::System#available_volume_groups
+ # @return [Array]
+ def available_volume_groups
+ proposal.storage_system.available_volume_groups.map(&:sid)
+ end
+
# Meaningful mount points for the current product.
#
# @return [Array]
diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb
index a36456f286..3a51adbbfb 100644
--- a/service/lib/agama/storage/config.rb
+++ b/service/lib/agama/storage/config.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -112,6 +112,11 @@ def partitionable(device_alias)
supporting_partitions.find { |d| d.alias?(device_alias) }
end
+ # @return [Array]
+ def volumes
+ partitions + logical_volumes
+ end
+
# @return [Array]
def partitions
supporting_partitions.flat_map(&:partitions)
@@ -131,7 +136,7 @@ def filesystems
#
# @return [Array<#search>]
def supporting_search
- drives + md_raids + partitions
+ drives + md_raids + partitions + volume_groups + logical_volumes
end
# Configs with configurable encryption.
@@ -166,7 +171,7 @@ def supporting_partitions
#
# @return [#delete?]
def supporting_delete
- partitions
+ partitions + logical_volumes
end
# Config objects that could act as physical volume
@@ -223,6 +228,20 @@ def valid_partitions
partitions.reject { |p| skipped?(p) }
end
+ # Volume group configs, excluding skipped ones.
+ #
+ # @return [Array]
+ def valid_volume_groups
+ volume_groups.reject { |d| skipped?(d) }
+ end
+
+ # Logical volume configs, excluding skipped ones.
+ #
+ # @return [Array]
+ def valid_logical_volumes
+ logical_volumes.reject { |d| skipped?(d) }
+ end
+
# Configs directly using a device with the given alias.
#
# @note Devices using the given alias as a target device (e.g., for creating physical volumes)
@@ -242,6 +261,14 @@ def target_users(device_alias)
[boot_target_user(device_alias), vg_target_users(device_alias)].flatten.compact
end
+ # Finds the config assigned to the given device.
+ #
+ # @param device [Y2Storage::BlkDevice]
+ # @return [#search]
+ def find_device(device)
+ supporting_search.find { |c| c.found_device == device }
+ end
+
private
# MD RAIDs using the given alias as member device.
diff --git a/service/lib/agama/storage/config_checkers/logical_volume.rb b/service/lib/agama/storage/config_checkers/logical_volume.rb
index accc0a4411..c6cf98190d 100644
--- a/service/lib/agama/storage/config_checkers/logical_volume.rb
+++ b/service/lib/agama/storage/config_checkers/logical_volume.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,8 +20,10 @@
# find current contact information at www.suse.com.
require "agama/storage/config_checkers/base"
+require "agama/storage/config_checkers/with_alias"
require "agama/storage/config_checkers/with_encryption"
require "agama/storage/config_checkers/with_filesystem"
+require "agama/storage/config_checkers/with_search"
require "yast/i18n"
module Agama
@@ -30,18 +32,22 @@ module ConfigCheckers
# Class for checking a logical volume config.
class LogicalVolume < Base
include Yast::I18n
+ include WithAlias
include WithEncryption
include WithFilesystem
+ include WithSearch
# @param config [Configs::LogicalVolume]
# @param volume_group_config [Configs::VolumeGroup]
+ # @param storage_config [Storage::Config]
# @param product_config [Agama::Config]
- def initialize(config, volume_group_config, product_config)
+ def initialize(config, volume_group_config, storage_config, product_config)
super()
textdomain "agama"
@config = config
@volume_group_config = volume_group_config
+ @storage_config = storage_config
@product_config = product_config
end
@@ -50,6 +56,8 @@ def initialize(config, volume_group_config, product_config)
# @return [Array]
def issues
[
+ alias_issues,
+ search_issues,
filesystem_issues,
encryption_issues,
missing_thin_pool_issue
@@ -64,6 +72,9 @@ def issues
# @return [Configs::VolumeGroup]
attr_reader :volume_group_config
+ # @return [Storage::Config]
+ attr_reader :storage_config
+
# @return [Agama::Config]
attr_reader :product_config
diff --git a/service/lib/agama/storage/config_checkers/md_raid.rb b/service/lib/agama/storage/config_checkers/md_raid.rb
index 6988d88463..544eafa84e 100644
--- a/service/lib/agama/storage/config_checkers/md_raid.rb
+++ b/service/lib/agama/storage/config_checkers/md_raid.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -63,8 +63,7 @@ def issues
partitions_issues,
devices_issues,
level_issue,
- devices_size_issue,
- reused_member_issues
+ devices_size_issue
].flatten.compact
end
@@ -133,188 +132,6 @@ def used_devices
storage_config.potential_for_md_device
.select { |d| config.devices.include?(d.alias) }
end
-
- # Issues from the member devices of a reused MD RAID.
- #
- # @return [Array]
- def reused_member_issues
- return [] unless config.found_device
-
- config.found_device.devices.map { |d| reused_member_issue(d) }
- end
-
- # Issue from the member devices of a reused MD RAID.
- #
- # @param device [Y2Storage::BlkDevice]
- # @return [Issue, nil]
- def reused_member_issue(device)
- member_config = find_config(device)
- return parent_reused_member_issue(device) unless member_config
-
- deleted_reused_member_issue(member_config) ||
- resized_reused_member_issue(member_config) ||
- formatted_reused_member_issue(member_config) ||
- partitioned_reused_member_issue(member_config) ||
- target_reused_member_issue(member_config)
- end
-
- # Issue if the device member is deleted.
- #
- # @param member_config [#search]
- # @return [Issue, nil]
- def deleted_reused_member_issue(member_config)
- return unless storage_config.supporting_delete.include?(member_config)
- return unless member_config.delete? || member_config.delete_if_needed?
-
- error(
- format(
- _(
- # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and
- # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0").
- "The device '%{member}' cannot be deleted because it is part of the MD RAID " \
- "%{md_raid}"
- ),
- member: member_config.found_device.name,
- md_raid: config.found_device.name
- ),
- kind: IssueClasses::Config::MISUSED_MD_MEMBER
- )
- end
-
- # Issue if the device member is resized.
- #
- # @param member_config [#search]
- # @return [Issue, nil]
- def resized_reused_member_issue(member_config)
- return unless storage_config.supporting_size.include?(member_config)
- return if member_config.size.default?
-
- error(
- format(
- _(
- # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and
- # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0").
- "The device '%{member}' cannot be resized because it is part of the MD RAID " \
- "%{md_raid}"
- ),
- member: member_config.found_device.name,
- md_raid: config.found_device.name
- ),
- kind: IssueClasses::Config::MISUSED_MD_MEMBER
- )
- end
-
- # Issue if the device member is formatted.
- #
- # @param member_config [#search]
- # @return [Issue, nil]
- def formatted_reused_member_issue(member_config)
- return unless storage_config.supporting_filesystem.include?(member_config)
- return unless member_config.filesystem
-
- error(
- format(
- _(
- # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and
- # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0").
- "The device '%{member}' cannot be formatted because it is part of the MD RAID " \
- "%{md_raid}"
- ),
- member: member_config.found_device.name,
- md_raid: config.found_device.name
- ),
- kind: IssueClasses::Config::MISUSED_MD_MEMBER
- )
- end
-
- # Issue if the device member is partitioned.
- #
- # @param member_config [#search]
- # @return [Issue, nil]
- def partitioned_reused_member_issue(member_config)
- return unless storage_config.supporting_partitions.include?(member_config)
- return unless member_config.partitions?
-
- error(
- format(
- _(
- # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and
- # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0").
- "The device '%{member}' cannot be partitioned because it is part of the MD RAID " \
- "%{md_raid}"
- ),
- member: member_config.found_device.name,
- md_raid: config.found_device.name
- ),
- kind: IssueClasses::Config::MISUSED_MD_MEMBER
- )
- end
-
- # Issue if the device member is used by other device (e.g., as target for physical volumes).
- #
- # @param member_config [#search]
- # @return [Issue, nil]
- def target_reused_member_issue(member_config)
- return unless users?(member_config)
-
- error(
- format(
- _(
- # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and
- # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0").
- "The device '%{member}' cannot be used because it is part of the MD RAID " \
- "%{md_raid}"
- ),
- member: member_config.found_device.name,
- md_raid: config.found_device.name
- ),
- kind: IssueClasses::Config::MISUSED_MD_MEMBER
- )
- end
-
- # Issue if the parent of the device member is formatted.
- #
- # @param device [Y2Storage::BlkDevice]
- # @return [Issue, nil]
- def parent_reused_member_issue(device)
- return unless device.respond_to?(:partitionable)
-
- parent_config = find_config(device.partitionable)
- return unless parent_config&.filesystem
-
- error(
- format(
- _(
- # TRANSLATORS: %{device} is replaced by a device name (e.g., "/dev/vda") and
- # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0").
- "The device '%{device}' cannot be formatted because it is part of the MD RAID " \
- "%{md_raid}"
- ),
- device: parent_config.found_device.name,
- md_raid: config.found_device.name
- ),
- kind: IssueClasses::Config::MISUSED_MD_MEMBER
- )
- end
-
- # Finds the config assigned to the given device.
- #
- # @param device [Y2Storage::BlkDevice]
- # @return [#search]
- def find_config(device)
- storage_config.supporting_search.find { |c| c.found_device == device }
- end
-
- # Whether the given config has any user (direct user or as target).
- #
- # @param config [#search]
- # @return [Boolean]
- def users?(config)
- return false unless config.alias
-
- storage_config.users(config.alias).any? ||
- storage_config.target_users(config.alias).any?
- end
end
end
end
diff --git a/service/lib/agama/storage/config_checkers/search.rb b/service/lib/agama/storage/config_checkers/search.rb
index 9ddeaaa302..cfdc97ff4b 100644
--- a/service/lib/agama/storage/config_checkers/search.rb
+++ b/service/lib/agama/storage/config_checkers/search.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -24,6 +24,8 @@
require "agama/storage/configs/logical_volume"
require "agama/storage/configs/md_raid"
require "agama/storage/configs/partition"
+require "agama/storage/configs/volume_group"
+require "agama/storage/issue_classes"
require "yast/i18n"
module Agama
@@ -34,20 +36,25 @@ class Search < Base
include Yast::I18n
# @param config [#search]
- def initialize(config)
+ # @param storage_config [Storage::Config]
+ def initialize(config, storage_config)
super()
textdomain "agama"
@config = config
+ @storage_config = storage_config
end
# Search config issues.
#
# @return [Array]
def issues
- return [] unless search
+ return [] unless config.search
- [not_found_issue].compact
+ [
+ not_found_issue,
+ reused_issues
+ ].flatten.compact
end
private
@@ -55,27 +62,318 @@ def issues
# @return [#search]
attr_reader :config
- # @return [Configs::Search, nil]
- def search
- config.search
- end
-
- # @see Base
- def error(message)
- super(message, kind: IssueClasses::Config::SEARCH_NOT_FOUND)
- end
+ # @return [Storage::Config]
+ attr_reader :storage_config
# @return [Issue, nil]
def not_found_issue
+ search = config.search
return if search.device || search.create_device? || search.skip_device?
if search.name
- # TRANSLATORS: %s is replaced by a device name (e.g., "/dev/vda").
- error(format(_("Mandatory device %s not found"), search.name))
+ error(
+ # TRANSLATORS: %s is replaced by a device name (e.g., "/dev/vda").
+ format(_("Mandatory device %s not found"), search.name),
+ kind: IssueClasses::Config::SEARCH_NOT_FOUND
+ )
+ else
+ error(
+ # TRANSLATORS: %s is replaced by a device type (e.g., "drive").
+ format(_("Mandatory %s not found"), device_type),
+ kind: IssueClasses::Config::SEARCH_NOT_FOUND
+ )
+ end
+ end
+
+ # Issues from a reused device.
+ #
+ # When a MD RAID or LVM volume group is reused, the members of the device (i.e., MD devices
+ # or physical volume) must be kept. Otherwise the reused device will be deleted.
+ #
+ # @return [Array]
+ def reused_issues
+ reused_members.map { |m| reused_member_issue(m) }.compact
+ end
+
+ # Issue if the member device is used for any other purpose.
+ #
+ # @param member [Y2Storage::BlkDevice] Member device.
+ # @return [Issue, nil]
+ def reused_member_issue(member)
+ member_config = storage_config.find_device(member)
+ return parent_reused_member_issue(member) unless member_config
+
+ deleted_reused_member_issue(member_config) ||
+ resized_reused_member_issue(member_config) ||
+ formatted_reused_member_issue(member_config) ||
+ partitioned_reused_member_issue(member_config) ||
+ target_reused_member_issue(member_config)
+ end
+
+ # Issue if the parent of the member device is formatted.
+ #
+ # @param device [Y2Storage::BlkDevice] Parent of the member device.
+ # @return [Issue, nil]
+ def parent_reused_member_issue(device)
+ return unless device.respond_to?(:partitionable)
+
+ parent_config = storage_config.find_device(device.partitionable)
+ return unless parent_config&.filesystem
+
+ kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid)
+ [
+ IssueClasses::Config::MISUSED_MD_MEMBER,
+ _(
+ # TRANSLATORS: %{parent_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/md0").
+ "The device '%{parent_name}' cannot be formatted because it is part of the " \
+ "reused MD RAID '%{reused_name}'"
+ )
+ ]
+ else
+ [
+ IssueClasses::Config::MISUSED_PV,
+ _(
+ # TRANSLATORS: %{parent_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/vg0").
+ "The device '%{parent_name}' cannot be formatted because it is a physical volume " \
+ "of the reused LVM volume group '%{reused_name}'"
+ )
+ ]
+ end
+
+ error(
+ format(
+ message,
+ parent_name: parent_config.found_device.name,
+ reused_name: config.found_device.name
+ ),
+ kind: kind
+ )
+ end
+
+ # Issue if the member device is deleted.
+ #
+ # @param member_config [#search]
+ # @return [Issue, nil]
+ def deleted_reused_member_issue(member_config)
+ return unless storage_config.supporting_delete.include?(member_config)
+ return unless member_config.delete? || member_config.delete_if_needed?
+
+ kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid)
+ [
+ IssueClasses::Config::MISUSED_MD_MEMBER,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/md0").
+ "The device '%{member_name}' cannot be deleted because it is part of the reused " \
+ "MD RAID '%{reused_name}'"
+ )
+ ]
+ else
+ [
+ IssueClasses::Config::MISUSED_PV,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/vg0").
+ "The device '%{member_name}' cannot be deleted because it is a physical volume " \
+ "of the reused LVM volume group '%{reused_name}'"
+ )
+ ]
+ end
+
+ error(
+ format(
+ message,
+ member_name: member_config.found_device.name,
+ reused_name: config.found_device.name
+ ),
+ kind: kind
+ )
+ end
+
+ # Issue if the device member is resized.
+ #
+ # @param member_config [#search]
+ # @return [Issue, nil]
+ def resized_reused_member_issue(member_config)
+ return unless storage_config.supporting_size.include?(member_config)
+ return if member_config.size.default?
+
+ kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid)
+ [
+ IssueClasses::Config::MISUSED_MD_MEMBER,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/md0").
+ "The device '%{member_name}' cannot be resized because it is part of the reused " \
+ "MD RAID '%{reused_name}'"
+ )
+ ]
else
- # TRANSLATORS: %s is replaced by a device type (e.g., "drive").
- error(format(_("Mandatory %s not found"), device_type))
+ [
+ IssueClasses::Config::MISUSED_PV,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/vg0").
+ "The device '%{member_name}' cannot be resized because it is a physical volume " \
+ "of the reused LVM volume group '%{reused_name}'"
+ )
+ ]
end
+
+ error(
+ format(
+ message,
+ member_name: member_config.found_device.name,
+ reused_name: config.found_device.name
+ ),
+ kind: kind
+ )
+ end
+
+ # Issue if the device member is formatted.
+ #
+ # @param member_config [#search]
+ # @return [Issue, nil]
+ def formatted_reused_member_issue(member_config)
+ return unless storage_config.supporting_filesystem.include?(member_config)
+ return unless member_config.filesystem
+
+ kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid)
+ [
+ IssueClasses::Config::MISUSED_MD_MEMBER,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/md0").
+ "The device '%{member_name}' cannot be formatted because it is part of the " \
+ "reused MD RAID '%{reused_name}'"
+ )
+ ]
+ else
+ [
+ IssueClasses::Config::MISUSED_PV,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/vg0").
+ "The device '%{member_name}' cannot be formatted because it is a physical volume " \
+ "of the reused LVM volume group '%{reused_name}'"
+ )
+ ]
+ end
+
+ error(
+ format(
+ message,
+ member_name: member_config.found_device.name,
+ reused_name: config.found_device.name
+ ),
+ kind: kind
+ )
+ end
+
+ # Issue if the device member is partitioned.
+ #
+ # @param member_config [#search]
+ # @return [Issue, nil]
+ def partitioned_reused_member_issue(member_config)
+ return unless storage_config.supporting_partitions.include?(member_config)
+ return unless member_config.partitions?
+
+ kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid)
+ [
+ IssueClasses::Config::MISUSED_MD_MEMBER,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/md0").
+ "The device '%{member_name}' cannot be partitioned because it is part of the " \
+ "reused MD RAID '%{reused_name}'"
+ )
+ ]
+ else
+ [
+ IssueClasses::Config::MISUSED_PV,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/vg0").
+ "The device '%{member_name}' cannot be partitioned because it is a physical " \
+ "volume of the reused LVM volume group '%{reused_name}'"
+ )
+ ]
+ end
+
+ error(
+ format(
+ message,
+ member_name: member_config.found_device.name,
+ reused_name: config.found_device.name
+ ),
+ kind: kind
+ )
+ end
+
+ # Issue if the device member is used by other device (e.g., as target for physical volumes).
+ #
+ # @param member_config [#search]
+ # @return [Issue, nil]
+ def target_reused_member_issue(member_config)
+ return unless users?(member_config)
+
+ kind, message = if config.is_a?(Agama::Storage::Configs::MdRaid)
+ [
+ IssueClasses::Config::MISUSED_MD_MEMBER,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/md0").
+ "The device '%{member_name}' cannot be used because it is part of the reused " \
+ "MD RAID '%{reused_name}'"
+ )
+ ]
+ else
+ [
+ IssueClasses::Config::MISUSED_PV,
+ _(
+ # TRANSLATORS: %{member_name} and %{reused_name} are replaced by device names (e.g.,
+ # "/dev/vda", "/dev/vg0").
+ "The device '%{member_name}' cannot be used because it is a physical volume " \
+ "of the reused LVM volume group '%{reused_name}'"
+ )
+ ]
+ end
+
+ error(
+ format(
+ message,
+ member_name: member_config.found_device.name,
+ reused_name: config.found_device.name
+ ),
+ kind: kind
+ )
+ end
+
+ # Whether the given config has any user (direct user or as target).
+ #
+ # @param config [#search]
+ # @return [Boolean]
+ def users?(config)
+ return false unless config.alias
+
+ storage_config.users(config.alias).any? ||
+ storage_config.target_users(config.alias).any?
+ end
+
+ # Members of the device reused by the config.
+ #
+ # @return [Array]
+ def reused_members
+ device = config.found_device
+ return [] unless device
+
+ return device.lvm_pvs.map(&:plain_blk_device) if device.is?(:lvm_vg)
+
+ return device.plain_devices if device.is?(:md)
+
+ []
end
# @return [String]
@@ -89,6 +387,8 @@ def device_type
_("partition")
when Agama::Storage::Configs::LogicalVolume
_("LVM logical volume")
+ when Agama::Storage::Configs::VolumeGroup
+ _("LVM volume group")
else
_("device")
end
diff --git a/service/lib/agama/storage/config_checkers/volume_group.rb b/service/lib/agama/storage/config_checkers/volume_group.rb
index 89de8178eb..6bbda49c1e 100644
--- a/service/lib/agama/storage/config_checkers/volume_group.rb
+++ b/service/lib/agama/storage/config_checkers/volume_group.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -22,6 +22,7 @@
require "agama/storage/config_checkers/base"
require "agama/storage/config_checkers/logical_volume"
require "agama/storage/config_checkers/physical_volumes_encryption"
+require "agama/storage/config_checkers/with_search"
require "yast/i18n"
module Agama
@@ -30,6 +31,7 @@ module ConfigCheckers
# Class for checking a volume group config.
class VolumeGroup < Base
include Yast::I18n
+ include WithSearch
# @param config [Configs::VolumeGroup]
# @param storage_config [Storage::Config]
@@ -52,7 +54,8 @@ def issues
logical_volumes_issues,
physical_volumes_issues,
physical_volumes_devices_issues,
- physical_volumes_encryption_issues
+ physical_volumes_encryption_issues,
+ search_issues
].compact.flatten
end
@@ -85,7 +88,7 @@ def name_issue
def logical_volumes_issues
config.logical_volumes.flat_map do |logical_volume|
ConfigCheckers::LogicalVolume
- .new(logical_volume, config, product_config)
+ .new(logical_volume, config, storage_config, product_config)
.issues
end
end
diff --git a/service/lib/agama/storage/config_checkers/with_search.rb b/service/lib/agama/storage/config_checkers/with_search.rb
index 70ef0b87d7..b829b99103 100644
--- a/service/lib/agama/storage/config_checkers/with_search.rb
+++ b/service/lib/agama/storage/config_checkers/with_search.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -28,7 +28,7 @@ module ConfigCheckers
module WithSearch
# @return [Array]
def search_issues
- ConfigCheckers::Search.new(config).issues
+ ConfigCheckers::Search.new(config, storage_config).issues
end
end
end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb
index 4735bf0c2c..678613634c 100644
--- a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -130,7 +130,7 @@ def convert_volume_groups(targets)
# @return [Configs::VolumeGroup]
def convert_volume_group(volume_group_model, targets)
FromModelConversions::VolumeGroup
- .new(volume_group_model, targets, model_json[:encryption])
+ .new(volume_group_model, product_config, targets, model_json[:encryption])
.convert
end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb
index 4a8c0cd5b9..076a65c4f3 100644
--- a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb
@@ -22,9 +22,9 @@
require "agama/storage/config_conversions/from_model_conversions/base"
require "agama/storage/config_conversions/from_model_conversions/with_encryption"
require "agama/storage/config_conversions/from_model_conversions/with_filesystem"
-require "agama/storage/config_conversions/from_model_conversions/with_partitions"
require "agama/storage/config_conversions/from_model_conversions/with_ptable_type"
require "agama/storage/config_conversions/from_model_conversions/with_search"
+require "agama/storage/config_conversions/from_model_conversions/with_volumes"
require "agama/storage/configs/drive"
module Agama
@@ -36,7 +36,7 @@ class Drive < Base
include WithEncryption
include WithFilesystem
include WithPtableType
- include WithPartitions
+ include WithVolumes
include WithSearch
# @param model_json [Hash]
@@ -72,7 +72,7 @@ def conversions
encryption: convert_encryption,
filesystem: convert_filesystem,
ptable_type: convert_ptable_type,
- partitions: convert_partitions(encryption_model)
+ partitions: convert_volumes(encryption_model)
}
end
end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb
index e22f85d7c1..21a3b795f3 100644
--- a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb
@@ -20,6 +20,7 @@
# find current contact information at www.suse.com.
require "agama/storage/config_conversions/from_model_conversions/base"
+require "agama/storage/config_conversions/from_model_conversions/with_delete"
require "agama/storage/config_conversions/from_model_conversions/with_filesystem"
require "agama/storage/config_conversions/from_model_conversions/with_size"
require "agama/storage/config_conversions/from_model_conversions/with_search"
@@ -32,6 +33,7 @@ module ConfigConversions
module FromModelConversions
# Logical volume conversion from model according to the JSON schema.
class LogicalVolume < Base
+ include WithDelete
include WithFilesystem
include WithSize
include WithSearch
@@ -50,12 +52,14 @@ def default_config
# @return [Hash]
def conversions
{
- name: logical_volume_model[:lvName],
- search: convert_search,
- filesystem: convert_filesystem,
- size: convert_size,
- stripes: logical_volume_model[:stripes],
- stripe_size: convert_stripe_size
+ name: logical_volume_model[:lvName],
+ search: convert_search,
+ filesystem: convert_filesystem,
+ size: convert_size,
+ stripes: logical_volume_model[:stripes],
+ stripe_size: convert_stripe_size,
+ delete: convert_delete,
+ delete_if_needed: convert_delete_if_needed
}
end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb
index d7b480a9a7..9d19086a60 100644
--- a/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/md_raid.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -22,9 +22,9 @@
require "agama/storage/config_conversions/from_model_conversions/base"
require "agama/storage/config_conversions/from_model_conversions/with_encryption"
require "agama/storage/config_conversions/from_model_conversions/with_filesystem"
-require "agama/storage/config_conversions/from_model_conversions/with_partitions"
require "agama/storage/config_conversions/from_model_conversions/with_ptable_type"
require "agama/storage/config_conversions/from_model_conversions/with_search"
+require "agama/storage/config_conversions/from_model_conversions/with_volumes"
require "agama/storage/configs/md_raid"
module Agama
@@ -36,7 +36,7 @@ class MdRaid < Base
include WithEncryption
include WithFilesystem
include WithPtableType
- include WithPartitions
+ include WithVolumes
include WithSearch
# @param model_json [Hash]
@@ -72,7 +72,7 @@ def conversions
encryption: convert_encryption,
filesystem: convert_filesystem,
ptable_type: convert_ptable_type,
- partitions: convert_partitions(encryption_model)
+ partitions: convert_volumes(encryption_model)
}
end
end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb
index ca53d87971..321a29ae37 100644
--- a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,6 +20,7 @@
# find current contact information at www.suse.com.
require "agama/storage/config_conversions/from_model_conversions/base"
+require "agama/storage/config_conversions/from_model_conversions/with_delete"
require "agama/storage/config_conversions/from_model_conversions/with_encryption"
require "agama/storage/config_conversions/from_model_conversions/with_filesystem"
require "agama/storage/config_conversions/from_model_conversions/with_search"
@@ -33,6 +34,7 @@ module ConfigConversions
module FromModelConversions
# Partition conversion from model according to the JSON schema.
class Partition < Base
+ include WithDelete
include WithSearch
include WithEncryption
include WithFilesystem
@@ -79,24 +81,6 @@ def convert_id
Y2Storage::PartitionId.find(value)
end
-
- # TODO: do not delete if the partition is used by other device (VG, RAID, etc).
- # @return [Boolean]
- def convert_delete
- # Do not mark to delete if the partition is used.
- return false if partition_model[:mountPath]
-
- partition_model[:delete]
- end
-
- # TODO: do not delete if the partition is used by other device (VG, RAID, etc).
- # @return [Boolean]
- def convert_delete_if_needed
- # Do not mark to delete if the partition is used.
- return false if partition_model[:mountPath]
-
- partition_model[:deleteIfNeeded]
- end
end
end
end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb
index 6f00248906..49ef4b854a 100644
--- a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb
@@ -20,9 +20,9 @@
# find current contact information at www.suse.com.
require "agama/storage/config_conversions/from_model_conversions/base"
-require "agama/storage/config_conversions/from_model_conversions/logical_volume"
require "agama/storage/config_conversions/from_model_conversions/encryption"
require "agama/storage/config_conversions/from_model_conversions/with_search"
+require "agama/storage/config_conversions/from_model_conversions/with_volumes"
require "agama/storage/configs/volume_group"
require "y2storage/disk_size"
@@ -33,12 +33,15 @@ module FromModelConversions
# Volume group conversion from model according to the JSON schema.
class VolumeGroup < Base
include WithSearch
+ include WithVolumes
# @param model_json [Hash]
+ # @param product_config [Agama::Config]
# @param targets [Array]
# @param encryption_model [Hash, nil]
- def initialize(model_json, targets, encryption_model = nil)
+ def initialize(model_json, product_config, targets, encryption_model = nil)
super(model_json)
+ @product_config = product_config
@targets = targets
@encryption_model = encryption_model
end
@@ -47,6 +50,9 @@ def initialize(model_json, targets, encryption_model = nil)
alias_method :volume_group_model, :model_json
+ # @return [Agama::Config]
+ attr_reader :product_config
+
# @return [Array]
attr_reader :targets
@@ -68,7 +74,7 @@ def conversions
extent_size: convert_extent_size,
physical_volumes_devices: convert_physical_volumes_devices,
physical_volumes_encryption: convert_physical_volumes_encryption,
- logical_volumes: convert_logical_volumes
+ logical_volumes: convert_volumes
}
end
@@ -97,14 +103,6 @@ def convert_physical_volumes_encryption
FromModelConversions::Encryption.new(encryption_model).convert
end
- # @return [Array, nil]
- def convert_logical_volumes
- logical_volumes_model = volume_group_model[:logicalVolumes]
- return unless logical_volumes_model
-
- logical_volumes_model.map { |l| FromModelConversions::LogicalVolume.new(l).convert }
- end
-
# @param name [String]
# @return [Configs::Drive, Configs::MdRaid, nil]
def target(name)
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_delete.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_delete.rb
new file mode 100644
index 0000000000..e9625f1b31
--- /dev/null
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_delete.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+module Agama
+ module Storage
+ module ConfigConversions
+ module FromModelConversions
+ # Mixin for delete properties conversion.
+ module WithDelete
+ # TODO: do not delete if the volume is used by other device (VG, RAID, etc).
+ # @return [Boolean]
+ def convert_delete
+ # Do not mark to delete if the volume is used.
+ return false if model_json[:mountPath]
+
+ model_json[:delete]
+ end
+
+ # TODO: do not delete if the volume is used by other device (VG, RAID, etc).
+ # @return [Boolean]
+ def convert_delete_if_needed
+ # Do not mark to delete if the volume is used.
+ return false if model_json[:mountPath]
+
+ model_json[:deleteIfNeeded]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb
deleted file mode 100644
index c5ba9d4219..0000000000
--- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb
+++ /dev/null
@@ -1,159 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright (c) [2024-2025] SUSE LLC
-#
-# All Rights Reserved.
-#
-# This program is free software; you can redistribute it and/or modify it
-# under the terms of version 2 of the GNU General Public License as published
-# by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
-# more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, contact SUSE LLC.
-#
-# To contact SUSE LLC about this file by physical or electronic mail, you may
-# find current contact information at www.suse.com.
-
-require "agama/storage/config_conversions/from_model_conversions/partition"
-require "agama/storage/configs/partition"
-
-module Agama
- module Storage
- module ConfigConversions
- module FromModelConversions
- # Mixin for partitions conversion.
- module WithPartitions
- # @param encryption_model [Hash, nil]
- # @return [Array]
- def convert_partitions(encryption_model = nil)
- # If the model does not indicate a space policy, then the space policy defined by the
- # product is applied.
- space_policy = model_json[:spacePolicy] || product_config.space_policy
-
- case space_policy
- when "keep"
- used_partition_configs(encryption_model)
- when "delete"
- [used_partition_configs(encryption_model), delete_all_partition_config].flatten
- when "resize"
- [used_partition_configs(encryption_model), resize_all_partition_config].flatten
- else
- [used_partition_configs(encryption_model), action_partition_configs].flatten
- end
- end
-
- # @param encryption_model [Hash, nil]
- # @return [Array]
- def used_partition_configs(encryption_model = nil)
- used_partitions.map { |p| convert_partition(p, encryption_model) }
- end
-
- # @return [Array]
- def action_partition_configs
- action_partitions.map { |p| convert_partition(p) }
- end
-
- # Partitions with any usage (format, mount, etc).
- #
- # @return [Array]
- def used_partitions
- partitions.reject { |p| space_policy_partition?(p) }
- end
-
- # Partitions representing a space policy action (delete, resize if needed), excluding
- # the keep actions.
- #
- # Omitting the partitions that only represent a keep action is important. Otherwise, the
- # resulting config would contain a partition without any usage (delete, resize, format,
- # etc) and without a mount path. Such a partition is not supported by the model yet (see
- # {ModelSupportChecker}) and would make impossible to build a model again from the
- # resulting config.
- #
- # @return [Array]
- def action_partitions
- partitions
- .select { |p| space_policy_partition?(p) }
- .reject { |p| keep_action_partition?(p) }
- end
-
- # @return [Array]
- def partitions
- model_json[:partitions] || []
- end
-
- # Whether the partition only represents a space policy action.
- #
- # @param partition_model [Hash]
- # @return [Boolean]
- def space_policy_partition?(partition_model)
- delete_action_partition?(partition_model) ||
- resize_action_partition?(partition_model) ||
- keep_action_partition?(partition_model)
- end
-
- # @param partition_model [Hash]
- # @return [Boolean]
- def delete_action_partition?(partition_model)
- partition_model[:delete] || partition_model[:deleteIfNeeded]
- end
-
- # @param partition_model [Hash]
- # @return [Boolean]
- def resize_action_partition?(partition_model)
- return false if delete_action_partition?(partition_model)
-
- return false if any_usage?(partition_model)
-
- partition_model[:name] && (
- partition_model[:resizeIfNeeded] ||
- (partition_model[:size] && !partition_model.dig(:size, :default))
- )
- end
-
- # @param partition_model [Hash]
- # @return [Boolean]
- def keep_action_partition?(partition_model)
- return false if delete_action_partition?(partition_model)
-
- return false if resize_action_partition?(partition_model)
-
- return false if any_usage?(partition_model)
-
- !partition_model[:name].nil?
- end
-
- # TODO: improve check by ensuring the partition is referenced by other device.
- #
- # @param partition_model [Hash]
- # @return [Boolean]
- def any_usage?(partition_model)
- partition_model[:mountPath] || partition_model[:filesystem]
- end
-
- # @return [Configs::Partition]
- def delete_all_partition_config
- Configs::Partition.new_for_delete_all
- end
-
- # @return [Configs::Partition]
- def resize_all_partition_config
- Configs::Partition.new_for_shrink_any_if_needed
- end
-
- # @param partition_model [Hash]
- # @param encryption_model [Hash, nil]
- #
- # @return [Configs::Partition]
- def convert_partition(partition_model, encryption_model = nil)
- FromModelConversions::Partition.new(partition_model, encryption_model).convert
- end
- end
- end
- end
- end
-end
diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_volumes.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_volumes.rb
new file mode 100644
index 0000000000..c0925f8c1b
--- /dev/null
+++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_volumes.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2024-2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require "agama/storage/config_conversions/from_model_conversions/partition"
+require "agama/storage/config_conversions/from_model_conversions/logical_volume"
+require "agama/storage/configs/partition"
+require "agama/storage/configs/logical_volume"
+require "agama/storage/configs/volume_group"
+
+module Agama
+ module Storage
+ module ConfigConversions
+ module FromModelConversions
+ # Mixin for volumes conversion.
+ #
+ # In this context, volume is a term to refer to partition or logical volume config
+ # indiscriminately.
+ module WithVolumes
+ # @param encryption_model [Hash, nil]
+ # @return [Array, Array]
+ def convert_volumes(encryption_model = nil)
+ # If the model does not indicate a space policy, then the space policy defined by the
+ # product is applied.
+ space_policy = model_json[:spacePolicy] || product_config.space_policy
+
+ case space_policy
+ when "keep"
+ used_volumes_configs(encryption_model)
+ when "delete"
+ [used_volumes_configs(encryption_model), delete_all_volume_config].flatten
+ when "resize"
+ [used_volumes_configs(encryption_model), resize_all_volume_config].flatten
+ else
+ [used_volumes_configs(encryption_model), action_volume_configs].flatten
+ end
+ end
+
+ # @param encryption_model [Hash, nil]
+ # @return [Array, Array]
+ def used_volumes_configs(encryption_model = nil)
+ used_volumes.map { |v| convert_volume(v, encryption_model) }
+ end
+
+ # @return [Array, Array]
+ def action_volume_configs
+ action_volumes.map { |v| convert_volume(v) }
+ end
+
+ # Volumes with any usage (format, mount, etc).
+ #
+ # @return [Array]
+ def used_volumes
+ volumes.reject { |v| space_policy_volume?(v) }
+ end
+
+ # Volumes representing a space policy action (delete, resize if needed), excluding
+ # the keep actions.
+ #
+ # Omitting the volumes that only represent a keep action is important. Otherwise, the
+ # resulting config would contain a volume without any usage (delete, resize, format,
+ # etc) and without a mount path. Such a volume is not supported by the model yet (see
+ # {ModelSupportChecker}) and would make impossible to build a model again from the
+ # resulting config.
+ #
+ # @return [Array]
+ def action_volumes
+ volumes
+ .select { |v| space_policy_volume?(v) }
+ .reject { |v| keep_action_volume?(v) }
+ end
+
+ # @return [Array]
+ def volumes
+ model_json[:partitions] || model_json[:logicalVolumes] || []
+ end
+
+ # Whether the volume only represents a space policy action.
+ #
+ # @param volume [Hash]
+ # @return [Boolean]
+ def space_policy_volume?(volume)
+ delete_action_volume?(volume) ||
+ resize_action_volume?(volume) ||
+ keep_action_volume?(volume)
+ end
+
+ # @param volume [Hash]
+ # @return [Boolean]
+ def delete_action_volume?(volume)
+ volume[:delete] || volume[:deleteIfNeeded]
+ end
+
+ # @param volume [Hash]
+ # @return [Boolean]
+ def resize_action_volume?(volume)
+ return false if delete_action_volume?(volume)
+
+ return false if any_usage?(volume)
+
+ volume[:name] && (
+ volume[:resizeIfNeeded] ||
+ (volume[:size] && !volume.dig(:size, :default))
+ )
+ end
+
+ # @param volume [Hash]
+ # @return [Boolean]
+ def keep_action_volume?(volume)
+ return false if delete_action_volume?(volume)
+
+ return false if resize_action_volume?(volume)
+
+ return false if any_usage?(volume)
+
+ !volume[:name].nil?
+ end
+
+ # TODO: improve check by ensuring the volume is referenced by other device.
+ #
+ # @param volume [Hash]
+ # @return [Boolean]
+ def any_usage?(volume)
+ volume[:mountPath] || volume[:filesystem]
+ end
+
+ # @return [Configs::Partition, Configs::LogicalVolume]
+ def delete_all_volume_config
+ volume_class.new_for_delete_all
+ end
+
+ # @return [Configs::Partition, Configs::LogicalVolume]
+ def resize_all_volume_config
+ volume_class.new_for_shrink_any_if_needed
+ end
+
+ # @param volume [Hash]
+ # @param encryption_model [Hash, nil]
+ #
+ # @return [Configs::Partition, Configs::LogicalVolume]
+ def convert_volume(volume, encryption_model = nil)
+ return FromModelConversions::LogicalVolume.new(volume).convert if convert_lvm?
+
+ FromModelConversions::Partition.new(volume, encryption_model).convert
+ end
+
+ # Volume config class depending on the conversion.
+ def volume_class
+ convert_lvm? ? Configs::LogicalVolume : Configs::Partition
+ end
+
+ # Whether the conversion if for LVM.
+ #
+ # @return [Boolean]
+ def convert_lvm?
+ default_config.is_a?(Configs::VolumeGroup)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb
index 81a8524541..a70e1169a9 100644
--- a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb
+++ b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -21,6 +21,7 @@
require "agama/storage/config_conversions/to_model_conversions/base"
require "agama/storage/config_conversions/to_model_conversions/with_filesystem"
+require "agama/storage/config_conversions/to_model_conversions/with_resize"
require "agama/storage/config_conversions/to_model_conversions/with_size"
module Agama
@@ -30,6 +31,7 @@ module ToModelConversions
# LVM logical volume conversion to model according to the JSON schema.
class LogicalVolume < Base
include WithFilesystem
+ include WithResize
include WithSize
# @param config [Configs::LogicalVolume]
@@ -48,12 +50,17 @@ def initialize(config, volumes)
# @see Base#conversions
def conversions
{
- lvName: config.name,
- mountPath: config.filesystem&.path,
- filesystem: convert_filesystem,
- size: convert_size,
- stripes: config.stripes,
- stripeSize: config.stripe_size&.to_i
+ name: config.device_name,
+ lvName: config.name,
+ mountPath: config.filesystem&.path,
+ filesystem: convert_filesystem,
+ stripes: config.stripes,
+ stripeSize: config.stripe_size&.to_i,
+ size: convert_size,
+ delete: config.delete?,
+ deleteIfNeeded: config.delete_if_needed?,
+ resize: convert_resize,
+ resizeIfNeeded: convert_resize_if_needed
}
end
end
diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb
index d6f0ac0865..9a20cdb5d0 100644
--- a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb
+++ b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -21,6 +21,7 @@
require "agama/storage/config_conversions/to_model_conversions/base"
require "agama/storage/config_conversions/to_model_conversions/with_filesystem"
+require "agama/storage/config_conversions/to_model_conversions/with_resize"
require "agama/storage/config_conversions/to_model_conversions/with_size"
module Agama
@@ -30,6 +31,7 @@ module ToModelConversions
# Partition conversion to model according to the JSON schema.
class Partition < Base
include WithFilesystem
+ include WithResize
include WithSize
# @param config [Configs::Partition]
@@ -58,22 +60,6 @@ def conversions
resizeIfNeeded: convert_resize_if_needed
}
end
-
- # @return [Booelan]
- def convert_resize
- return false unless config.found_device
-
- size = config.size
- !size.nil? && !size.default? && size.min == size.max
- end
-
- # @return [Booelan]
- def convert_resize_if_needed
- return false unless config.found_device
-
- size = config.size
- !size.nil? && !size.default? && size.min != size.max
- end
end
end
end
diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb
index 7708863586..a6b8e73137 100644
--- a/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb
+++ b/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -19,78 +19,88 @@
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
+require "agama/storage/configs/volume_group"
+
module Agama
module Storage
module ConfigConversions
module ToModelConversions
# Space policy conversion to model according to the JSON schema.
class SpacePolicy
- # TODO: make it work with volume groups and raids too.
- #
- # @param config [Configs::Drive]
+ # @param config [Configs::Drive, Configs::MdRaid, Configs::VolumeGroup]
def initialize(config)
@config = config
end
# @return [String]
def convert
- return "delete" if delete_all_partition?
- return "resize" if shrink_all_partition?
- return "custom" if delete_partition? || resize_partition?
+ return "delete" if delete_all_volumes?
+ return "resize" if shrink_all_volumes?
+ return "custom" if delete_volume? || resize_volume?
"keep"
end
private
- # @return [Configs::Drive]
+ # @return [Configs::Drive, Configs::MdRaid, Configs::VolumeGroup]
attr_reader :config
+ # Volumes from the config.
+ #
+ # In this context, volume is a term to refer to partition or logical volume config
+ # indiscriminately.
+ #
+ # @return [Array, Array]
+ def volumes
+ config.is_a?(Configs::VolumeGroup) ? config.logical_volumes : config.partitions
+ end
+
# @return [Boolean]
- def delete_all_partition?
- config.partitions.any? { |p| delete_all?(p) }
+ def delete_all_volumes?
+ volumes.any? { |v| delete_all?(v) }
end
# @return [Boolean]
- def shrink_all_partition?
- config.partitions.any? { |p| shrink_all?(p) }
+ def shrink_all_volumes?
+ volumes.any? { |v| shrink_all?(v) }
end
# @return [Boolean]
- def delete_partition?
- config.partitions
+ def delete_volume?
+ volumes
.select(&:found_device)
- .any? { |p| p.delete? || p.delete_if_needed? }
+ .any? { |v| v.delete? || v.delete_if_needed? }
end
# @return [Boolean]
- def resize_partition?
- config.partitions
+ def resize_volume?
+ volumes
.select(&:found_device)
- .any? { |p| !p.size.default? }
+ .any? { |v| !v.size.default? }
end
- # @param partition_config [Configs::Partition]
+ # @param volume [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def delete_all?(partition_config)
- search_all?(partition_config) && partition_config.delete?
+ def delete_all?(volume)
+ search_all?(volume) && volume.delete?
end
- # @param partition_config [Configs::Partition]
+ # @param volume [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def shrink_all?(partition_config)
- search_all?(partition_config) &&
- !partition_config.size.nil? &&
- !partition_config.size.min.nil? &&
- partition_config.size.min.to_i == 0
+ def shrink_all?(volume)
+ search_all?(volume) &&
+ !volume.size.nil? &&
+ !volume.size.min.nil? &&
+ volume.size.min.to_i == 0
end
- # @param partition_config [Configs::Partition]
+ # @param volume [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def search_all?(partition_config)
- !partition_config.search.nil? &&
- !partition_config.search.condition? &&
- partition_config.search.max.nil?
+ def search_all?(volume)
+ !volume.search.nil? &&
+ !volume.search.condition? &&
+ volume.search.max.nil?
end
end
end
diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb
index 4ad39bd1f5..d9172c1232 100644
--- a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb
+++ b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -21,6 +21,7 @@
require "agama/storage/config_conversions/to_model_conversions/base"
require "agama/storage/config_conversions/to_model_conversions/logical_volume"
+require "agama/storage/config_conversions/to_model_conversions/with_space_policy"
module Agama
module Storage
@@ -28,7 +29,7 @@ module ConfigConversions
module ToModelConversions
# LVM volume group conversion to model according to the JSON schema.
class VolumeGroup < Base
- include WithFilesystem
+ include WithSpacePolicy
# @param config [Configs::VolumeGroup]
# @param storage_config [Storage::Config]
@@ -51,9 +52,11 @@ def initialize(config, storage_config, volumes)
# @see Base#conversions
def conversions
{
+ name: config.device_name,
vgName: config.name,
extentSize: config.extent_size&.to_i,
targetDevices: convert_target_devices,
+ spacePolicy: convert_space_policy,
logicalVolumes: convert_logical_volumes
}
end
@@ -69,9 +72,9 @@ def convert_target_devices
# @return [Array]
def convert_logical_volumes
- config.logical_volumes.map do |logical_volume|
- ToModelConversions::LogicalVolume.new(logical_volume, volumes).convert
- end
+ config.logical_volumes
+ .reject(&:skipped?)
+ .map { |l| ToModelConversions::LogicalVolume.new(l, volumes).convert }
end
end
end
diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_resize.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_resize.rb
new file mode 100644
index 0000000000..29d3645a2e
--- /dev/null
+++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_resize.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+module Agama
+ module Storage
+ module ConfigConversions
+ module ToModelConversions
+ # Mixin for resize info conversion to model according to the JSON schema.
+ module WithResize
+ # @return [Booelan]
+ def convert_resize
+ return false unless config.found_device
+
+ size = config.size
+ !size.nil? && !size.default? && size.min == size.max
+ end
+
+ # @return [Booelan]
+ def convert_resize_if_needed
+ return false unless config.found_device
+
+ size = config.size
+ !size.nil? && !size.default? && size.min != size.max
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb
index d87ae591c7..9bbb44f8fe 100644
--- a/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb
+++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -29,8 +29,6 @@ module ToModelConversions
module WithSpacePolicy
# @return [String, nil]
def convert_space_policy
- return unless config.respond_to?(:partitions)
-
ToModelConversions::SpacePolicy.new(config).convert
end
end
diff --git a/service/lib/agama/storage/configs/logical_volume.rb b/service/lib/agama/storage/configs/logical_volume.rb
index 4c24965458..f10332add2 100644
--- a/service/lib/agama/storage/configs/logical_volume.rb
+++ b/service/lib/agama/storage/configs/logical_volume.rb
@@ -19,28 +19,18 @@
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
-require "agama/storage/configs/size"
-require "agama/storage/configs/with_alias"
-require "agama/storage/configs/with_filesystem"
-require "agama/storage/configs/with_search"
-require "agama/storage/configs/with_delete"
+require "agama/storage/configs/with_volume_properties"
module Agama
module Storage
module Configs
# Section of the configuration representing a LVM logical volume.
class LogicalVolume
- include WithAlias
- include WithFilesystem
- include WithSearch
- include WithDelete
+ include WithVolumeProperties
# @return [String, nil]
attr_accessor :name
- # @return [Size]
- attr_accessor :size
-
# @return [Integer, nil]
attr_accessor :stripes
@@ -54,12 +44,8 @@ class LogicalVolume
# @return [String, nil]
attr_accessor :used_pool
- # @return [Encryption, nil]
- attr_accessor :encryption
-
def initialize
- initialize_delete
- @size = Size.new
+ initialize_volume_properties
@pool = false
end
diff --git a/service/lib/agama/storage/configs/partition.rb b/service/lib/agama/storage/configs/partition.rb
index 834a51cefa..1a9bd3dc70 100644
--- a/service/lib/agama/storage/configs/partition.rb
+++ b/service/lib/agama/storage/configs/partition.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -19,55 +19,20 @@
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
-require "agama/storage/configs/size"
-require "agama/storage/configs/with_alias"
-require "agama/storage/configs/with_filesystem"
-require "agama/storage/configs/with_search"
-require "agama/storage/configs/with_delete"
+require "agama/storage/configs/with_volume_properties"
module Agama
module Storage
module Configs
# Section of the configuration representing a partition
class Partition
- include WithDelete
-
- # Partition config meaning "delete all partitions".
- #
- # @return [Configs::Partition]
- def self.new_for_delete_all
- new.tap do |config|
- config.search = Configs::Search.new_for_search_all
- config.delete = true
- end
- end
-
- # Partition config meaning "shrink any partitions if needed".
- #
- # @return [Configs::Partition]
- def self.new_for_shrink_any_if_needed
- new.tap do |config|
- config.search = Configs::Search.new_for_search_all
- config.size = Configs::Size.new_for_shrink_if_needed
- end
- end
-
- include WithAlias
- include WithFilesystem
- include WithSearch
+ include WithVolumeProperties
# @return [Y2Storage::PartitionId, nil]
attr_accessor :id
- # @return [Size]
- attr_accessor :size
-
- # @return [Encryption, nil]
- attr_accessor :encryption
-
def initialize
- initialize_delete
- @size = Size.new
+ initialize_volume_properties
end
end
end
diff --git a/service/lib/agama/storage/configs/with_volume_properties.rb b/service/lib/agama/storage/configs/with_volume_properties.rb
new file mode 100644
index 0000000000..72599b181a
--- /dev/null
+++ b/service/lib/agama/storage/configs/with_volume_properties.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require "agama/storage/configs/size"
+require "agama/storage/configs/with_alias"
+require "agama/storage/configs/with_filesystem"
+require "agama/storage/configs/with_search"
+require "agama/storage/configs/with_delete"
+
+module Agama
+ module Storage
+ module Configs
+ # Mixin for configs with volume properties.
+ module WithVolumeProperties
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ # Class methods to build default configs.
+ module ClassMethods
+ # Volume config meaning "delete all partitions".
+ #
+ # @return [Configs::Partition]
+ def new_for_delete_all
+ new.tap do |config|
+ config.search = Configs::Search.new_for_search_all
+ config.delete = true
+ end
+ end
+
+ # Volume config meaning "shrink any partitions if needed".
+ #
+ # @return [Configs::Partition]
+ def new_for_shrink_any_if_needed
+ new.tap do |config|
+ config.search = Configs::Search.new_for_search_all
+ config.size = Configs::Size.new_for_shrink_if_needed
+ end
+ end
+ end
+
+ include WithAlias
+ include WithFilesystem
+ include WithSearch
+ include WithDelete
+
+ # @return [Size]
+ attr_accessor :size
+
+ # @return [Encryption, nil]
+ attr_accessor :encryption
+
+ def initialize_volume_properties
+ initialize_delete
+ @size = Size.new
+ end
+ end
+ end
+ end
+end
diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb
index 0bc8b47f3e..68d5f3d73f 100644
--- a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb
+++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -53,7 +53,7 @@ def lvm_vg_size
#
# @return [Array]
def lvm_vg_pvs
- storage_device.lvm_pvs.map(&:sid)
+ storage_device.lvm_pvs.map(&:plain_blk_device).map(&:sid)
end
end
end
diff --git a/service/lib/agama/storage/issue_classes.rb b/service/lib/agama/storage/issue_classes.rb
index 87edb9a472..a465a7730c 100644
--- a/service/lib/agama/storage/issue_classes.rb
+++ b/service/lib/agama/storage/issue_classes.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -37,6 +37,9 @@ module Config
# A device that is part of a reused RAID is chosen to be used with other purpose
MISUSED_MD_MEMBER = :configMisusedMdMember
+ # A device that is a PV of a reused VG is chosen to be used with other purpose
+ MISUSED_PV = :configMisusedMdMember
+
# Reused and new devices are both used as target for generating PVs for the same LV
INCOMPATIBLE_PV_TARGETS = :configIncompatiblePvTargets
diff --git a/service/lib/agama/storage/model_support_checker.rb b/service/lib/agama/storage/model_support_checker.rb
index bfa023e9ea..6563bed6c6 100644
--- a/service/lib/agama/storage/model_support_checker.rb
+++ b/service/lib/agama/storage/model_support_checker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -59,8 +59,7 @@ def unsupported_config? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/
any_partitionable_without_name? ||
any_volume_group_without_name? ||
any_volume_group_with_pvs? ||
- any_partition_without_mount_path? ||
- any_logical_volume_without_mount_path? ||
+ any_volume_without_mount_path? ||
any_logical_volume_with_encryption? ||
any_different_encryption? ||
any_missing_encryption? ||
@@ -71,8 +70,8 @@ def unsupported_config? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/
#
# @return [Boolean]
def any_unsupported_device?
- thin_pools = config.logical_volumes.select(&:pool?)
- thin_volumes = config.logical_volumes.select(&:thin_volume?)
+ thin_pools = config.valid_logical_volumes.select(&:pool?)
+ thin_volumes = config.valid_logical_volumes.select(&:thin_volume?)
[
config.btrfs_raids,
@@ -99,7 +98,7 @@ def any_partitionable_without_name?
#
# @return [Boolean]
def any_volume_group_without_name?
- !config.volume_groups.all?(&:name)
+ !config.valid_volume_groups.all?(&:name)
end
# Only volume groups with automatically generated physical volumes are supported.
@@ -107,96 +106,91 @@ def any_volume_group_without_name?
#
# @return [Boolean]
def any_volume_group_with_pvs?
- config.volume_groups.any? { |v| v.physical_volumes.any? }
- end
-
- # Whether there is any logical volume with missing mount path.
- # @todo Revisit this check once volume groups can be reused.
- #
- # @return [Boolean]
- def any_logical_volume_without_mount_path?
- config.logical_volumes.any? { |p| !p.filesystem&.path }
+ config.valid_volume_groups.any? { |v| v.physical_volumes.any? }
end
# Whether there is any logical volume with encryption.
#
# @return [Boolean]
def any_logical_volume_with_encryption?
- config.logical_volumes.any?(&:encryption)
+ config.valid_logical_volumes.any?(&:encryption)
end
- # Whether there is any partition with missing mount path.
+ # Whether there is any volume (i.e., partition or logical volume) with missing mount path.
# @see #need_mount_path?
#
# @return [Boolean]
- def any_partition_without_mount_path?
- config.partitions.any? { |p| need_mount_path?(p) && !p.filesystem&.path }
+ def any_volume_without_mount_path?
+ config.volumes.any? { |v| need_mount_path?(v) && !v.filesystem&.path }
end
- # Whether the config represents a partition that requires a mount path.
+ # Whether the volume config requires a mount path.
#
- # A mount path is required for all the partitions that are going to be created. For a config
- # reusing an existing partition, the mount path is required only if the partition does not
- # represent a space policy action (delete or resize).
+ # A mount path is required for all the volumes (i.e., partitions or logical volumes) that are
+ # going to be created. For a config reusing an existing device, the mount path is required
+ # only if the volume does not represent a space policy action (delete or resize).
#
# @todo Revisit this check once individual physical volumes are supported by the model. The
# partitions representing the new physical volumes would not need a mount path.
#
- # @param partition_config [Configs::Partition]
+ # @param volume_config [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def need_mount_path?(partition_config)
- return true if new_partition?(partition_config)
+ def need_mount_path?(volume_config)
+ return true if new_volume?(volume_config)
- reused_partition?(partition_config) &&
- !delete_action_partition?(partition_config) &&
- !resize_action_partition?(partition_config)
+ reused_volume?(volume_config) &&
+ !delete_action?(volume_config) &&
+ !resize_action?(volume_config)
end
- # Whether the config represents a new partition to be created.
+ # Whether the config represents a new volume (i.e., partition or logical volume) to be
+ # created.
#
# @note The config has to be solved. Otherwise, in some cases it would be impossible to
- # determine whether the partition is going to be created or reused. For example, if the
+ # determine whether the volume is going to be created or reused. For example, if the
# config has a search and #if_not_found is set to :create.
#
- # @param partition_config [Configs::Partition]
+ # @param volume_config [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def new_partition?(partition_config)
- partition_config.search.nil? || partition_config.search.create_device?
+ def new_volume?(volume_config)
+ volume_config.search.nil? || volume_config.search.create_device?
end
- # Whether the config is reusing an existing partition.
+ # Whether the config is reusing an existing volume (i.e., partition or logical volume).
#
# @note The config has to be solved. Otherwise, in some cases it would be impossible to
- # determine whether the partition is going to be reused or skipped.
+ # determine whether the volume is going to be reused or skipped.
#
- # @param partition_config [Configs::Partition]
+ # @param volume_config [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def reused_partition?(partition_config)
- !new_partition?(partition_config) && !partition_config.search.skip_device?
+ def reused_volume?(volume_config)
+ !new_volume?(volume_config) && !volume_config.search.skip_device?
end
- # Whether the partition is configured to be deleted or deleted if needed.
+ # Whether the volume (i.e., partition or logical volume) is configured to be deleted or
+ # deleted if needed.
#
- # @param partition_config [Configs::Partition]
+ # @param volume_config [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def delete_action_partition?(partition_config)
- return false unless reused_partition?(partition_config)
+ def delete_action?(volume_config)
+ return false unless reused_volume?(volume_config)
- partition_config.delete? || partition_config.delete_if_needed?
+ volume_config.delete? || volume_config.delete_if_needed?
end
- # Whether the partition is configured to be resized if needed.
+ # Whether the volume (i.e., partition or logical volume) is configured to be resized if
+ # needed.
#
- # @param partition_config [Configs::Partition]
+ # @param volume_config [Configs::Partition, Configs::LogicalVolume]
# @return [Boolean]
- def resize_action_partition?(partition_config)
- return false unless reused_partition?(partition_config)
-
- partition_config.filesystem.nil? &&
- partition_config.encryption.nil? &&
- partition_config.size &&
- !partition_config.size.default? &&
- partition_config.size.min == Y2Storage::DiskSize.zero
+ def resize_action?(volume_config)
+ return false unless reused_volume?(volume_config)
+
+ volume_config.filesystem.nil? &&
+ volume_config.encryption.nil? &&
+ volume_config.size &&
+ !volume_config.size.default? &&
+ volume_config.size.min == Y2Storage::DiskSize.zero
end
# Whether there are different encryptions.
@@ -238,7 +232,7 @@ def any_missing_device_encryption?
def any_missing_volume_group_encryption?
return false if config.valid_encryptions.none?
- config.volume_groups
+ config.valid_volume_groups
.reject { |c| c.physical_volumes_devices.none? }
.reject(&:physical_volumes_encryption)
.any?
diff --git a/service/lib/agama/storage/system.rb b/service/lib/agama/storage/system.rb
index b44ccc1649..248faa1e0d 100644
--- a/service/lib/agama/storage/system.rb
+++ b/service/lib/agama/storage/system.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -66,8 +66,7 @@ def candidate_drives
# All devices that can be referenced by an mdRaid entry at the Agama config
#
- # This excludes devices with any mounted filesystem and devices that contain a repository
- # for installation.
+ # This excludes MD RAIDs that are not based on available devices.
#
# @return [Array]
def available_md_raids
@@ -92,14 +91,34 @@ def candidate_md_raids
available_md_raids.reject { |r| r.is?(:software_raid) }
end
- # Whether the device is usable as drive or mdRaid
+ # All devices that can be referenced by a volumeGroups entry at the Agama config
#
- # See {#available_drives} and {#available_md_raids}
+ # This excludes volume groups that are not based on available devices.
+ #
+ # @return [Array]
+ def available_volume_groups
+ return [] unless devicegraph
+
+ devicegraph.lvm_vgs.select { |v| available?(v) }
+ end
+
+ # Whether the device is usable for the installation.
+ #
+ # A device is usable if it contains neither a mounted filesystem nor a repository for the
+ # installation.
+ #
+ # For "compound" devices like MD RAIDs or volume groups, all the devices used for creating
+ # them have to be usable for the installation too.
+ #
+ # See {#available_drives}, {#available_md_raids} and {#available_volume_groups}
#
# @param device [Y2Storage::Partitionable, Y2Storage::Md]
# @return [Boolean]
def available?(device)
- analyzer.available_device?(device)
+ devices = device.ancestors.select { |a| a.parents.none? }
+ devices << device if devices.empty?
+
+ devices.all? { |d| analyzer.available_device?(d) }
end
# Whether the device can be used for installation, including the boot partitions
diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb
index 931438fc87..e70896782e 100644
--- a/service/lib/y2storage/agama_proposal.rb
+++ b/service/lib/y2storage/agama_proposal.rb
@@ -138,7 +138,8 @@ def calculate_initial_planned(devicegraph)
@planned_devices = planner.planned_devices(config)
end
- # Performs the mandatory space-making actions on the given devicegraph
+ # Performs the mandatory partition actions for making space on the given devicegraph. The
+ # actions for making space in the volume groups are performed later by the devices creator.
#
# @param devicegraph [Devicegraph] the graph gets modified
def clean_graph(devicegraph)
diff --git a/service/lib/y2storage/proposal/agama_md_planner.rb b/service/lib/y2storage/proposal/agama_md_planner.rb
index 71e4fb48eb..763939bf10 100644
--- a/service/lib/y2storage/proposal/agama_md_planner.rb
+++ b/service/lib/y2storage/proposal/agama_md_planner.rb
@@ -32,6 +32,8 @@ class AgamaMdPlanner < AgamaDevicePlanner
# @param config [Agama::Storage::Config]
# @return [Array]
def planned_devices(md_config, config)
+ return [] if md_config.search&.skip_device?
+
md = planned_md(md_config, config)
register_partitionable(md, md_config)
[md]
diff --git a/service/lib/y2storage/proposal/agama_vg_planner.rb b/service/lib/y2storage/proposal/agama_vg_planner.rb
index e993bc0b6e..7dbf1462a1 100644
--- a/service/lib/y2storage/proposal/agama_vg_planner.rb
+++ b/service/lib/y2storage/proposal/agama_vg_planner.rb
@@ -29,6 +29,8 @@ class AgamaVgPlanner < AgamaDevicePlanner
# @param vg_config [Agama::Storage::Configs::VolumeGroup]
# @return [Array]
def planned_devices(vg_config)
+ return [] if vg_config.search&.skip_device?
+
[planned_vg(vg_config)]
end
@@ -95,15 +97,18 @@ def planned_lvs(config)
# @param config [Agama::Storage::Configs::VolumeGroup]
# @return [Array]
def planned_normal_lvs(config)
- configs = config.logical_volumes.reject(&:pool?).reject(&:thin_volume?)
- configs.map { |c| planned_lv(c, LvType::NORMAL) }
+ valid_lv_configs(config)
+ .reject(&:pool?)
+ .reject(&:thin_volume?)
+ .map { |c| planned_lv(c, LvType::NORMAL) }
end
# @param config [Agama::Storage::Configs::VolumeGroup]
# @return [Array]
def planned_thin_pool_lvs(config)
- pool_configs = config.logical_volumes.select(&:pool?)
- pool_configs.map { |c| planned_thin_pool_lv(c, config) }
+ valid_lv_configs(config)
+ .select(&:pool?)
+ .map { |c| planned_thin_pool_lv(c, config) }
end
# Plan a thin pool logical volume and its thin volumes.
@@ -147,6 +152,17 @@ def planned_lv(config, type)
configure_reuse(planned, config)
end
end
+
+ # Valid logical volume configs to plan for.
+ #
+ # @param config [Agama::Storage::Configs::VolumeGroup]
+ # @return [Array]
+ def valid_lv_configs(config)
+ config.logical_volumes
+ .reject(&:delete?)
+ .reject(&:delete_if_needed?)
+ .reject { |c| c.search&.skip_device? }
+ end
end
end
end
diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes
index 2371a8634e..1166b49e42 100644
--- a/service/package/rubygem-agama-yast.changes
+++ b/service/package/rubygem-agama-yast.changes
@@ -1,3 +1,9 @@
+-------------------------------------------------------------------
+Mon Apr 13 15:22:25 UTC 2026 - José Iván López González
+
+- Adapt storage model to reuse LVM volume groups
+ (gh#agama-project/agama#3380).
+
-------------------------------------------------------------------
Fri Apr 10 08:03:07 UTC 2026 - Lidong Zhong
diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb
index 185988fe6d..0c2d31161e 100644
--- a/service/test/agama/dbus/storage/manager_test.rb
+++ b/service/test/agama/dbus/storage/manager_test.rb
@@ -209,12 +209,14 @@ def parse(string)
allow(proposal.storage_system).to receive(:candidate_md_raids).and_return(candidate_raids)
allow(proposal.storage_system).to receive(:candidate_devices)
.and_return(candidate_drives + candidate_raids)
+ allow(proposal.storage_system).to receive(:available_volume_groups).and_return(available_vgs)
end
let(:available_drives) { [] }
let(:candidate_drives) { [] }
let(:available_raids) { [] }
let(:candidate_raids) { [] }
+ let(:available_vgs) { [] }
describe "serialized_system[:availableDrives]" do
context "if there is no available drives" do
@@ -306,6 +308,29 @@ def parse(string)
end
end
+ describe "serialized_system[:availableVolumeGroups]" do
+ context "if there is no available volume groups" do
+ let(:available_vgs) { [] }
+
+ it "returns an empty list" do
+ expect(parse(subject.serialized_system)[:availableVolumeGroups]).to eq([])
+ end
+ end
+
+ context "if there are available volume groups" do
+ let(:available_vgs) { [vg1, vg2, vg3] }
+
+ let(:vg1) { instance_double(Y2Storage::LvmVg, sid: 200) }
+ let(:vg2) { instance_double(Y2Storage::LvmVg, sid: 201) }
+ let(:vg3) { instance_double(Y2Storage::LvmVg, sid: 202) }
+
+ it "retuns the id of each volume group" do
+ result = parse(subject.serialized_system)[:availableVolumeGroups]
+ expect(result).to contain_exactly(200, 201, 202)
+ end
+ end
+ end
+
describe "serialized_system[:issues]" do
context "if there is no candidate drives" do
let(:candidate_drives) { [] }
diff --git a/service/test/agama/storage/config_checkers/logical_volume_test.rb b/service/test/agama/storage/config_checkers/logical_volume_test.rb
index b2023c8298..9d270bf675 100644
--- a/service/test/agama/storage/config_checkers/logical_volume_test.rb
+++ b/service/test/agama/storage/config_checkers/logical_volume_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -26,7 +26,7 @@
describe Agama::Storage::ConfigCheckers::LogicalVolume do
include_context "config"
- subject { described_class.new(lv_config, config, product_config) }
+ subject { described_class.new(lv_config, vg_config, config, product_config) }
let(:config_json) do
{
@@ -34,6 +34,7 @@
{
logicalVolumes: [
{
+ search: search,
filesystem: filesystem,
encryption: encryption,
usedPool: pool
@@ -48,13 +49,16 @@
}
end
+ let(:search) { nil }
let(:filesystem) { nil }
let(:encryption) { nil }
let(:pool) { nil }
- let(:lv_config) { config.volume_groups.first.logical_volumes.first }
+ let(:vg_config) { config.volume_groups.first }
+ let(:lv_config) { vg_config.logical_volumes.first }
describe "#issues" do
+ include_examples "search issues"
include_examples "filesystem issues"
include_examples "encryption issues"
diff --git a/service/test/agama/storage/config_checkers/md_raid_test.rb b/service/test/agama/storage/config_checkers/md_raid_test.rb
index bd83dc4209..7f7c8ab7a5 100644
--- a/service/test/agama/storage/config_checkers/md_raid_test.rb
+++ b/service/test/agama/storage/config_checkers/md_raid_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -126,180 +126,6 @@
end
end
- context "if the MD RAID is reused" do
- let(:scenario) { "md_disks.yaml" }
- let(:search) { "/dev/md0" }
-
- before { solve_config }
-
- context "and there is a config reusing a device member" do
- let(:drives) do
- [
- {
- alias: "vda",
- search: "/dev/vda",
- filesystem: member_filesystem,
- partitions: member_partitions
- }
- ]
- end
-
- let(:member_filesystem) { nil }
- let(:member_partitions) { nil }
-
- context "and the member config has filesystem" do
- let(:member_filesystem) { { path: "/" } }
-
- it "includes the expected issue" do
- issues = subject.issues
- expect(issues).to include an_object_having_attributes(
- kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
- description: /.*vda.*cannot be formatted.*part of.*md0/
- )
- end
- end
-
- context "and the member config has partitions" do
- let(:member_partitions) do
- [
- {
- filesystem: { path: "/" }
- }
- ]
- end
-
- it "includes the expected issue" do
- issues = subject.issues
- expect(issues).to include an_object_having_attributes(
- kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
- description: /.*vda.*cannot be partitioned.*part of.*md0/
- )
- end
- end
-
- context "and the member config is used by other device" do
- let(:volume_groups) do
- [
- {
- physicalVolumes: ["vda"]
- }
- ]
- end
-
- it "includes the expected issue" do
- issues = subject.issues
- expect(issues).to include an_object_having_attributes(
- kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
- description: /.*vda.*cannot be used.*part of.*md0/
- )
- end
- end
-
- context "and the member config is deleted" do
- let(:scenario) { "md_raids.yaml" }
-
- let(:drives) do
- [
- {
- search: "/dev/vda",
- partitions: [
- {
- search: "/dev/vda1",
- delete: true
- }
- ]
- }
- ]
- end
-
- it "includes the expected issue" do
- issues = subject.issues
- expect(issues).to include an_object_having_attributes(
- kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
- description: /.*vda1.*cannot be deleted.*part of.*md0/
- )
- end
- end
-
- context "and the member config is resized" do
- let(:scenario) { "md_raids.yaml" }
-
- let(:drives) do
- [
- {
- search: "/dev/vda",
- partitions: [
- {
- search: "/dev/vda1",
- size: "2 GiB"
- }
- ]
- }
- ]
- end
-
- it "includes the expected issue" do
- issues = subject.issues
- expect(issues).to include an_object_having_attributes(
- kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
- description: /.*vda1.*cannot be resized.*part of.*md0/
- )
- end
- end
- end
-
- context "and a member is indirectly deleted (i.e., the drive is formatted)" do
- let(:scenario) { "md_raids.yaml" }
-
- let(:drives) do
- [
- {
- search: "/dev/vda",
- filesystem: { path: "/data" }
- }
- ]
- end
-
- it "includes the expected issue" do
- issues = subject.issues
- expect(issues).to include an_object_having_attributes(
- kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
- description: /.*vda.*cannot be formatted.*part of.*md0/
- )
- end
- end
- end
-
- context "if the MD RAID is valid" do
- let(:config_json) do
- {
- drives: [
- { alias: "md-disk" },
- { alias: "md-disk" }
- ],
- mdRaids: [
- {
- alias: "md",
- level: "raid0",
- devices: ["md-disk"]
- }
- ],
- volumeGroups: [
- {
- name: "vg",
- physicalVolumes: ["md"]
- }
- ]
- }
- end
-
- before { solve_config }
-
- it "does not report issues" do
- expect(subject.issues).to eq([])
- end
- end
-
context "if the reused MD RAID is valid" do
let(:scenario) { "md_disks.yaml" }
diff --git a/service/test/agama/storage/config_checkers/search_test.rb b/service/test/agama/storage/config_checkers/search_test.rb
index 15f31583d7..7057b296db 100644
--- a/service/test/agama/storage/config_checkers/search_test.rb
+++ b/service/test/agama/storage/config_checkers/search_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -19,25 +19,40 @@
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
-require_relative "../storage_helpers"
+require_relative "../config_context"
require "agama/storage/config_checkers/search"
-require "y2storage/disk"
+require "agama/storage/issue_classes"
describe Agama::Storage::ConfigCheckers::Search do
- subject { described_class.new(config) }
+ include_context "config"
- let(:config) { Agama::Storage::Configs::Drive.new }
+ subject { described_class.new(device_config, config) }
describe "#issues" do
+ before { solve_config }
+
+ let(:config_json) do
+ {
+ drives: [
+ { search: search }
+ ]
+ }
+ end
+
+ let(:scenario) { "disks.yaml" }
+ let(:search) { nil }
+ let(:device_config) { config.drives.first }
+
context "if the device is not found" do
- before do
- config.search.solve
+ let(:search) do
+ {
+ condition: { name: "/dev/unknown" },
+ ifNotFound: if_not_found
+ }
end
context "and the device can be skipped" do
- before do
- config.search.if_not_found = :skip
- end
+ let(:if_not_found) { "skip" }
it "does not include any issue" do
expect(subject.issues).to be_empty
@@ -45,9 +60,7 @@
end
context "and the device should be created instead" do
- before do
- config.search.if_not_found = :create
- end
+ let(:if_not_found) { "create" }
it "does not include any issue" do
expect(subject.issues).to be_empty
@@ -55,26 +68,360 @@
end
context "and the device cannot be skipped or created" do
- before do
- config.search.if_not_found = :error
- end
+ let(:if_not_found) { "error" }
it "includes the expected issue" do
issues = subject.issues
expect(issues).to include an_object_having_attributes(
kind: Agama::Storage::IssueClasses::Config::SEARCH_NOT_FOUND,
- description: "Mandatory drive not found"
+ description: "Mandatory device /dev/unknown not found"
)
end
end
end
- context "if the device is found" do
- before do
- config.search.solve(disk)
+ context "if a MD RAID is reused" do
+ let(:config_json) do
+ {
+ drives: drives,
+ mdRaids: [
+ {
+ search: search,
+ filesystem: filesystem,
+ encryption: encryption,
+ partitions: partitions
+ }
+ ],
+ volumeGroups: volume_groups
+ }
+ end
+
+ let(:drives) { nil }
+ let(:search) { "/dev/md0" }
+ let(:filesystem) { nil }
+ let(:encryption) { nil }
+ let(:partitions) { nil }
+ let(:volume_groups) { nil }
+
+ let(:scenario) { "md_disks.yaml" }
+ let(:device_config) { config.md_raids.first }
+
+ context "and there is a config reusing a device member" do
+ let(:drives) do
+ [
+ {
+ alias: "vda",
+ search: "/dev/vda",
+ filesystem: member_filesystem,
+ partitions: member_partitions
+ }
+ ]
+ end
+
+ let(:member_filesystem) { nil }
+ let(:member_partitions) { nil }
+
+ context "and the member config has filesystem" do
+ let(:member_filesystem) { { path: "/" } }
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
+ description: /.*vda.*cannot be formatted.*part of.* MD RAID .*md0/
+ )
+ end
+ end
+
+ context "and the member config has partitions" do
+ let(:member_partitions) do
+ [
+ {
+ filesystem: { path: "/" }
+ }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
+ description: /.*vda.*cannot be partitioned.*part of.* MD RAID .*md0/
+ )
+ end
+ end
+
+ context "and the member config is used by other device" do
+ let(:volume_groups) do
+ [
+ {
+ physicalVolumes: ["vda"]
+ }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
+ description: /.*vda.*cannot be used.*part of.* MD RAID .*md0/
+ )
+ end
+ end
+
+ context "and the member config is deleted" do
+ let(:scenario) { "md_raids.yaml" }
+
+ let(:drives) do
+ [
+ {
+ search: "/dev/vda",
+ partitions: [
+ {
+ search: "/dev/vda1",
+ delete: true
+ }
+ ]
+ }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
+ description: /.*vda1.*cannot be deleted.*part of.* MD RAID .*md0/
+ )
+ end
+ end
+
+ context "and the member config is resized" do
+ let(:scenario) { "md_raids.yaml" }
+
+ let(:drives) do
+ [
+ {
+ search: "/dev/vda",
+ partitions: [
+ {
+ search: "/dev/vda1",
+ size: "2 GiB"
+ }
+ ]
+ }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
+ description: /.*vda1.*cannot be resized.*part of.* MD RAID .*md0/
+ )
+ end
+ end
+
+ context "and a member is indirectly deleted (i.e., the drive is formatted)" do
+ let(:scenario) { "md_raids.yaml" }
+
+ let(:drives) do
+ [
+ {
+ search: "/dev/vda",
+ filesystem: { path: "/data" }
+ }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_MD_MEMBER,
+ description: /.*vda.*cannot be formatted.*part of.* MD RAID .*md0/
+ )
+ end
+ end
+ end
+ end
+
+ context "if a volume group is reused" do
+ let(:config_json) do
+ {
+ drives: drives,
+ volumeGroups: volume_groups,
+ mdRaids: md_raids
+ }
+ end
+
+ let(:drives) { [] }
+ let(:md_raids) { [] }
+
+ let(:volume_groups) do
+ [
+ { search: "/dev/vg0" }
+ ]
+ end
+
+ let(:device_config) { config.volume_groups.first }
+ let(:scenario) { "lvm-over-raids.yaml" }
+
+ context "and there is a config reusing a physical volume" do
+ let(:md_raids) do
+ [
+ {
+ alias: "md0",
+ search: "/dev/md0",
+ filesystem: member_filesystem,
+ partitions: member_partitions
+ }
+ ]
+ end
+
+ let(:member_filesystem) { nil }
+ let(:member_partitions) { nil }
+
+ context "and the member config has filesystem" do
+ let(:member_filesystem) { { path: "/" } }
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_PV,
+ description: /.*md0.*cannot be formatted.*physical volume of .*volume group .*vg0/
+ )
+ end
+ end
+
+ context "and the member config has partitions" do
+ let(:member_partitions) do
+ [
+ {
+ filesystem: { path: "/" }
+ }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_PV,
+ description: /.*md0.*cannot be partitioned.*physical volume of.*volume group .*vg0/
+ )
+ end
+ end
+
+ context "and the member config is used by other device" do
+ let(:volume_groups) do
+ [
+ { search: "/dev/vg0" },
+ { physicalVolumes: ["md0"] }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_PV,
+ description: /.*md0.*cannot be used.*physical volume of.*volume group .*vg0/
+ )
+ end
+ end
+
+ context "and the member config is deleted" do
+ let(:scenario) { "several_vgs.yaml" }
+
+ let(:drives) do
+ [
+ {
+ search: "/dev/sda",
+ partitions: [
+ {
+ search: "/dev/sda3",
+ delete: true
+ }
+ ]
+ }
+ ]
+ end
+
+ let(:volume_groups) do
+ [
+ { search: "/dev/data" }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_PV,
+ description: /.*sda3.*cannot be deleted.*physical volume of.* volume group .*data/
+ )
+ end
+ end
+
+ context "and the member config is resized" do
+ let(:scenario) { "several_vgs.yaml" }
+
+ let(:drives) do
+ [
+ {
+ search: "/dev/sda",
+ partitions: [
+ {
+ search: "/dev/sda3",
+ size: "2 GiB"
+ }
+ ]
+ }
+ ]
+ end
+
+ let(:volume_groups) do
+ [
+ { search: "/dev/data" }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_PV,
+ description: /.*sda3.*cannot be resized.*physical volume of.* volume group .*data/
+ )
+ end
+ end
+
+ context "and a member is indirectly deleted (parent device is formatted)" do
+ let(:scenario) { "several_vgs.yaml" }
+
+ let(:drives) do
+ [
+ {
+ search: "/dev/sda",
+ filesystem: { path: "/data" }
+ }
+ ]
+ end
+
+ let(:volume_groups) do
+ [
+ { search: "/dev/data" }
+ ]
+ end
+
+ it "includes the expected issue" do
+ issues = subject.issues
+ expect(issues).to include an_object_having_attributes(
+ kind: Agama::Storage::IssueClasses::Config::MISUSED_PV,
+ description: /.*sda.*cannot be formatted.*physical volume of.* volume group .*data/
+ )
+ end
+ end
end
+ end
- let(:disk) { instance_double(Y2Storage::Disk) }
+ context "if the device is found" do
+ let(:search) { "/dev/vda" }
it "does not include an issue" do
expect(subject.issues.size).to eq(0)
diff --git a/service/test/agama/storage/config_checkers/volume_group_test.rb b/service/test/agama/storage/config_checkers/volume_group_test.rb
index e267b7b596..3c182fcc23 100644
--- a/service/test/agama/storage/config_checkers/volume_group_test.rb
+++ b/service/test/agama/storage/config_checkers/volume_group_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,6 +20,7 @@
# find current contact information at www.suse.com.
require_relative "../config_context"
+require_relative "./examples"
require "agama/storage/config_checkers/volume_group"
describe Agama::Storage::ConfigCheckers::VolumeGroup do
@@ -35,18 +36,22 @@
volumeGroups: [
{
name: name,
+ search: search,
physicalVolumes: physical_volumes
}
]
}
end
- let(:name) { nil }
+ let(:name) { "vg0" }
+ let(:search) { nil }
let(:physical_volumes) { nil }
let(:vg_config) { config.volume_groups.first }
describe "#issues" do
+ include_examples "search issues"
+
context "if the volume group has no name" do
let(:name) { nil }
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/boot_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/boot_test.rb
new file mode 100644
index 0000000000..fdd2666c69
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/boot_test.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "../../../../test_helper"
+require "agama/storage/config_conversions/from_model_conversions/boot"
+require "agama/storage/configs/boot"
+require "agama/storage/configs/drive"
+require "agama/storage/configs/search"
+
+describe Agama::Storage::ConfigConversions::FromModelConversions::Boot do
+ subject do
+ described_class.new(model_json, targets)
+ end
+
+ let(:model_json) do
+ {
+ configure: configure,
+ device: {
+ default: default,
+ name: name
+ }
+ }
+ end
+
+ let(:configure) { false }
+ let(:default) { false }
+ let(:name) { nil }
+
+ let(:targets) { [] }
+
+ describe "#convert" do
+ it "returns a boot config" do
+ config = subject.convert
+ expect(config).to be_a(Agama::Storage::Configs::Boot)
+ end
+
+ context "if boot is not set to be configured" do
+ let(:configure) { false }
+ let(:default) { true }
+ let(:name) { "/dev/vda" }
+
+ it "returns the expected config" do
+ config = subject.convert
+ expect(config.configure?).to eq(false)
+ expect(config.device.default?).to eq(true)
+ expect(config.device.device_alias).to be_nil
+ end
+ end
+
+ context "if boot is set to be configured" do
+ let(:configure) { true }
+
+ context "and the boot device is set to default" do
+ let(:default) { true }
+ let(:name) { "/dev/vda" }
+
+ it "returns the expected config" do
+ config = subject.convert
+ expect(config.configure?).to eq(true)
+ expect(config.device.default?).to eq(true)
+ expect(config.device.device_alias).to be_nil
+ end
+ end
+
+ context "and the boot device is not set to default" do
+ let(:default) { false }
+
+ context "and the boot device does not specify 'name'" do
+ let(:name) { nil }
+
+ it "returns the expected config" do
+ config = subject.convert
+ expect(config.configure?).to eq(true)
+ expect(config.device.default?).to eq(false)
+ expect(config.device.device_alias).to be_nil
+ end
+ end
+
+ context "and the boot device specifies a 'name'" do
+ let(:name) { "/dev/vda" }
+
+ context "and there is a target for the given boot device name" do
+ let(:targets) { [drive] }
+
+ let(:drive) do
+ Agama::Storage::Configs::Drive.new.tap do |drive|
+ drive.search = Agama::Storage::Configs::Search.new.tap { |s| s.name = name }
+ end
+ end
+
+ it "sets an alias to the drive config" do
+ subject.convert
+ expect(drive.alias).to_not be_nil
+ end
+
+ it "returns the expected config" do
+ config = subject.convert
+ expect(config.configure?).to eq(true)
+ expect(config.device.default?).to eq(false)
+ expect(config.device.device_alias).to eq(drive.alias)
+ end
+ end
+
+ context "and there is not a target for the given boot device name" do
+ let(:drives) { [] }
+
+ it "returns the expected config" do
+ config = subject.convert
+ expect(config.configure?).to eq(true)
+ expect(config.device.default?).to eq(false)
+ expect(config.device.device_alias).to be_nil
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/config_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/config_test.rb
new file mode 100644
index 0000000000..3dda8cf065
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/config_test.rb
@@ -0,0 +1,487 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "./context"
+require "agama/config"
+require "agama/storage/config"
+require "agama/storage/config_conversions/from_model_conversions/config"
+require "agama/storage/configs/boot"
+require "agama/storage/configs/boot_device"
+require "agama/storage/configs/drive"
+require "agama/storage/configs/encryption"
+require "agama/storage/configs/md_raid"
+
+describe Agama::Storage::ConfigConversions::FromModelConversions::Config do
+ include_context "from model conversions"
+
+ subject do
+ described_class.new(model_json, product_config, storage_system)
+ end
+
+ describe "#convert" do
+ let(:model_json) do
+ {
+ encryption: encryption,
+ boot: boot,
+ drives: drives,
+ volumeGroups: volume_groups,
+ mdRaids: md_raids
+ }
+ end
+
+ let(:encryption) { nil }
+ let(:boot) { nil }
+ let(:drives) { nil }
+ let(:volume_groups) { nil }
+ let(:md_raids) { nil }
+
+ it "returns a storage config" do
+ config = subject.convert
+ expect(config).to be_a(Agama::Storage::Config)
+ end
+
+ context "if 'boot' is not specified" do
+ let(:boot) { nil }
+
+ it "sets #boot to the expected value" do
+ config = subject.convert
+ expect(config.boot).to be_a(Agama::Storage::Configs::Boot)
+ expect(config.boot.configure).to eq(true)
+ expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice)
+ expect(config.boot.device.default).to eq(true)
+ expect(config.boot.device.device_alias).to be_nil
+ end
+ end
+
+ context "if 'drives' is not specified" do
+ let(:drives) { nil }
+
+ it "sets #drives to the expected value" do
+ config = subject.convert
+ expect(config.drives).to be_empty
+ end
+ end
+
+ context "if 'volumeGroups' is not specified" do
+ let(:volume_groups) { nil }
+
+ it "sets #volume_groups to the expected value" do
+ config = subject.convert
+ expect(config.volume_groups).to be_empty
+ end
+ end
+
+ context "if 'mdRaids' is not specified" do
+ let(:md_raids) { nil }
+
+ it "sets #md_raids to the expected value" do
+ config = subject.convert
+ expect(config.md_raids).to be_empty
+ end
+ end
+
+ context "if 'boot' is specified" do
+ let(:boot) do
+ {
+ configure: true,
+ device: {
+ default: true
+ }
+ }
+ end
+
+ it "sets #boot to the expected value" do
+ config = subject.convert
+ boot = config.boot
+ expect(boot.configure?).to eq(true)
+ expect(boot.device.default?).to eq(true)
+ expect(boot.device.device_alias).to be_nil
+ end
+
+ context "and there is a drive config for the given boot device name" do
+ let(:boot) do
+ {
+ configure: true,
+ device: {
+ default: false,
+ name: "/dev/vda"
+ }
+ }
+ end
+
+ let(:drives) do
+ [
+ { name: "/dev/vda" }
+ ]
+ end
+
+ it "does not add more drives" do
+ config = subject.convert
+ expect(config.drives.size).to eq(1)
+ expect(config.drives.first.search.name).to eq("/dev/vda")
+ end
+
+ it "sets an alias to the drive config" do
+ config = subject.convert
+ drive = config.drives.first
+ expect(drive.alias).to_not be_nil
+ end
+
+ it "sets #boot to the expected value" do
+ config = subject.convert
+ boot = config.boot
+ drive = config.drives.first
+ expect(boot.configure?).to eq(true)
+ expect(boot.device.default?).to eq(false)
+ expect(boot.device.device_alias).to eq(drive.alias)
+ end
+ end
+
+ context "and there is not a drive config for the given boot device name" do
+ let(:boot) do
+ {
+ configure: true,
+ device: {
+ default: false,
+ name: "/dev/vda"
+ }
+ }
+ end
+
+ let(:drives) do
+ [
+ { name: "/dev/vdb" }
+ ]
+ end
+
+ it "adds a drive for the boot device" do
+ config = subject.convert
+ expect(config.drives.size).to eq(2)
+
+ drive = config.drives.find { |d| d.search.name == "/dev/vda" }
+ expect(drive.alias).to_not be_nil
+ expect(drive.partitions).to be_empty
+ end
+
+ it "sets #boot to the expected value" do
+ config = subject.convert
+ boot = config.boot
+ drive = config.drives.find { |d| d.search.name == "/dev/vda" }
+ expect(boot.configure?).to eq(true)
+ expect(boot.device.default?).to eq(false)
+ expect(boot.device.device_alias).to eq(drive.alias)
+ end
+ end
+
+ context "and there is a MD RAID config for the given boot device name" do
+ let(:boot) do
+ {
+ configure: true,
+ device: {
+ default: false,
+ name: "/dev/md0"
+ }
+ }
+ end
+
+ let(:md_raids) do
+ [
+ { name: "/dev/md0" }
+ ]
+ end
+
+ it "does not add more MD RAIDs" do
+ config = subject.convert
+ expect(config.md_raids.size).to eq(1)
+ expect(config.md_raids.first.search.name).to eq("/dev/md0")
+ end
+
+ it "sets an alias to the MD RAID config" do
+ config = subject.convert
+ md = config.md_raids.first
+ expect(md.alias).to_not be_nil
+ end
+
+ it "sets #boot to the expected value" do
+ config = subject.convert
+ boot = config.boot
+ md = config.md_raids.first
+ expect(boot.configure?).to eq(true)
+ expect(boot.device.default?).to eq(false)
+ expect(boot.device.device_alias).to eq(md.alias)
+ end
+ end
+
+ context "and there is not a MD RAID config for the given boot device name" do
+ let(:scenario) { "md_raids.yaml" }
+
+ let(:boot) do
+ {
+ configure: true,
+ device: {
+ default: false,
+ name: "/dev/md0"
+ }
+ }
+ end
+
+ let(:md_raids) do
+ [
+ { name: "/dev/md1" }
+ ]
+ end
+
+ it "adds a MD RAID for the boot device" do
+ config = subject.convert
+ expect(config.md_raids.size).to eq(2)
+
+ md = config.md_raids.find { |d| d.search.name == "/dev/md0" }
+ expect(md.alias).to_not be_nil
+ expect(md.partitions).to be_empty
+ end
+
+ it "sets #boot to the expected value" do
+ config = subject.convert
+ boot = config.boot
+ md = config.md_raids.find { |d| d.search.name == "/dev/md0" }
+ expect(boot.configure?).to eq(true)
+ expect(boot.device.default?).to eq(false)
+ expect(boot.device.device_alias).to eq(md.alias)
+ end
+ end
+ end
+
+ context "if 'drives' is specified" do
+ context "with an empty list" do
+ let(:drives) { [] }
+
+ it "sets #drives to the expected value" do
+ config = subject.convert
+ expect(config.drives).to eq([])
+ end
+ end
+
+ context "with a list of drives" do
+ let(:drives) do
+ [
+ { name: "/dev/vda" },
+ { name: "/dev/vdb" }
+ ]
+ end
+
+ it "sets #drives to the expected value" do
+ config = subject.convert
+ expect(config.drives.size).to eq(2)
+ expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive))
+
+ drive1, drive2 = config.drives
+ expect(drive1.search.name).to eq("/dev/vda")
+ expect(drive1.partitions).to eq([])
+ expect(drive2.search.name).to eq("/dev/vdb")
+ expect(drive2.partitions).to eq([])
+ end
+ end
+ end
+
+ context "if 'mdRaids' is specified" do
+ context "with an empty list" do
+ let(:md_raids) { [] }
+
+ it "sets #md_raids to the expected value" do
+ config = subject.convert
+ expect(config.md_raids).to eq([])
+ end
+ end
+
+ context "with a list of raids" do
+ let(:md_raids) do
+ [
+ { name: "/dev/md0" },
+ { name: "/dev/md1" }
+ ]
+ end
+
+ it "sets #md_raids to the expected value" do
+ config = subject.convert
+ expect(config.md_raids.size).to eq(2)
+ expect(config.md_raids).to all(be_a(Agama::Storage::Configs::MdRaid))
+
+ md_raid1, md_raid2 = config.md_raids
+ expect(md_raid1.search.name).to eq("/dev/md0")
+ expect(md_raid1.partitions).to eq([])
+ expect(md_raid2.search.name).to eq("/dev/md1")
+ expect(md_raid2.partitions).to eq([])
+ end
+ end
+ end
+
+ context "if 'volumeGroups' is specified" do
+ context "with an empty list" do
+ let(:volume_groups) { [] }
+
+ it "sets #volume_groups to the expected value" do
+ config = subject.convert
+ expect(config.volume_groups).to eq([])
+ end
+ end
+
+ context "with a list of volume groups" do
+ let(:volume_groups) do
+ [
+ { name: "/dev/vg0" },
+ { name: "/dev/vg1" }
+ ]
+ end
+
+ it "sets #volume_groups to the expected value" do
+ config = subject.convert
+ expect(config.volume_groups.size).to eq(2)
+ expect(config.volume_groups).to all(be_a(Agama::Storage::Configs::VolumeGroup))
+
+ vg1, vg2 = config.volume_groups
+ expect(vg1.search.name).to eq("/dev/vg0")
+ expect(vg1.logical_volumes).to eq([])
+ expect(vg2.search.name).to eq("/dev/vg1")
+ expect(vg2.logical_volumes).to eq([])
+ end
+ end
+
+ context "if a volume group specifies 'targetDevices'" do
+ let(:scenario) { "md_raids.yaml" }
+
+ let(:volume_groups) { [{ targetDevices: ["/dev/vda", "/dev/vdb", "/dev/md0"] }] }
+
+ let(:drives) do
+ [
+ { name: "/dev/vda" },
+ { name: "/dev/vdc" }
+ ]
+ end
+
+ let(:md_raids) do
+ [
+ { name: "/dev/md1" }
+ ]
+ end
+
+ it "adds the missing drives" do
+ config = subject.convert
+ expect(config.drives.size).to eq(3)
+ expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive))
+ expect(config.drives).to include(an_object_having_attributes({ device_name: "/dev/vdb" }))
+ end
+
+ it "adds the missing MD RAIDs" do
+ config = subject.convert
+ expect(config.md_raids.size).to eq(2)
+ expect(config.md_raids).to all(be_a(Agama::Storage::Configs::MdRaid))
+ expect(config.md_raids)
+ .to include(an_object_having_attributes({ device_name: "/dev/md0" }))
+ end
+ end
+ end
+
+ context "if 'encryption' is specified" do
+ let(:encryption) do
+ {
+ method: "luks1",
+ password: "12345"
+ }
+ end
+
+ let(:drives) do
+ [
+ {
+ name: "/dev/vda",
+ partitions: [
+ {
+ name: "/dev/vda1",
+ mountPath: "/test"
+ },
+ {
+ name: "/dev/vda2",
+ mountPath: "/test2",
+ filesystem: { reuse: true }
+ },
+ {
+ mountPath: "/boot/efi"
+ },
+ {
+ mountPath: "/test3"
+ },
+ {}
+ ]
+ }
+ ]
+ end
+
+ let(:md_raids) do
+ [
+ {
+ name: "/dev/md0",
+ partitions: [
+ { name: "/dev/md0-p1" },
+ {}
+ ]
+ }
+ ]
+ end
+
+ let(:volume_groups) do
+ [
+ {
+ vgName: "system",
+ targetDevices: ["/dev/vda"]
+ }
+ ]
+ end
+
+ it "sets #encryption to the newly formatted partitions, except the boot-related ones" do
+ config = subject.convert
+ partitions = config.partitions
+ new_partitions = partitions.reject(&:search)
+ reused_partitions = partitions.select(&:search)
+ mounted_partitions, reformatted_partitions = reused_partitions.partition do |part|
+ part.filesystem.reuse?
+ end
+ new_non_boot_partitions, new_boot_partitions = new_partitions.partition do |part|
+ part.filesystem&.path != "/boot/efi"
+ end
+
+ expect(new_non_boot_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1))
+ expect(new_non_boot_partitions.map { |p| p.encryption.password }).to all(eq("12345"))
+ expect(reformatted_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1))
+ expect(reformatted_partitions.map { |p| p.encryption.password }).to all(eq("12345"))
+ expect(mounted_partitions.map(&:encryption)).to all(be_nil)
+ expect(new_boot_partitions.map(&:encryption)).to all(be_nil)
+ end
+
+ it "sets #encryption for the automatically created physical volumes" do
+ config = subject.convert
+ volume_group = config.volume_groups.first
+ target_encryption = volume_group.physical_volumes_encryption
+
+ expect(target_encryption.method.id).to eq(:luks1)
+ expect(target_encryption.password).to eq("12345")
+ end
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/context.rb b/service/test/agama/storage/config_conversions/from_model_conversions/context.rb
new file mode 100644
index 0000000000..6775c89f5b
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/context.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "../../storage_helpers"
+require_relative "../../product_config_context"
+require "agama/storage/system"
+require "y2storage/encryption_method"
+
+shared_context "from model conversions" do
+ include Agama::RSpec::StorageHelpers
+
+ include_context "product config"
+
+ before do
+ mock_storage(devicegraph: scenario)
+
+ # Speed up tests by avoding real check of TPM presence.
+ allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true)
+ end
+
+ let(:scenario) { "disks.yaml" }
+
+ let(:storage_system) { Agama::Storage::System.new }
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/drive_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/drive_test.rb
new file mode 100644
index 0000000000..7e2f25e4be
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/drive_test.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "./context"
+require_relative "./examples"
+require "agama/storage/config_conversions/from_model_conversions/drive"
+require "agama/storage/configs/drive"
+require "agama/storage/configs/search"
+
+describe Agama::Storage::ConfigConversions::FromModelConversions::Drive do
+ include_context "from model conversions"
+
+ subject do
+ described_class.new(model_json, product_config)
+ end
+
+ describe "#convert" do
+ let(:model_json) { {} }
+
+ it "returns a drive config" do
+ config = subject.convert
+ expect(config).to be_a(Agama::Storage::Configs::Drive)
+ end
+
+ context "if 'name' is not specified" do
+ let(:model_json) { {} }
+
+ it "sets #search to the expected value" do
+ config = subject.convert
+ expect(config.search).to be_a(Agama::Storage::Configs::Search)
+ expect(config.search.name).to be_nil
+ expect(config.search.if_not_found).to eq(:error)
+ end
+ end
+
+ context "if neither 'mountPath' nor 'filesystem' are specified" do
+ let(:model_json) { {} }
+ include_examples "without filesystem"
+ end
+
+ context "if 'ptableType' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without ptableType"
+ end
+
+ context "if 'spacePolicy' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without spacePolicy", :partitions
+ end
+
+ context "if 'name' is specified" do
+ let(:model_json) { { name: name } }
+ include_examples "with name"
+ end
+
+ context "if 'mountPath' is specified" do
+ let(:model_json) { { mountPath: mountPath } }
+ include_examples "with mountPath"
+ end
+
+ context "if 'filesystem' is specified" do
+ let(:model_json) { { filesystem: filesystem } }
+ include_examples "with filesystem"
+ end
+
+ context "if 'mountPath' and 'filesystem' are specified" do
+ let(:model_json) { { mountPath: mountPath, filesystem: filesystem } }
+ include_examples "with mountPath and filesystem"
+ end
+
+ context "if 'ptableType' is specified" do
+ let(:model_json) { { ptableType: ptableType } }
+ include_examples "with ptableType"
+ end
+
+ context "if 'partitions' is specified" do
+ let(:model_json) { { partitions: partitions } }
+ include_examples "with partitions"
+ end
+
+ context "if 'spacePolicy' is specified" do
+ let(:model_json) { { spacePolicy: spacePolicy } }
+ include_examples "with spacePolicy"
+ end
+
+ context "if 'spacePolicy' and 'partitions' are specified" do
+ let(:model_json) { { spacePolicy: spacePolicy, partitions: partitions } }
+ include_examples "with spacePolicy and volumes"
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/examples.rb b/service/test/agama/storage/config_conversions/from_model_conversions/examples.rb
new file mode 100644
index 0000000000..57603851a7
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/examples.rb
@@ -0,0 +1,837 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "../../../../test_helper"
+require "agama/storage/configs/btrfs"
+require "agama/storage/configs/filesystem"
+require "agama/storage/configs/partition"
+require "agama/storage/configs/search"
+require "agama/storage/configs/size"
+require "y2storage/filesystems/type"
+require "y2storage/refinements"
+
+using Y2Storage::Refinements::SizeCasts
+
+shared_examples "without filesystem" do
+ it "does not set #filesystem" do
+ config = subject.convert
+ expect(config.filesystem).to be_nil
+ end
+end
+
+shared_examples "without ptableType" do
+ it "does not set #ptable_type" do
+ config = subject.convert
+ expect(config.ptable_type).to be_nil
+ end
+end
+
+shared_examples "without spacePolicy" do |volumes_property|
+ context "if the default space policy is 'keep'" do
+ let(:space_policy) { "keep" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = config.public_send(volumes_property)
+ expect(volumes).to be_empty
+ end
+ end
+
+ context "if the default space policy is 'delete'" do
+ let(:space_policy) { "delete" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = config.public_send(volumes_property)
+ expect(volumes.size).to eq(1)
+
+ volume = volumes.first
+ expect(volume.search.name).to be_nil
+ expect(volume.search.if_not_found).to eq(:skip)
+ expect(volume.search.max).to be_nil
+ expect(volume.delete?).to eq(true)
+ end
+ end
+
+ context "if the default space policy is 'resize'" do
+ let(:space_policy) { "resize" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = config.public_send(volumes_property)
+ expect(volumes.size).to eq(1)
+
+ volume = volumes.first
+ expect(volume.search.name).to be_nil
+ expect(volume.search.if_not_found).to eq(:skip)
+ expect(volume.search.max).to be_nil
+ expect(volume.delete?).to eq(false)
+ expect(volume.size.default?).to eq(false)
+ expect(volume.size.min).to eq(Y2Storage::DiskSize.zero)
+ expect(volume.size.max).to be_nil
+ end
+ end
+
+ context "if the default space policy is 'custom'" do
+ let(:space_policy) { "custom" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = config.public_send(volumes_property)
+ expect(volumes).to be_empty
+ end
+ end
+end
+
+shared_examples "without size" do
+ it "sets #size to default size" do
+ config = subject.convert
+ expect(config.size.default?).to eq(true)
+ expect(config.size.min).to be_nil
+ expect(config.size.max).to be_nil
+ end
+end
+
+shared_examples "without delete" do
+ it "sets #delete to false" do
+ config = subject.convert
+ expect(config.delete?).to eq(false)
+ end
+end
+
+shared_examples "without deleteIfNeeded" do
+ it "sets #delete_if_needed to false" do
+ config = subject.convert
+ expect(config.delete_if_needed?).to eq(false)
+ end
+end
+
+shared_examples "with name" do
+ let(:name) { "/dev/vda" }
+
+ it "sets #search to the expected value" do
+ config = subject.convert
+ expect(config.search).to be_a(Agama::Storage::Configs::Search)
+ expect(config.search.name).to eq("/dev/vda")
+ expect(config.search.max).to be_nil
+ expect(config.search.if_not_found).to eq(:error)
+ end
+end
+
+shared_examples "with mountPath" do
+ let(:mountPath) { "/test" }
+
+ it "sets #filesystem to the expected value" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type).to be_nil
+ expect(filesystem.label).to be_nil
+ expect(filesystem.path).to eq("/test")
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to be_empty
+ expect(filesystem.mount_options).to be_empty
+ end
+end
+
+shared_examples "with filesystem" do
+ let(:filesystem) do
+ {
+ reuse: reuse,
+ default: default,
+ type: type,
+ label: label
+ }
+ end
+
+ let(:reuse) { false }
+ let(:default) { false }
+ let(:type) { nil }
+ let(:label) { "test" }
+
+ context "if the filesystem is default" do
+ let(:default) { true }
+
+ RSpec.shared_examples "#filesystem set to default btrfs" do
+ it "sets #filesystem to the expected btrfs-related values" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type.default?).to eq(true)
+ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS)
+ expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs)
+ expect(filesystem.label).to eq("test")
+ expect(filesystem.path).to be_nil
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to be_empty
+ expect(filesystem.mount_options).to be_empty
+ end
+ end
+
+ context "and the type is 'btrfs'" do
+ let(:type) { "btrfs" }
+
+ include_examples "#filesystem set to default btrfs"
+
+ it "sets Btrfs snapshots to false" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem.type.btrfs.snapshots?).to eq(false)
+ end
+ end
+
+ context "and the type is 'btrfsSnapshots'" do
+ let(:type) { "btrfsSnapshots" }
+
+ include_examples "#filesystem set to default btrfs"
+
+ it "sets Btrfs snapshots to true" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem.type.btrfs.snapshots?).to eq(true)
+ end
+ end
+
+ context "and the type is 'btrfsImmutable'" do
+ let(:type) { "btrfsSnapshots" }
+
+ include_examples "#filesystem set to default btrfs"
+
+ it "sets Btrfs snapshots to true" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem.type.btrfs.snapshots?).to eq(true)
+ end
+ end
+
+ context "and the type is not 'btrfs'" do
+ let(:type) { "xfs" }
+
+ it "sets #filesystem to the expected value" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type.default?).to eq(true)
+ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS)
+ expect(filesystem.type.btrfs).to be_nil
+ expect(filesystem.label).to eq("test")
+ expect(filesystem.path).to be_nil
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to be_empty
+ expect(filesystem.mount_options).to be_empty
+ end
+ end
+ end
+
+ context "if the filesystem is not default" do
+ let(:default) { false }
+
+ RSpec.shared_examples "#filesystem set to non-default btrfs" do
+ it "sets #filesystem to the expected btrfs-related values" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type.default?).to eq(false)
+ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS)
+ expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs)
+ expect(filesystem.label).to eq("test")
+ expect(filesystem.path).to be_nil
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to be_empty
+ expect(filesystem.mount_options).to be_empty
+ end
+ end
+
+ context "and the type is 'btrfs'" do
+ let(:type) { "btrfs" }
+
+ include_examples "#filesystem set to non-default btrfs"
+
+ it "sets Btrfs snapshots to false" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem.type.btrfs.snapshots?).to eq(false)
+ end
+ end
+
+ context "and the type is 'btrfsSnapshots'" do
+ let(:type) { "btrfsSnapshots" }
+
+ include_examples "#filesystem set to non-default btrfs"
+
+ it "sets Btrfs snapshots to true" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem.type.btrfs.snapshots?).to eq(true)
+ end
+ end
+
+ context "and the type is 'btrfsImmutable'" do
+ let(:type) { "btrfsImmutable" }
+
+ include_examples "#filesystem set to non-default btrfs"
+
+ it "sets Btrfs snapshots to true" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem.type.btrfs.snapshots?).to eq(true)
+ end
+ end
+
+ context "and the type is not 'btrfs'" do
+ let(:type) { "xfs" }
+
+ it "sets #filesystem to the expected value" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type.default?).to eq(false)
+ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS)
+ expect(filesystem.type.btrfs).to be_nil
+ expect(filesystem.label).to eq("test")
+ expect(filesystem.path).to be_nil
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to be_empty
+ expect(filesystem.mount_options).to be_empty
+ end
+ end
+ end
+
+ context "if the filesystem specifies 'reuse'" do
+ let(:reuse) { true }
+
+ it "sets #filesystem to the expected value" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(true)
+ expect(filesystem.type.default?).to eq(false)
+ expect(filesystem.type.fs_type).to be_nil
+ expect(filesystem.type.btrfs).to be_nil
+ expect(filesystem.label).to eq("test")
+ expect(filesystem.path).to be_nil
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to be_empty
+ expect(filesystem.mount_options).to be_empty
+ end
+ end
+
+ context "if the filesystem does not specify 'type'" do
+ let(:type) { nil }
+
+ it "sets #filesystem to the expected value" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type.default?).to eq(false)
+ expect(filesystem.type.fs_type).to be_nil
+ expect(filesystem.type.btrfs).to be_nil
+ expect(filesystem.label).to eq("test")
+ expect(filesystem.path).to be_nil
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to eq([])
+ expect(filesystem.mount_options).to eq([])
+ end
+ end
+
+ context "if the filesystem does not specify 'label'" do
+ let(:label) { nil }
+
+ it "sets #filesystem to the expected value" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type.default?).to eq(false)
+ expect(filesystem.type.fs_type).to be_nil
+ expect(filesystem.type.btrfs).to be_nil
+ expect(filesystem.label).to be_nil
+ expect(filesystem.path).to be_nil
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to eq([])
+ expect(filesystem.mount_options).to eq([])
+ end
+ end
+end
+
+shared_examples "with mountPath and filesystem" do
+ let(:mountPath) { "/test" }
+
+ let(:filesystem) do
+ {
+ default: false,
+ type: "btrfs",
+ label: "test"
+ }
+ end
+
+ it "sets #filesystem to the expected value" do
+ config = subject.convert
+ filesystem = config.filesystem
+ expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
+ expect(filesystem.reuse?).to eq(false)
+ expect(filesystem.type.default?).to eq(false)
+ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS)
+ expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs)
+ expect(filesystem.label).to eq("test")
+ expect(filesystem.path).to eq("/test")
+ expect(filesystem.mount_by).to be_nil
+ expect(filesystem.mkfs_options).to be_empty
+ expect(filesystem.mount_options).to be_empty
+ end
+end
+
+shared_examples "with ptableType" do
+ let(:ptableType) { "gpt" }
+
+ it "sets #ptable_type to the expected value" do
+ config = subject.convert
+ expect(config.ptable_type).to eq(Y2Storage::PartitionTables::Type::GPT)
+ end
+end
+
+shared_examples "with size" do
+ context "if the size is default" do
+ let(:size) do
+ {
+ default: true,
+ min: 1.GiB.to_i,
+ max: 10.GiB.to_i
+ }
+ end
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ size = config.size
+ expect(size).to be_a(Agama::Storage::Configs::Size)
+ expect(size.default?).to eq(true)
+ expect(size.min).to eq(1.GiB)
+ expect(size.max).to eq(10.GiB)
+ end
+ end
+
+ context "if the size is not default" do
+ let(:size) do
+ {
+ default: false,
+ min: 1.GiB.to_i,
+ max: 10.GiB.to_i
+ }
+ end
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ size = config.size
+ expect(size).to be_a(Agama::Storage::Configs::Size)
+ expect(size.default?).to eq(false)
+ expect(size.min).to eq(1.GiB)
+ expect(size.max).to eq(10.GiB)
+ end
+ end
+
+ context "if the size does not spicify 'max'" do
+ let(:size) do
+ {
+ default: false,
+ min: 1.GiB.to_i
+ }
+ end
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ size = config.size
+ expect(size).to be_a(Agama::Storage::Configs::Size)
+ expect(size.default?).to eq(false)
+ expect(size.min).to eq(1.GiB)
+ expect(size.max).to eq(Y2Storage::DiskSize.unlimited)
+ end
+ end
+end
+
+shared_examples "with partitions" do
+ context "with an empty list" do
+ let(:partitions) { [] }
+
+ it "sets #partitions to empty" do
+ config = subject.convert
+ expect(config.partitions).to eq([])
+ end
+ end
+
+ context "with a list of partitions" do
+ let(:partitions) do
+ [
+ { mountPath: "/" },
+ { mountPath: "/test" }
+ ]
+ end
+
+ it "sets #partitions to the expected value" do
+ config = subject.convert
+ partitions = config.partitions
+ expect(partitions.size).to eq(2)
+
+ partition1, partition2 = partitions
+ expect(partition1).to be_a(Agama::Storage::Configs::Partition)
+ expect(partition1.filesystem.path).to eq("/")
+ expect(partition2).to be_a(Agama::Storage::Configs::Partition)
+ expect(partition2.filesystem.path).to eq("/test")
+ end
+ end
+end
+
+shared_examples "with spacePolicy" do
+ def volumes_config(config)
+ config.respond_to?(:logical_volumes) ? config.logical_volumes : config.partitions
+ end
+
+ context "if space policy is 'keep'" do
+ let(:spacePolicy) { "keep" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes).to be_empty
+ end
+ end
+
+ context "if space policy is 'delete'" do
+ let(:spacePolicy) { "delete" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes.size).to eq(1)
+
+ volume = volumes.first
+ expect(volume.search.name).to be_nil
+ expect(volume.search.if_not_found).to eq(:skip)
+ expect(volume.search.max).to be_nil
+ expect(volume.delete?).to eq(true)
+ end
+ end
+
+ context "if space policy is 'resize'" do
+ let(:spacePolicy) { "resize" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes.size).to eq(1)
+
+ volume = volumes.first
+ expect(volume.search.name).to be_nil
+ expect(volume.search.if_not_found).to eq(:skip)
+ expect(volume.search.max).to be_nil
+ expect(volume.delete?).to eq(false)
+ expect(volume.size.default?).to eq(false)
+ expect(volume.size.min).to eq(Y2Storage::DiskSize.zero)
+ expect(volume.size.max).to be_nil
+ end
+ end
+
+ context "if space policy is 'custom'" do
+ let(:spacePolicy) { "custom" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes).to be_empty
+ end
+ end
+end
+
+shared_examples "with spacePolicy and volumes" do
+ def volumes_config(config)
+ config.respond_to?(:logical_volumes) ? config.logical_volumes : config.partitions
+ end
+
+ let(:volumes_json) do
+ [
+ # Reused volumes with some usage.
+ {
+ name: "/dev/vol1",
+ mountPath: "/test1",
+ size: { default: true, min: 10.GiB.to_i }
+ },
+ # Reused volume with some usage.
+ {
+ name: "/dev/vol2",
+ mountPath: "/test2",
+ resizeIfNeeded: true,
+ size: { default: false, min: 10.GiB.to_i }
+ },
+ # Reused volume with some usage.
+ {
+ name: "/dev/vol3",
+ mountPath: "/test3",
+ resize: true,
+ size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i }
+ },
+ # Reused volume representing a space action (resize).
+ {
+ name: "/dev/vol4",
+ resizeIfNeeded: true,
+ size: { default: false, min: 10.GiB.to_i }
+ },
+ # Reused volume representing a space action (resize).
+ {
+ name: "/dev/vol5",
+ resize: true,
+ size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i }
+ },
+ # Reused volume representing a space action (delete).
+ {
+ name: "/dev/vol6",
+ delete: true
+ },
+ # Reused volume representing a space action (delete).
+ {
+ name: "/dev/vol7",
+ deleteIfNeeded: true
+ },
+ # Reused volume representing a space action (keep).
+ {
+ name: "/dev/vol8"
+ },
+ # New volume.
+ {},
+ # New volume.
+ {
+ mountPath: "/",
+ resizeIfNeeded: true,
+ size: { default: false, min: 10.GiB.to_i },
+ filesystem: { type: "btrfs" }
+ }
+ ]
+ end
+
+ let(:partitions) { volumes_json }
+ let(:logical_volumes) { volumes_json }
+
+ context "if space policy is 'keep'" do
+ let(:spacePolicy) { "keep" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes.size).to eq(5)
+ expect(volumes[0].search.name).to eq("/dev/vol1")
+ expect(volumes[1].search.name).to eq("/dev/vol2")
+ expect(volumes[2].search.name).to eq("/dev/vol3")
+ expect(volumes[3].filesystem).to be_nil
+ expect(volumes[4].filesystem.path).to eq("/")
+ end
+ end
+
+ context "if space policy is 'delete'" do
+ let(:spacePolicy) { "delete" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes.size).to eq(6)
+ expect(volumes[0].search.name).to eq("/dev/vol1")
+ expect(volumes[1].search.name).to eq("/dev/vol2")
+ expect(volumes[2].search.name).to eq("/dev/vol3")
+ expect(volumes[3].filesystem).to be_nil
+ expect(volumes[4].filesystem.path).to eq("/")
+ expect(volumes[5].search.name).to be_nil
+ expect(volumes[5].search.max).to be_nil
+ expect(volumes[5].delete).to eq(true)
+ end
+ end
+
+ context "if space policy is 'resize'" do
+ let(:spacePolicy) { "resize" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes.size).to eq(6)
+ expect(volumes[0].search.name).to eq("/dev/vol1")
+ expect(volumes[1].search.name).to eq("/dev/vol2")
+ expect(volumes[2].search.name).to eq("/dev/vol3")
+ expect(volumes[3].filesystem).to be_nil
+ expect(volumes[4].filesystem.path).to eq("/")
+ expect(volumes[5].search.name).to be_nil
+ expect(volumes[5].search.max).to be_nil
+ expect(volumes[5].size.default?).to eq(false)
+ expect(volumes[5].size.min).to eq(Y2Storage::DiskSize.zero)
+ expect(volumes[5].size.max).to be_nil
+ end
+ end
+
+ context "if space policy is 'custom'" do
+ let(:spacePolicy) { "custom" }
+
+ it "sets volumes to the expected value" do
+ config = subject.convert
+ volumes = volumes_config(config)
+ expect(volumes.size).to eq(9)
+ expect(volumes[0].search.name).to eq("/dev/vol1")
+ expect(volumes[1].search.name).to eq("/dev/vol2")
+ expect(volumes[2].search.name).to eq("/dev/vol3")
+ expect(volumes[3].filesystem).to be_nil
+ expect(volumes[4].filesystem.path).to eq("/")
+ expect(volumes[5].search.name).to eq("/dev/vol4")
+ expect(volumes[6].search.name).to eq("/dev/vol5")
+ expect(volumes[7].search.name).to eq("/dev/vol6")
+ expect(volumes[8].search.name).to eq("/dev/vol7")
+ end
+ end
+end
+
+shared_examples "with resizeIfNeeded" do
+ context "if 'resizeIfNeeded' is true" do
+ let(:resize_if_needed) { true }
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ expect(config.size).to be_a(Agama::Storage::Configs::Size)
+ expect(config.size.default?).to eq(false)
+ expect(config.size.min).to eq(Y2Storage::DiskSize.zero)
+ expect(config.size.max).to be_nil
+ end
+ end
+
+ context "if 'resizeIfNeeded' is false" do
+ let(:resize_if_needed) { false }
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ expect(config.size).to be_a(Agama::Storage::Configs::Size)
+ expect(config.size.default?).to eq(true)
+ expect(config.size.min).to be_nil
+ expect(config.size.max).to be_nil
+ end
+ end
+end
+
+shared_examples "with size and resizeIfNeeded" do
+ let(:size) do
+ {
+ default: true,
+ min: 1.GiB.to_i,
+ max: 10.GiB.to_i
+ }
+ end
+
+ context "if 'resizeIfNeeded' is true" do
+ let(:resize_if_needed) { true }
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ expect(config.size).to be_a(Agama::Storage::Configs::Size)
+ expect(config.size.default?).to eq(false)
+ expect(config.size.min).to eq(Y2Storage::DiskSize.zero)
+ expect(config.size.max).to be_nil
+ end
+ end
+
+ context "if 'resizeIfNeeded' is false" do
+ let(:resize_if_needed) { false }
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ expect(config.size).to be_a(Agama::Storage::Configs::Size)
+ expect(config.size.default?).to eq(true)
+ expect(config.size.min).to eq(1.GiB)
+ expect(config.size.max).to eq(10.GiB)
+ end
+ end
+end
+
+shared_examples "with size and resize" do
+ let(:size) do
+ {
+ default: true,
+ min: 1.GiB.to_i,
+ max: 10.GiB.to_i
+ }
+ end
+
+ context "if 'resize' is true" do
+ let(:resize) { true }
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ expect(config.size).to be_a(Agama::Storage::Configs::Size)
+ expect(config.size.default?).to eq(true)
+ expect(config.size.min).to eq(1.GiB)
+ expect(config.size.max).to eq(10.GiB)
+ end
+ end
+
+ context "if 'size' is false" do
+ let(:resize) { false }
+
+ it "sets #size to the expected value" do
+ config = subject.convert
+ expect(config.size).to be_a(Agama::Storage::Configs::Size)
+ expect(config.size.default?).to eq(true)
+ expect(config.size.min).to eq(1.GiB)
+ expect(config.size.max).to eq(10.GiB)
+ end
+ end
+end
+
+shared_examples "with delete" do
+ let(:mount_path) { nil }
+
+ it "sets #delete to true" do
+ config = subject.convert
+ expect(config.delete?).to eq(true)
+ end
+
+ context "and 'mountPath' is specified" do
+ let(:mount_path) { "/test" }
+
+ it "sets #delete to false" do
+ config = subject.convert
+ expect(config.delete?).to eq(false)
+ end
+ end
+end
+
+shared_examples "with deleteIfNeeded" do
+ let(:mount_path) { nil }
+
+ it "sets #delete_if_needed to true" do
+ config = subject.convert
+ expect(config.delete_if_needed?).to eq(true)
+ end
+
+ context "and the partition has a mount path" do
+ let(:mount_path) { "/test" }
+
+ it "sets #delete_if_needed to false" do
+ config = subject.convert
+ expect(config.delete_if_needed?).to eq(false)
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/logical_volume_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/logical_volume_test.rb
new file mode 100644
index 0000000000..15335247f6
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/logical_volume_test.rb
@@ -0,0 +1,151 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "./examples"
+require "agama/storage/config_conversions/from_model_conversions/logical_volume"
+require "agama/storage/configs/logical_volume"
+require "y2storage/refinements"
+
+using Y2Storage::Refinements::SizeCasts
+
+describe Agama::Storage::ConfigConversions::FromModelConversions::LogicalVolume do
+ subject do
+ described_class.new(model_json)
+ end
+
+ describe "#convert" do
+ let(:model_json) { {} }
+
+ it "returns a logical volume config" do
+ config = subject.convert
+ expect(config).to be_a(Agama::Storage::Configs::LogicalVolume)
+ end
+
+ context "if 'lvName' is not specified" do
+ let(:model_json) { {} }
+
+ it "does not set #name" do
+ config = subject.convert
+ expect(config.name).to be_nil
+ end
+ end
+
+ context "if 'size' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without size"
+ end
+
+ context "if neither 'mountPath' nor 'filesystem' are specified" do
+ let(:model_json) { {} }
+ include_examples "without filesystem"
+ end
+
+ context "if 'stripes' is not specified" do
+ let(:model_json) { {} }
+
+ it "does not set #stripes" do
+ config = subject.convert
+ expect(config.stripes).to be_nil
+ end
+ end
+
+ context "if 'stripeSize' is not specified" do
+ let(:model_json) { {} }
+
+ it "does not set #stripe_size" do
+ config = subject.convert
+ expect(config.stripe_size).to be_nil
+ end
+ end
+
+ context "if 'lvName' is specified" do
+ let(:model_json) { { lvName: "lv1" } }
+
+ it "sets #name to the expected value" do
+ config = subject.convert
+ expect(config.name).to eq("lv1")
+ end
+ end
+
+ context "if 'size' is specified" do
+ let(:model_json) { { size: size } }
+ include_examples "with size"
+ end
+
+ context "if 'mountPath' is specified" do
+ let(:model_json) { { mountPath: mountPath } }
+ include_examples "with mountPath"
+ end
+
+ context "if 'filesystem' is specified" do
+ let(:model_json) { { filesystem: filesystem } }
+ include_examples "with filesystem"
+ end
+
+ context "if 'mountPath' and 'filesystem' are specified" do
+ let(:model_json) { { mountPath: mountPath, filesystem: filesystem } }
+ include_examples "with mountPath and filesystem"
+ end
+
+ context "if 'stripes' is specified" do
+ let(:model_json) { { stripes: 4 } }
+
+ it "sets #stripes to the expected value" do
+ config = subject.convert
+ expect(config.stripes).to eq(4)
+ end
+ end
+
+ context "if 'stripeSize' is specified" do
+ let(:model_json) { { stripeSize: 2.KiB.to_i } }
+
+ it "sets #stripeSize to the expected value" do
+ config = subject.convert
+ expect(config.stripe_size).to eq(2.KiB)
+ end
+ end
+
+ context "if 'resizeIfNeeded' is specified" do
+ let(:model_json) { { resizeIfNeeded: resize_if_needed } }
+ include_examples "with resizeIfNeeded"
+ end
+
+ context "if 'size' and 'resizeIfNeeded' are specified" do
+ let(:model_json) { { size: size, resizeIfNeeded: resize_if_needed } }
+ include_examples "with size and resizeIfNeeded"
+ end
+
+ context "if 'size' and 'resize' are specified" do
+ let(:model_json) { { size: size, resize: resize } }
+ include_examples "with size and resize"
+ end
+
+ context "if 'delete' is specified" do
+ let(:model_json) { { delete: true, mountPath: mount_path } }
+ include_examples "with delete"
+ end
+
+ context "if 'deleteIfNeeded' is specified" do
+ let(:model_json) { { deleteIfNeeded: true, mountPath: mount_path } }
+ include_examples "with deleteIfNeeded"
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/md_raid_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/md_raid_test.rb
new file mode 100644
index 0000000000..b84abed58c
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/md_raid_test.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "./context"
+require_relative "./examples"
+require "agama/storage/config_conversions/from_model_conversions/md_raid"
+require "agama/storage/configs/md_raid"
+
+describe Agama::Storage::ConfigConversions::FromModelConversions::MdRaid do
+ include_context "from model conversions"
+
+ subject do
+ described_class.new(model_json, product_config)
+ end
+
+ describe "#convert" do
+ let(:model_json) { {} }
+
+ it "returns a MD RAID config" do
+ config = subject.convert
+ expect(config).to be_a(Agama::Storage::Configs::MdRaid)
+ end
+
+ context "if 'name' is not specified" do
+ let(:model_json) { {} }
+
+ it "sets #search to the expected value" do
+ config = subject.convert
+ expect(config.search).to be_nil
+ end
+ end
+
+ context "if neither 'mountPath' nor 'filesystem' are specified" do
+ let(:model_json) { {} }
+ include_examples "without filesystem"
+ end
+
+ context "if 'ptableType' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without ptableType"
+ end
+
+ context "if 'spacePolicy' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without spacePolicy", :partitions
+ end
+
+ context "if 'name' is specified" do
+ let(:model_json) { { name: name } }
+ include_examples "with name"
+ end
+
+ context "if 'mountPath' is specified" do
+ let(:model_json) { { mountPath: mountPath } }
+ include_examples "with mountPath"
+ end
+
+ context "if 'filesystem' is specified" do
+ let(:model_json) { { filesystem: filesystem } }
+ include_examples "with filesystem"
+ end
+
+ context "if 'mountPath' and 'filesystem' are specified" do
+ let(:model_json) { { mountPath: mountPath, filesystem: filesystem } }
+ include_examples "with mountPath and filesystem"
+ end
+
+ context "if 'ptableType' is specified" do
+ let(:model_json) { { ptableType: ptableType } }
+ include_examples "with ptableType"
+ end
+
+ context "if 'partitions' is specified" do
+ let(:model_json) { { partitions: partitions } }
+ include_examples "with partitions"
+ end
+
+ context "if 'spacePolicy' is specified" do
+ let(:model_json) { { spacePolicy: spacePolicy } }
+ include_examples "with spacePolicy"
+ end
+
+ context "if 'spacePolicy' and 'partitions' are specified" do
+ let(:model_json) { { spacePolicy: spacePolicy, partitions: partitions } }
+ include_examples "with spacePolicy and volumes"
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/partition_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/partition_test.rb
new file mode 100644
index 0000000000..55bc8f04b2
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/partition_test.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "./examples"
+require "agama/storage/config_conversions/from_model_conversions/partition"
+require "agama/storage/configs/partition"
+require "y2storage/partition_id"
+
+describe Agama::Storage::ConfigConversions::FromModelConversions::Partition do
+ subject do
+ described_class.new(model_json)
+ end
+
+ describe "#convert" do
+ let(:model_json) { {} }
+
+ it "returns a partition config" do
+ config = subject.convert
+ expect(config).to be_a(Agama::Storage::Configs::Partition)
+ end
+
+ context "if 'name' is not specified" do
+ let(:model_json) { {} }
+
+ it "does not set #search" do
+ config = subject.convert
+ expect(config.search).to be_nil
+ end
+ end
+
+ context "if a partition does not spicify 'id'" do
+ let(:model_json) { {} }
+
+ it "does not set #id" do
+ config = subject.convert
+ expect(config.id).to be_nil
+ end
+ end
+
+ context "if 'size' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without size"
+ end
+
+ context "if neither 'mountPath' nor 'filesystem' are specified" do
+ let(:model_json) { {} }
+ include_examples "without filesystem"
+ end
+
+ context "if 'delete' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without delete"
+ end
+
+ context "if 'deleteIfNeeded' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without deleteIfNeeded"
+ end
+
+ context "if 'name' is not specified" do
+ # Add mount path in order to use the partition. Otherwise the partition is omitted because it
+ # is considered a keep action.
+ let(:model_json) { { name: name, mountPath: "/test2" } }
+ include_examples "with name"
+ end
+
+ context "if 'id' is specified" do
+ let(:model_json) { { id: "esp" } }
+
+ it "sets #id to the expected value" do
+ config = subject.convert
+ expect(config.id).to eq(Y2Storage::PartitionId::ESP)
+ end
+ end
+
+ context "if 'size' is specified" do
+ let(:model_json) { { size: size } }
+ include_examples "with size"
+ end
+
+ context "if 'mountPath' is specified" do
+ let(:model_json) { { mountPath: mountPath } }
+ include_examples "with mountPath"
+ end
+
+ context "if 'filesystem' is specified" do
+ let(:model_json) { { filesystem: filesystem } }
+ include_examples "with filesystem"
+ end
+
+ context "if 'mountPath' and 'filesystem' are specified" do
+ let(:model_json) { { mountPath: mountPath, filesystem: filesystem } }
+ include_examples "with mountPath and filesystem"
+ end
+
+ context "if 'resizeIfNeeded' is specified" do
+ let(:model_json) { { resizeIfNeeded: resize_if_needed } }
+ include_examples "with resizeIfNeeded"
+ end
+
+ context "if 'size' and 'resizeIfNeeded' are specified" do
+ let(:model_json) { { size: size, resizeIfNeeded: resize_if_needed } }
+ include_examples "with size and resizeIfNeeded"
+ end
+
+ context "if 'size' and 'resize' are specified" do
+ let(:model_json) { { size: size, resize: resize } }
+ include_examples "with size and resize"
+ end
+
+ context "if 'delete' is specified" do
+ let(:model_json) { { delete: true, mountPath: mount_path } }
+ include_examples "with delete"
+ end
+
+ context "if 'deleteIfNeeded' is specified" do
+ let(:model_json) { { deleteIfNeeded: true, mountPath: mount_path } }
+ include_examples "with deleteIfNeeded"
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_conversions/volume_group_test.rb b/service/test/agama/storage/config_conversions/from_model_conversions/volume_group_test.rb
new file mode 100644
index 0000000000..07207a3c1f
--- /dev/null
+++ b/service/test/agama/storage/config_conversions/from_model_conversions/volume_group_test.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2026] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, contact SUSE LLC.
+#
+# To contact SUSE LLC about this file by physical or electronic mail, you may
+# find current contact information at www.suse.com.
+
+require_relative "./context"
+require_relative "./examples"
+require "agama/storage/config_conversions/from_model_conversions/volume_group"
+require "agama/storage/configs/drive"
+require "agama/storage/configs/logical_volume"
+require "agama/storage/configs/md_raid"
+require "agama/storage/configs/search"
+require "y2storage/refinements"
+
+using Y2Storage::Refinements::SizeCasts
+
+describe Agama::Storage::ConfigConversions::FromModelConversions::VolumeGroup do
+ include_context "from model conversions"
+
+ subject do
+ described_class.new(model_json, product_config, targets)
+ end
+
+ describe "#convert" do
+ let(:model_json) { {} }
+
+ let(:targets) { [] }
+
+ it "returns a volume group config" do
+ config = subject.convert
+ expect(config).to be_a(Agama::Storage::Configs::VolumeGroup)
+ end
+
+ context "if 'vgName' is not specified" do
+ let(:model_json) { {} }
+
+ it "does not set #name" do
+ config = subject.convert
+ expect(config.name).to be_nil
+ end
+ end
+
+ context "if 'extentSize' is not specified" do
+ let(:model_json) { {} }
+
+ it "does not set #extent_size" do
+ config = subject.convert
+ expect(config.extent_size).to be_nil
+ end
+ end
+
+ context "if 'targetDevices' is not specified" do
+ let(:model_json) { {} }
+
+ it "sets #physical_volumes_devices to the expected value" do
+ config = subject.convert
+ expect(config.physical_volumes_devices).to eq([])
+ end
+ end
+
+ context "if 'logicalVolumes' is not specified" do
+ let(:model_json) { {} }
+
+ it "sets #logical_volumes to the expected value" do
+ config = subject.convert
+ expect(config.logical_volumes).to eq([])
+ end
+ end
+
+ context "if 'spacePolicy' is not specified" do
+ let(:model_json) { {} }
+ include_examples "without spacePolicy", :logical_volumes
+ end
+
+ context "if 'vgName' is specified" do
+ let(:model_json) { { vgName: "vg1" } }
+
+ it "sets #name to the expected value" do
+ config = subject.convert
+ expect(config.name).to eq("vg1")
+ end
+ end
+
+ context "if 'extentSize' is specified" do
+ let(:model_json) { { extentSize: 1.KiB.to_i } }
+
+ it "sets #extent_size to the expected value" do
+ config = subject.convert
+ expect(config.extent_size).to eq(1.KiB)
+ end
+ end
+
+ context "if 'targetDevices' is specified" do
+ let(:model_json) { { targetDevices: ["/dev/vda", "/dev/md0"] } }
+
+ let(:drive) do
+ Agama::Storage::Configs::Drive.new.tap do |drive|
+ drive.search = Agama::Storage::Configs::Search.new.tap { |s| s.name = "/dev/vda" }
+ end
+ end
+
+ let(:md_raid) do
+ Agama::Storage::Configs::MdRaid.new.tap do |md_raid|
+ md_raid.search = Agama::Storage::Configs::Search.new.tap { |s| s.name = "/dev/md0" }
+ end
+ end
+
+ let(:targets) { [drive, md_raid] }
+
+ it "sets an alias to the target devices" do
+ subject.convert
+ expect(drive.alias).to_not be_nil
+ expect(md_raid.alias).to_not be_nil
+ end
+
+ it "sets #physical_volumes_devices to the expected value" do
+ config = subject.convert
+ expect(config.physical_volumes_devices).to eq([drive.alias, md_raid.alias])
+ end
+ end
+
+ context "if 'logicalVolumes' is specified" do
+ let(:model_json) { { logicalVolumes: logical_volumes } }
+
+ context "with an empty list" do
+ let(:logical_volumes) { [] }
+
+ it "sets #logical_volumes to the expected value" do
+ config = subject.convert
+ expect(config.logical_volumes).to eq([])
+ end
+ end
+
+ context "with a list of logical volumes" do
+ let(:logical_volumes) do
+ [
+ { lvName: "lv1" },
+ { lvName: "lv2" }
+ ]
+ end
+ it "sets #logical_volumes to the expected value" do
+ config = subject.convert
+ expect(config.logical_volumes)
+ .to all(be_a(Agama::Storage::Configs::LogicalVolume))
+ expect(config.logical_volumes.size).to eq(2)
+
+ lv1, lv2 = config.logical_volumes
+ expect(lv1.name).to eq("lv1")
+ expect(lv2.name).to eq("lv2")
+ end
+ end
+ end
+
+ context "if 'spacePolicy' is specified" do
+ let(:model_json) { { spacePolicy: spacePolicy } }
+ include_examples "with spacePolicy"
+ end
+
+ context "if 'spacePolicy' and 'logicalVolumes' are specified" do
+ let(:model_json) { { spacePolicy: spacePolicy, logicalVolumes: logical_volumes } }
+ include_examples "with spacePolicy and volumes"
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb
index 239a51d331..d1e03baa2e 100644
--- a/service/test/agama/storage/config_conversions/from_model_test.rb
+++ b/service/test/agama/storage/config_conversions/from_model_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -19,1731 +19,63 @@
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
-require_relative "../storage_helpers"
-require "agama/config"
+require_relative "./from_model_conversions/context"
require "agama/storage/config"
require "agama/storage/config_conversions/from_model"
-require "agama/storage/configs"
-require "y2storage/encryption_method"
-require "y2storage/filesystems/mount_by_type"
-require "y2storage/filesystems/type"
-require "y2storage/pbkd_function"
-require "y2storage/refinements"
-
-# TODO: this test suite requires a better organization, similar to ToJSON tests.
-
-using Y2Storage::Refinements::SizeCasts
-
-shared_examples "without filesystem" do |config_proc|
- it "does not set #filesystem" do
- config = config_proc.call(subject.convert)
- expect(config.filesystem).to be_nil
- end
-end
-
-shared_examples "without ptableType" do |config_proc|
- it "does not set #ptable_type" do
- config = config_proc.call(subject.convert)
- expect(config.ptable_type).to be_nil
- end
-end
-
-shared_examples "without spacePolicy" do |config_proc|
- context "if the default space policy is 'keep'" do
- let(:product_space_policy) { "keep" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions).to be_empty
- end
- end
-
- context "if the default space policy is 'delete'" do
- let(:product_space_policy) { "delete" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(1)
-
- partition = partitions.first
- expect(partition.search.name).to be_nil
- expect(partition.search.if_not_found).to eq(:skip)
- expect(partition.search.max).to be_nil
- expect(partition.delete?).to eq(true)
- end
- end
-
- context "if the default space policy is 'resize'" do
- let(:product_space_policy) { "resize" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(1)
-
- partition = partitions.first
- expect(partition.search.name).to be_nil
- expect(partition.search.if_not_found).to eq(:skip)
- expect(partition.search.max).to be_nil
- expect(partition.delete?).to eq(false)
- expect(partition.size.default?).to eq(false)
- expect(partition.size.min).to eq(Y2Storage::DiskSize.zero)
- expect(partition.size.max).to be_nil
- end
- end
-
- context "if the default space policy is 'custom'" do
- let(:product_space_policy) { "custom" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions).to be_empty
- end
- end
-end
-
-shared_examples "without size" do |config_proc|
- it "sets #size to default size" do
- config = config_proc.call(subject.convert)
- expect(config.size.default?).to eq(true)
- expect(config.size.min).to be_nil
- expect(config.size.max).to be_nil
- end
-end
-
-shared_examples "without delete" do |config_proc|
- it "sets #delete to false" do
- config = config_proc.call(subject.convert)
- expect(config.delete?).to eq(false)
- end
-end
-
-shared_examples "without deleteIfNeeded" do |config_proc|
- it "sets #delete_if_needed to false" do
- config = config_proc.call(subject.convert)
- expect(config.delete_if_needed?).to eq(false)
- end
-end
-
-shared_examples "with name" do |config_proc|
- let(:name) { "/dev/vda" }
-
- it "sets #search to the expected value" do
- config = config_proc.call(subject.convert)
- expect(config.search).to be_a(Agama::Storage::Configs::Search)
- expect(config.search.name).to eq("/dev/vda")
- expect(config.search.max).to be_nil
- expect(config.search.if_not_found).to eq(:error)
- end
-end
-
-shared_examples "with mountPath" do |config_proc|
- let(:mountPath) { "/test" }
-
- it "sets #filesystem to the expected value" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type).to be_nil
- expect(filesystem.label).to be_nil
- expect(filesystem.path).to eq("/test")
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to be_empty
- expect(filesystem.mount_options).to be_empty
- end
-end
-
-shared_examples "with filesystem" do |config_proc|
- let(:filesystem) do
- {
- reuse: reuse,
- default: default,
- type: type,
- label: label
- }
- end
-
- let(:reuse) { false }
- let(:default) { false }
- let(:type) { nil }
- let(:label) { "test" }
-
- context "if the filesystem is default" do
- let(:default) { true }
-
- RSpec.shared_examples "#filesystem set to default btrfs" do
- it "sets #filesystem to the expected btrfs-related values" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type.default?).to eq(true)
- expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS)
- expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs)
- expect(filesystem.label).to eq("test")
- expect(filesystem.path).to be_nil
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to be_empty
- expect(filesystem.mount_options).to be_empty
- end
- end
-
- context "and the type is 'btrfs'" do
- let(:type) { "btrfs" }
-
- include_examples "#filesystem set to default btrfs"
-
- it "sets Btrfs snapshots to false" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem.type.btrfs.snapshots?).to eq(false)
- end
- end
-
- context "and the type is 'btrfsSnapshots'" do
- let(:type) { "btrfsSnapshots" }
-
- include_examples "#filesystem set to default btrfs"
-
- it "sets Btrfs snapshots to true" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem.type.btrfs.snapshots?).to eq(true)
- end
- end
-
- context "and the type is 'btrfsImmutable'" do
- let(:type) { "btrfsSnapshots" }
-
- include_examples "#filesystem set to default btrfs"
-
- it "sets Btrfs snapshots to true" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem.type.btrfs.snapshots?).to eq(true)
- end
- end
-
- context "and the type is not 'btrfs'" do
- let(:type) { "xfs" }
-
- it "sets #filesystem to the expected value" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type.default?).to eq(true)
- expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS)
- expect(filesystem.type.btrfs).to be_nil
- expect(filesystem.label).to eq("test")
- expect(filesystem.path).to be_nil
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to be_empty
- expect(filesystem.mount_options).to be_empty
- end
- end
- end
-
- context "if the filesystem is not default" do
- let(:default) { false }
-
- RSpec.shared_examples "#filesystem set to non-default btrfs" do
- it "sets #filesystem to the expected btrfs-related values" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type.default?).to eq(false)
- expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS)
- expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs)
- expect(filesystem.label).to eq("test")
- expect(filesystem.path).to be_nil
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to be_empty
- expect(filesystem.mount_options).to be_empty
- end
- end
-
- context "and the type is 'btrfs'" do
- let(:type) { "btrfs" }
-
- include_examples "#filesystem set to non-default btrfs"
-
- it "sets Btrfs snapshots to false" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem.type.btrfs.snapshots?).to eq(false)
- end
- end
-
- context "and the type is 'btrfsSnapshots'" do
- let(:type) { "btrfsSnapshots" }
-
- include_examples "#filesystem set to non-default btrfs"
-
- it "sets Btrfs snapshots to true" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem.type.btrfs.snapshots?).to eq(true)
- end
- end
-
- context "and the type is 'btrfsImmutable'" do
- let(:type) { "btrfsImmutable" }
-
- include_examples "#filesystem set to non-default btrfs"
-
- it "sets Btrfs snapshots to true" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem.type.btrfs.snapshots?).to eq(true)
- end
- end
-
- context "and the type is not 'btrfs'" do
- let(:type) { "xfs" }
-
- it "sets #filesystem to the expected value" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type.default?).to eq(false)
- expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS)
- expect(filesystem.type.btrfs).to be_nil
- expect(filesystem.label).to eq("test")
- expect(filesystem.path).to be_nil
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to be_empty
- expect(filesystem.mount_options).to be_empty
- end
- end
- end
-
- context "if the filesystem specifies 'reuse'" do
- let(:reuse) { true }
-
- it "sets #filesystem to the expected value" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(true)
- expect(filesystem.type.default?).to eq(false)
- expect(filesystem.type.fs_type).to be_nil
- expect(filesystem.type.btrfs).to be_nil
- expect(filesystem.label).to eq("test")
- expect(filesystem.path).to be_nil
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to be_empty
- expect(filesystem.mount_options).to be_empty
- end
- end
-
- context "if the filesystem does not specify 'type'" do
- let(:type) { nil }
-
- it "sets #filesystem to the expected value" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type.default?).to eq(false)
- expect(filesystem.type.fs_type).to be_nil
- expect(filesystem.type.btrfs).to be_nil
- expect(filesystem.label).to eq("test")
- expect(filesystem.path).to be_nil
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to eq([])
- expect(filesystem.mount_options).to eq([])
- end
- end
-
- context "if the filesystem does not specify 'label'" do
- let(:label) { nil }
-
- it "sets #filesystem to the expected value" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type.default?).to eq(false)
- expect(filesystem.type.fs_type).to be_nil
- expect(filesystem.type.btrfs).to be_nil
- expect(filesystem.label).to be_nil
- expect(filesystem.path).to be_nil
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to eq([])
- expect(filesystem.mount_options).to eq([])
- end
- end
-end
-
-shared_examples "with mountPath and filesystem" do |config_proc|
- let(:mountPath) { "/test" }
-
- let(:filesystem) do
- {
- default: false,
- type: "btrfs",
- label: "test"
- }
- end
-
- it "sets #filesystem to the expected value" do
- config = config_proc.call(subject.convert)
- filesystem = config.filesystem
- expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem)
- expect(filesystem.reuse?).to eq(false)
- expect(filesystem.type.default?).to eq(false)
- expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS)
- expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs)
- expect(filesystem.label).to eq("test")
- expect(filesystem.path).to eq("/test")
- expect(filesystem.mount_by).to be_nil
- expect(filesystem.mkfs_options).to be_empty
- expect(filesystem.mount_options).to be_empty
- end
-end
-
-shared_examples "with ptableType" do |config_proc|
- let(:ptableType) { "gpt" }
-
- it "sets #ptable_type to the expected value" do
- config = config_proc.call(subject.convert)
- expect(config.ptable_type).to eq(Y2Storage::PartitionTables::Type::GPT)
- end
-end
-
-shared_examples "with size" do |config_proc|
- context "if the size is default" do
- let(:size) do
- {
- default: true,
- min: 1.GiB.to_i,
- max: 10.GiB.to_i
- }
- end
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(true)
- expect(size.min).to eq(1.GiB)
- expect(size.max).to eq(10.GiB)
- end
- end
-
- context "if the size is not default" do
- let(:size) do
- {
- default: false,
- min: 1.GiB.to_i,
- max: 10.GiB.to_i
- }
- end
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(false)
- expect(size.min).to eq(1.GiB)
- expect(size.max).to eq(10.GiB)
- end
- end
-
- context "if the size does not spicify 'max'" do
- let(:size) do
- {
- default: false,
- min: 1.GiB.to_i
- }
- end
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(false)
- expect(size.min).to eq(1.GiB)
- expect(size.max).to eq(Y2Storage::DiskSize.unlimited)
- end
- end
-end
-
-shared_examples "with partitions" do |config_proc|
- let(:partitions) do
- [
- partition,
- { mountPath: "/test" }
- ]
- end
-
- let(:partition) { { mountPath: "/" } }
-
- context "with an empty list" do
- let(:partitions) { [] }
-
- it "sets #partitions to empty" do
- config = config_proc.call(subject.convert)
- expect(config.partitions).to eq([])
- end
- end
-
- context "with a list of partitions" do
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(2)
-
- partition1, partition2 = partitions
- expect(partition1).to be_a(Agama::Storage::Configs::Partition)
- expect(partition1.filesystem.path).to eq("/")
- expect(partition2).to be_a(Agama::Storage::Configs::Partition)
- expect(partition2.filesystem.path).to eq("/test")
- end
- end
-
- partition_proc = proc { |c| config_proc.call(c).partitions.first }
-
- context "if a partition does not specify 'name'" do
- let(:partition) { {} }
-
- it "does not set #search" do
- partition = partition_proc.call(subject.convert)
- expect(partition.search).to be_nil
- end
- end
-
- context "if a partition does not spicify 'id'" do
- let(:partition) { {} }
-
- it "does not set #id" do
- partition = partition_proc.call(subject.convert)
- expect(partition.id).to be_nil
- end
- end
-
- context "if a partition does not spicify 'size'" do
- let(:partition) { {} }
- include_examples "without size", partition_proc
- end
-
- context "if a partition does not spicify neither 'mountPath' nor 'filesystem'" do
- let(:partition) { {} }
- include_examples "without filesystem", partition_proc
- end
-
- context "if a partition does not spicify 'delete'" do
- let(:partition) { {} }
- include_examples "without delete", partition_proc
- end
-
- context "if a partition does not spicify 'deleteIfNeeded'" do
- let(:partition) { {} }
- include_examples "without deleteIfNeeded", partition_proc
- end
-
- context "if a partition specifies 'name'" do
- # Add mount path in order to use the partition. Otherwise the partition is omitted because it
- # is considered a keep action.
- let(:partition) { { name: name, mountPath: "/test2" } }
- include_examples "with name", partition_proc
- end
-
- context "if a partition spicifies 'id'" do
- let(:partition) { { id: "esp" } }
-
- it "sets #id to the expected value" do
- partition = partition_proc.call(subject.convert)
- expect(partition.id).to eq(Y2Storage::PartitionId::ESP)
- end
- end
-
- context "if a partition specifies 'mountPath'" do
- let(:partition) { { mountPath: mountPath } }
- include_examples "with mountPath", partition_proc
- end
-
- context "if a partition specifies 'filesystem'" do
- let(:partition) { { filesystem: filesystem } }
- include_examples "with filesystem", partition_proc
- end
-
- context "if a partition specifies both 'mountPath' and 'filesystem'" do
- let(:partition) { { mountPath: mountPath, filesystem: filesystem } }
- include_examples "with mountPath and filesystem", partition_proc
- end
-
- context "if a partition spicifies 'size'" do
- let(:partition) { { size: size } }
- include_examples "with size", partition_proc
- end
-end
-
-shared_examples "with spacePolicy" do |config_proc|
- context "if space policy is 'keep'" do
- let(:spacePolicy) { "keep" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions).to be_empty
- end
- end
-
- context "if space policy is 'delete'" do
- let(:spacePolicy) { "delete" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(1)
-
- partition = partitions.first
- expect(partition.search.name).to be_nil
- expect(partition.search.if_not_found).to eq(:skip)
- expect(partition.search.max).to be_nil
- expect(partition.delete?).to eq(true)
- end
- end
-
- context "if space policy is 'resize'" do
- let(:spacePolicy) { "resize" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(1)
-
- partition = partitions.first
- expect(partition.search.name).to be_nil
- expect(partition.search.if_not_found).to eq(:skip)
- expect(partition.search.max).to be_nil
- expect(partition.delete?).to eq(false)
- expect(partition.size.default?).to eq(false)
- expect(partition.size.min).to eq(Y2Storage::DiskSize.zero)
- expect(partition.size.max).to be_nil
- end
- end
-
- context "if space policy is 'custom'" do
- let(:spacePolicy) { "custom" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions).to be_empty
- end
- end
-end
-
-shared_examples "with spacePolicy and partitions" do |config_proc|
- let(:partitions) do
- [
- # Reused partition with some usage.
- {
- name: "/dev/vda1",
- mountPath: "/test1",
- size: { default: true, min: 10.GiB.to_i }
- },
- # Reused partition with some usage.
- {
- name: "/dev/vda2",
- mountPath: "/test2",
- resizeIfNeeded: true,
- size: { default: false, min: 10.GiB.to_i }
- },
- # Reused partition with some usage.
- {
- name: "/dev/vda3",
- mountPath: "/test3",
- resize: true,
- size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i }
- },
- # Reused partition representing a space action (resize).
- {
- name: "/dev/vda4",
- resizeIfNeeded: true,
- size: { default: false, min: 10.GiB.to_i }
- },
- # Reused partition representing a space action (resize).
- {
- name: "/dev/vda5",
- resize: true,
- size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i }
- },
- # Reused partition representing a space action (delete).
- {
- name: "/dev/vda6",
- delete: true
- },
- # Reused partition representing a space action (delete).
- {
- name: "/dev/vda7",
- deleteIfNeeded: true
- },
- # Reused partition representing a space action (keep).
- {
- name: "/dev/vda8"
- },
- # New partition.
- {},
- # New partition.
- {
- mountPath: "/",
- resizeIfNeeded: true,
- size: { default: false, min: 10.GiB.to_i },
- filesystem: { type: "btrfs" }
- }
- ]
- end
-
- context "if space policy is 'keep'" do
- let(:spacePolicy) { "keep" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(5)
- expect(partitions[0].search.name).to eq("/dev/vda1")
- expect(partitions[1].search.name).to eq("/dev/vda2")
- expect(partitions[2].search.name).to eq("/dev/vda3")
- expect(partitions[3].filesystem).to be_nil
- expect(partitions[4].filesystem.path).to eq("/")
- end
- end
-
- context "if space policy is 'delete'" do
- let(:spacePolicy) { "delete" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(6)
- expect(partitions[0].search.name).to eq("/dev/vda1")
- expect(partitions[1].search.name).to eq("/dev/vda2")
- expect(partitions[2].search.name).to eq("/dev/vda3")
- expect(partitions[3].filesystem).to be_nil
- expect(partitions[4].filesystem.path).to eq("/")
- expect(partitions[5].search.name).to be_nil
- expect(partitions[5].search.max).to be_nil
- expect(partitions[5].delete).to eq(true)
- end
- end
-
- context "if space policy is 'resize'" do
- let(:spacePolicy) { "resize" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(6)
- expect(partitions[0].search.name).to eq("/dev/vda1")
- expect(partitions[1].search.name).to eq("/dev/vda2")
- expect(partitions[2].search.name).to eq("/dev/vda3")
- expect(partitions[3].filesystem).to be_nil
- expect(partitions[4].filesystem.path).to eq("/")
- expect(partitions[5].search.name).to be_nil
- expect(partitions[5].search.max).to be_nil
- expect(partitions[5].size.default?).to eq(false)
- expect(partitions[5].size.min).to eq(Y2Storage::DiskSize.zero)
- expect(partitions[5].size.max).to be_nil
- end
- end
-
- context "if space policy is 'custom'" do
- let(:spacePolicy) { "custom" }
-
- it "sets #partitions to the expected value" do
- config = config_proc.call(subject.convert)
- partitions = config.partitions
- expect(partitions.size).to eq(9)
- expect(partitions[0].search.name).to eq("/dev/vda1")
- expect(partitions[1].search.name).to eq("/dev/vda2")
- expect(partitions[2].search.name).to eq("/dev/vda3")
- expect(partitions[3].filesystem).to be_nil
- expect(partitions[4].filesystem.path).to eq("/")
- expect(partitions[5].search.name).to eq("/dev/vda4")
- expect(partitions[6].search.name).to eq("/dev/vda5")
- expect(partitions[7].search.name).to eq("/dev/vda6")
- expect(partitions[8].search.name).to eq("/dev/vda7")
- end
-
- context "if a partition spicifies 'resizeIfNeeded'" do
- let(:partitions) { [{ resizeIfNeeded: resizeIfNeeded }] }
-
- context "if 'resizeIfNeeded' is true" do
- let(:resizeIfNeeded) { true }
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.partitions.first.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(false)
- expect(size.min).to eq(Y2Storage::DiskSize.zero)
- expect(size.max).to be_nil
- end
- end
-
- context "if 'resizeIfNeeded' is false" do
- let(:resizeIfNeeded) { false }
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.partitions.first.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(true)
- expect(size.min).to be_nil
- expect(size.max).to be_nil
- end
- end
- end
-
- context "if a partition spicifies both 'size' and 'resizeIfNeeded'" do
- let(:partitions) { [{ size: size, resizeIfNeeded: resizeIfNeeded }] }
-
- let(:size) do
- {
- default: true,
- min: 1.GiB.to_i,
- max: 10.GiB.to_i
- }
- end
-
- context "if 'resizeIfNeeded' is true" do
- let(:resizeIfNeeded) { true }
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.partitions.first.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(false)
- expect(size.min).to eq(Y2Storage::DiskSize.zero)
- expect(size.max).to be_nil
- end
- end
-
- context "if 'resizeIfNeeded' is false" do
- let(:resizeIfNeeded) { false }
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.partitions.first.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(true)
- expect(size.min).to eq(1.GiB)
- expect(size.max).to eq(10.GiB)
- end
- end
- end
-
- context "if a partition spicifies both 'size' and 'resize'" do
- let(:partitions) { [{ size: size, resize: resize }] }
-
- let(:size) do
- {
- default: true,
- min: 1.GiB.to_i,
- max: 10.GiB.to_i
- }
- end
-
- context "if 'resize' is true" do
- let(:resize) { true }
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.partitions.first.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(true)
- expect(size.min).to eq(1.GiB)
- expect(size.max).to eq(10.GiB)
- end
- end
-
- context "if 'size' is false" do
- let(:resize) { false }
-
- it "sets #size to the expected value" do
- config = config_proc.call(subject.convert)
- size = config.partitions.first.size
- expect(size).to be_a(Agama::Storage::Configs::Size)
- expect(size.default?).to eq(true)
- expect(size.min).to eq(1.GiB)
- expect(size.max).to eq(10.GiB)
- end
- end
- end
-
- context "if a partition specifies 'delete'" do
- let(:partitions) { [{ delete: true, mountPath: mount_path }] }
-
- let(:mount_path) { nil }
-
- it "sets #delete to true" do
- config = config_proc.call(subject.convert)
- partition = config.partitions.first
- expect(partition.delete?).to eq(true)
- end
-
- context "and the partition has a mount path" do
- let(:mount_path) { "/test" }
-
- it "sets #delete to false" do
- config = config_proc.call(subject.convert)
- partition = config.partitions.first
- expect(partition.delete?).to eq(false)
- end
- end
- end
-
- context "if a partition specifies 'deleteIfNeeded'" do
- let(:partitions) { [{ deleteIfNeeded: true, mountPath: mount_path }] }
-
- let(:mount_path) { nil }
-
- it "sets #delete_if_needed to true" do
- config = config_proc.call(subject.convert)
- partition = config.partitions.first
- expect(partition.delete_if_needed?).to eq(true)
- end
-
- context "and the partition has a mount path" do
- let(:mount_path) { "/test" }
-
- it "sets #delete_if_needed to false" do
- config = config_proc.call(subject.convert)
- partition = config.partitions.first
- expect(partition.delete_if_needed?).to eq(false)
- end
- end
- end
- end
-end
describe Agama::Storage::ConfigConversions::FromModel do
- include Agama::RSpec::StorageHelpers
+ include_context "from model conversions"
subject do
described_class.new(model_json, product_config: product_config)
end
- let(:product_config) do
- Agama::Config.new({ "storage" => { "space_policy" => product_space_policy } })
- end
-
- let(:product_space_policy) { nil }
-
- before do
- mock_storage(devicegraph: scenario)
-
- # Speed up tests by avoding real check of TPM presence.
- allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true)
- end
-
- let(:scenario) { "disks.yaml" }
-
describe "#convert" do
- let(:model_json) { {} }
+ let(:model_json) do
+ {
+ boot: {
+ configure: true
+ },
+ drives: [
+ { name: "/dev/vda" }
+ ],
+ mdRaids: [
+ { name: "/dev/md0" }
+ ],
+ volumeGroups: [
+ { name: "/dev/vg0" }
+ ]
+ }
+ end
it "returns a storage config" do
config = subject.convert
expect(config).to be_a(Agama::Storage::Config)
- end
-
- context "with an empty JSON" do
- let(:model_json) { {} }
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- expect(boot).to be_a(Agama::Storage::Configs::Boot)
- expect(boot.configure?).to eq(true)
- expect(boot.device).to be_a(Agama::Storage::Configs::BootDevice)
- expect(boot.device.default?).to eq(true)
- expect(boot.device.device_alias).to be_nil
- end
-
- it "sets #drives to the expected value" do
- config = subject.convert
- expect(config.drives).to be_empty
- end
-
- it "sets #volume_groups to the expected value" do
- config = subject.convert
- expect(config.volume_groups).to be_empty
- end
- end
-
- context "with a JSON specifying 'boot'" do
- let(:model_json) do
- {
- boot: {
- configure: configure,
- device: {
- default: default,
- name: name
- }
- },
- drives: drives,
- mdRaids: md_raids
- }
- end
-
- let(:default) { false }
- let(:name) { nil }
- let(:drives) { [] }
- let(:md_raids) { [] }
-
- context "if boot is set to be configured" do
- let(:configure) { true }
-
- context "and the boot device is set to default" do
- let(:default) { true }
- let(:name) { "/dev/vda" }
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- expect(boot.configure?).to eq(true)
- expect(boot.device.default?).to eq(true)
- expect(boot.device.device_alias).to be_nil
- end
- end
-
- context "and the boot device is not set to default" do
- let(:default) { false }
-
- context "and the boot device does not specify 'name'" do
- let(:name) { nil }
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- expect(boot.configure?).to eq(true)
- expect(boot.device.default?).to eq(false)
- expect(boot.device.device_alias).to be_nil
- end
- end
-
- context "and the boot device specifies a 'name'" do
- context "and there is a drive config for the given boot device name" do
- let(:name) { "/dev/vda" }
-
- let(:drives) do
- [
- { name: "/dev/vda" }
- ]
- end
-
- it "does not add more drives" do
- config = subject.convert
- expect(config.drives.size).to eq(1)
- expect(config.drives.first.search.name).to eq("/dev/vda")
- end
-
- it "sets an alias to the drive config" do
- config = subject.convert
- drive = config.drives.first
- expect(drive.alias).to_not be_nil
- end
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- drive = config.drives.first
- expect(boot.configure?).to eq(true)
- expect(boot.device.default?).to eq(false)
- expect(boot.device.device_alias).to eq(drive.alias)
- end
- end
-
- context "and there is not a drive config for the given boot device name" do
- let(:name) { "/dev/vda" }
-
- let(:drives) do
- [
- { name: "/dev/vdb" }
- ]
- end
-
- it "adds a drive for the boot device" do
- config = subject.convert
- expect(config.drives.size).to eq(2)
-
- drive = config.drives.find { |d| d.search.name == name }
- expect(drive.alias).to_not be_nil
- expect(drive.partitions).to be_empty
- end
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- drive = config.drives.find { |d| d.search.name == name }
- expect(boot.configure?).to eq(true)
- expect(boot.device.default?).to eq(false)
- expect(boot.device.device_alias).to eq(drive.alias)
- end
- end
-
- context "and there is a MD RAID config for the given boot device name" do
- let(:name) { "/dev/md0" }
-
- let(:md_raids) do
- [
- { name: "/dev/md0" }
- ]
- end
-
- it "does not add more MD RAIDs" do
- config = subject.convert
- expect(config.md_raids.size).to eq(1)
- expect(config.md_raids.first.search.name).to eq("/dev/md0")
- end
-
- it "sets an alias to the MD RAID config" do
- config = subject.convert
- md = config.md_raids.first
- expect(md.alias).to_not be_nil
- end
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- md = config.md_raids.first
- expect(boot.configure?).to eq(true)
- expect(boot.device.default?).to eq(false)
- expect(boot.device.device_alias).to eq(md.alias)
- end
- end
-
- context "and there is not a MD RAID config for the given boot device name" do
- let(:scenario) { "md_raids.yaml" }
-
- let(:name) { "/dev/md0" }
-
- let(:md_raids) do
- [
- { name: "/dev/md1" }
- ]
- end
-
- it "adds a MD RAID for the boot device" do
- config = subject.convert
- expect(config.md_raids.size).to eq(2)
-
- md = config.md_raids.find { |d| d.search.name == name }
- expect(md.alias).to_not be_nil
- expect(md.partitions).to be_empty
- end
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- md = config.md_raids.find { |d| d.search.name == name }
- expect(boot.configure?).to eq(true)
- expect(boot.device.default?).to eq(false)
- expect(boot.device.device_alias).to eq(md.alias)
- end
- end
- end
- end
- end
-
- context "if boot is not set to be configured" do
- let(:configure) { false }
- let(:default) { true }
- let(:name) { "/dev/vda" }
-
- it "sets #boot to the expected value" do
- config = subject.convert
- boot = config.boot
- expect(boot.configure?).to eq(false)
- expect(boot.device.default?).to eq(true)
- expect(boot.device.device_alias).to be_nil
- end
- end
- end
-
- context "with a JSON specifying 'encryption'" do
- let(:model_json) do
- {
- encryption: {
- method: "luks1",
- password: "12345"
- },
- drives: [
- {
- name: "/dev/vda",
- partitions: [
- {
- name: "/dev/vda1",
- mountPath: "/test"
- },
- {
- name: "/dev/vda2",
- mountPath: "/test2",
- filesystem: { reuse: true }
- },
- {
- size: { default: false, min: 256.MiB.to_i },
- mountPath: "/boot/efi"
- },
- {
- size: { default: false, min: 1.GiB.to_i },
- mountPath: "/test3"
- },
- {}
- ]
- }
- ],
- mdRaids: [
- {
- name: "/dev/md0",
- partitions: [
- { name: "/dev/md0-p1" },
- {}
- ]
- }
- ],
- volumeGroups: [
- {
- vgName: "system",
- targetDevices: ["/dev/vda"]
- }
- ]
- }
- end
-
- it "sets #encryption to the newly formatted partitions, except the boot-related ones" do
- config = subject.convert
- partitions = config.partitions
- new_partitions = partitions.reject(&:search)
- reused_partitions = partitions.select(&:search)
- mounted_partitions, reformatted_partitions = reused_partitions.partition do |part|
- part.filesystem.reuse?
- end
- new_non_boot_partitions, new_boot_partitions = new_partitions.partition do |part|
- part.filesystem&.path != "/boot/efi"
- end
-
- expect(new_non_boot_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1))
- expect(new_non_boot_partitions.map { |p| p.encryption.password }).to all(eq("12345"))
- expect(reformatted_partitions.map { |p| p.encryption.method.id }).to all(eq(:luks1))
- expect(reformatted_partitions.map { |p| p.encryption.password }).to all(eq("12345"))
- expect(mounted_partitions.map(&:encryption)).to all(be_nil)
- expect(new_boot_partitions.map(&:encryption)).to all(be_nil)
- end
-
- it "sets #encryption for the automatically created physical volumes" do
- config = subject.convert
- volume_group = config.volume_groups.first
- target_encryption = volume_group.physical_volumes_encryption
-
- expect(target_encryption.method.id).to eq(:luks1)
- expect(target_encryption.password).to eq("12345")
- end
- end
-
- context "with a JSON specifying 'drives'" do
- let(:model_json) do
- { drives: drives }
- end
-
- let(:drives) do
- [
- drive,
- { name: "/dev/vdb" }
- ]
- end
-
- let(:drive) do
- { name: "/dev/vda" }
- end
-
- context "with an empty list" do
- let(:drives) { [] }
-
- it "sets #drives to the expected value" do
- config = subject.convert
- expect(config.drives).to eq([])
- end
- end
-
- context "with a list of drives" do
- it "sets #drives to the expected value" do
- config = subject.convert
- expect(config.drives.size).to eq(2)
- expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive))
-
- drive1, drive2 = config.drives
- expect(drive1.search.name).to eq("/dev/vda")
- expect(drive1.partitions).to eq([])
- expect(drive2.search.name).to eq("/dev/vdb")
- expect(drive2.partitions).to eq([])
- end
- end
-
- drive_proc = proc { |c| c.drives.first }
-
- context "if a drive does not specify 'name'" do
- let(:drive) { {} }
-
- it "sets #search to the expected value" do
- drive = drive_proc.call(subject.convert)
- expect(drive.search).to be_a(Agama::Storage::Configs::Search)
- expect(drive.search.name).to be_nil
- expect(drive.search.if_not_found).to eq(:error)
- end
- end
-
- context "if a drive does not spicify neither 'mountPath' nor 'filesystem'" do
- let(:drive) { {} }
- include_examples "without filesystem", drive_proc
- end
-
- context "if a drive does not spicify 'ptableType'" do
- let(:drive) { {} }
- include_examples "without ptableType", drive_proc
- end
-
- context "if a drive does not specifies 'spacePolicy'" do
- let(:drive) { {} }
- include_examples "without spacePolicy", drive_proc
- end
-
- context "if a drive specifies 'name'" do
- let(:drive) { { name: name } }
- include_examples "with name", drive_proc
- end
-
- context "if a drive specifies 'mountPath'" do
- let(:drive) { { mountPath: mountPath } }
- include_examples "with mountPath", drive_proc
- end
-
- context "if a drive specifies 'filesystem'" do
- let(:drive) { { filesystem: filesystem } }
- include_examples "with filesystem", drive_proc
- end
-
- context "if a drive specifies both 'mountPath' and 'filesystem'" do
- let(:drive) { { mountPath: mountPath, filesystem: filesystem } }
- include_examples "with mountPath and filesystem", drive_proc
- end
-
- context "if a drive specifies 'ptableType'" do
- let(:drive) { { ptableType: ptableType } }
- include_examples "with ptableType", drive_proc
- end
-
- context "if a drive specifies 'partitions'" do
- let(:drive) { { partitions: partitions } }
- include_examples "with partitions", drive_proc
- end
-
- context "if a drive specifies 'spacePolicy'" do
- let(:drive) { { spacePolicy: spacePolicy } }
- include_examples "with spacePolicy", drive_proc
- end
-
- context "if a drive specifies both 'spacePolicy' and 'partitions'" do
- let(:drive) { { spacePolicy: spacePolicy, partitions: partitions } }
- include_examples "with spacePolicy and partitions", drive_proc
- end
- end
-
- context "with a JSON specifying 'mdRaids'" do
- let(:model_json) do
- { mdRaids: md_raids }
- end
-
- let(:md_raids) do
- [
- md_raid,
- { name: "/dev/md1" }
- ]
- end
-
- let(:md_raid) do
- { name: "/dev/md0" }
- end
-
- context "with an empty list" do
- let(:md_raids) { [] }
-
- it "sets #md_raids to the expected value" do
- config = subject.convert
- expect(config.md_raids).to eq([])
- end
- end
-
- context "with a list of MD RAIDs" do
- it "sets #md_raids to the expected value" do
- config = subject.convert
- expect(config.md_raids.size).to eq(2)
- expect(config.md_raids).to all(be_a(Agama::Storage::Configs::MdRaid))
-
- md1, md2 = config.md_raids
- expect(md1.search.name).to eq("/dev/md0")
- expect(md1.partitions).to eq([])
- expect(md2.search.name).to eq("/dev/md1")
- expect(md2.partitions).to eq([])
- end
- end
-
- md_raid_proc = proc { |c| c.md_raids.first }
-
- context "if a MD RAID does not specify 'name'" do
- let(:md_raid) { {} }
-
- it "sets #search to the expected value" do
- md = md_raid_proc.call(subject.convert)
- expect(md.search).to be_nil
- end
- end
-
- context "if a MD RAID does not spicify neither 'mountPath' nor 'filesystem'" do
- let(:md_raid) { {} }
- include_examples "without filesystem", md_raid_proc
- end
-
- context "if a MD RAID does not spicify 'ptableType'" do
- let(:md_raid) { {} }
- include_examples "without ptableType", md_raid_proc
- end
-
- context "if a MD RAID does not specifies 'spacePolicy'" do
- let(:md_raid) { {} }
- include_examples "without spacePolicy", md_raid_proc
- end
-
- context "if a MD RAID specifies 'name'" do
- let(:md_raid) { { name: name } }
- include_examples "with name", md_raid_proc
- end
-
- context "if a MD RAID specifies 'mountPath'" do
- let(:md_raid) { { mountPath: mountPath } }
- include_examples "with mountPath", md_raid_proc
- end
-
- context "if a MD RAID specifies 'filesystem'" do
- let(:md_raid) { { filesystem: filesystem } }
- include_examples "with filesystem", md_raid_proc
- end
-
- context "if a MD RAID specifies both 'mountPath' and 'filesystem'" do
- let(:md_raid) { { mountPath: mountPath, filesystem: filesystem } }
- include_examples "with mountPath and filesystem", md_raid_proc
- end
-
- context "if a MD RAID specifies 'ptableType'" do
- let(:md_raid) { { ptableType: ptableType } }
- include_examples "with ptableType", md_raid_proc
- end
-
- context "if a MD RAID specifies 'partitions'" do
- let(:md_raid) { { partitions: partitions } }
- include_examples "with partitions", md_raid_proc
- end
-
- context "if a MD RAID specifies 'spacePolicy'" do
- let(:md_raid) { { spacePolicy: spacePolicy } }
- include_examples "with spacePolicy", md_raid_proc
- end
-
- context "if a MD RAID specifies both 'spacePolicy' and 'partitions'" do
- let(:md_raid) { { spacePolicy: spacePolicy, partitions: partitions } }
- include_examples "with spacePolicy and partitions", md_raid_proc
- end
- end
-
- context "with a JSON specifying 'volumeGroups'" do
- let(:model_json) do
- {
- drives: drives,
- mdRaids: md_raids,
- volumeGroups: volume_groups
- }
- end
-
- let(:drives) { [] }
- let(:md_raids) { [] }
-
- let(:volume_groups) do
- [
- volume_group,
- { vgName: "vg2" }
- ]
- end
-
- let(:volume_group) do
- { vgName: "vg1" }
- end
-
- context "with an empty list" do
- let(:volume_groups) { [] }
-
- it "sets #volume_groups to the expected value" do
- config = subject.convert
- expect(config.volume_groups).to eq([])
- end
- end
-
- context "with a list of volume groups" do
- it "sets #volume_groups to the expected value" do
- config = subject.convert
- expect(config.volume_groups.size).to eq(2)
- expect(config.volume_groups).to all(be_a(Agama::Storage::Configs::VolumeGroup))
-
- vg1, vg2 = config.volume_groups
- expect(vg1.name).to eq("vg1")
- expect(vg1.logical_volumes).to eq([])
- expect(vg2.name).to eq("vg2")
- expect(vg2.logical_volumes).to eq([])
- end
- end
-
- volume_group_proc = proc { |c| c.volume_groups.first }
-
- context "if a volume group does not specify 'vgName'" do
- let(:volume_group) { {} }
-
- it "does not set #name" do
- volume_group = volume_group_proc.call(subject.convert)
- expect(volume_group.name).to be_nil
- end
- end
-
- context "if a volume group does not specify 'extentSize'" do
- let(:volume_group) { {} }
-
- it "does not set #extent_size" do
- volume_group = volume_group_proc.call(subject.convert)
- expect(volume_group.extent_size).to be_nil
- end
- end
-
- context "if a volume group does not specify 'targetDevices'" do
- let(:volume_group) { {} }
-
- it "sets #physical_volumes_devices to the expected value" do
- volume_group = volume_group_proc.call(subject.convert)
- expect(volume_group.physical_volumes_devices).to eq([])
- end
- end
-
- context "if a volume group does not specify 'logicalVolumes'" do
- let(:volume_group) { {} }
-
- it "sets #logical_volumes to the expected value" do
- volume_group = volume_group_proc.call(subject.convert)
- expect(volume_group.logical_volumes).to eq([])
- end
- end
-
- context "if a volume group specifies 'vgName'" do
- let(:volume_group) { { vgName: "vg1" } }
-
- it "sets #name to the expected value" do
- volume_group = volume_group_proc.call(subject.convert)
- expect(volume_group.name).to eq("vg1")
- end
- end
-
- context "if a volume group specifies 'extentSize'" do
- let(:volume_group) { { extentSize: 1.KiB.to_i } }
-
- it "sets #extent_size to the expected value" do
- volume_group = volume_group_proc.call(subject.convert)
- expect(volume_group.extent_size).to eq(1.KiB)
- end
- end
-
- context "if a volume group specifies 'targetDevices'" do
- let(:scenario) { "md_raids.yaml" }
-
- let(:volume_group) { { targetDevices: ["/dev/vda", "/dev/vdb", "/dev/md0"] } }
-
- let(:drives) do
- [
- { name: "/dev/vda" },
- { name: "/dev/vdc" }
- ]
- end
-
- let(:md_raids) do
- [
- { name: "/dev/md1" }
- ]
- end
-
- it "adds the missing drives" do
- config = subject.convert
- expect(config.drives.size).to eq(3)
- expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive))
- expect(config.drives).to include(an_object_having_attributes({ device_name: "/dev/vdb" }))
- end
-
- it "adds the missing MD RAIDs" do
- config = subject.convert
- expect(config.md_raids.size).to eq(2)
- expect(config.md_raids).to all(be_a(Agama::Storage::Configs::MdRaid))
- expect(config.md_raids)
- .to include(an_object_having_attributes({ device_name: "/dev/md0" }))
- end
-
- it "sets an alias to the target devices" do
- config = subject.convert
- vda = config.drives.find { |d| d.device_name == "/dev/vda" }
- vdb = config.drives.find { |d| d.device_name == "/dev/vdb" }
- vdc = config.drives.find { |d| d.device_name == "/dev/vdc" }
- md0 = config.md_raids.find { |d| d.device_name == "/dev/md0" }
- md1 = config.md_raids.find { |d| d.device_name == "/dev/md1" }
- expect(vda.alias).to_not be_nil
- expect(vdb.alias).to_not be_nil
- expect(vdc.alias).to be_nil
- expect(md0.alias).to_not be_nil
- expect(md1.alias).to be_nil
- end
-
- it "sets #physical_volumes_devices to the expected value" do
- config = subject.convert
- volume_group = volume_group_proc.call(config)
- vda = config.drives.find { |d| d.device_name == "/dev/vda" }
- vdb = config.drives.find { |d| d.device_name == "/dev/vdb" }
- md0 = config.md_raids.find { |d| d.device_name == "/dev/md0" }
- expect(volume_group.physical_volumes_devices).to eq([vda.alias, vdb.alias, md0.alias])
- end
- end
-
- context "if a volume group specifies 'logicalVolumes'" do
- let(:volume_group) { { logicalVolumes: logical_volumes } }
-
- let(:logical_volumes) do
- [
- logical_volume,
- { lvName: "lv2" }
- ]
- end
-
- let(:logical_volume) { { lvName: "lv1" } }
-
- context "with an empty list" do
- let(:logical_volumes) { [] }
-
- it "sets #logical_volumes to empty" do
- config = subject.convert
- expect(config.logical_volumes).to eq([])
- end
- end
-
- context "with a list of logical volumes" do
- it "sets #logical_volumes to the expected value" do
- volume_group = volume_group_proc.call(subject.convert)
- expect(volume_group.logical_volumes)
- .to all(be_a(Agama::Storage::Configs::LogicalVolume))
- expect(volume_group.logical_volumes.size).to eq(2)
-
- lv1, lv2 = volume_group.logical_volumes
- expect(lv1.name).to eq("lv1")
- expect(lv2.name).to eq("lv2")
- end
- end
-
- logical_volume_proc = proc { |c| volume_group_proc.call(c).logical_volumes.first }
-
- context "if a logical volume does not specify 'lvName'" do
- let(:logical_volume) { {} }
-
- it "does not set #name" do
- logical_volume = logical_volume_proc.call(subject.convert)
- expect(logical_volume.name).to be_nil
- end
- end
-
- context "if a logical volume does not spicify neither 'mountPath' nor 'filesystem'" do
- let(:logical_volume) { {} }
- include_examples "without filesystem", logical_volume_proc
- end
-
- context "if a logical volume does not spicify 'size'" do
- let(:logical_volume) { {} }
- include_examples "without size", logical_volume_proc
- end
-
- context "if a logical volume does not spicify 'stripes'" do
- let(:logical_volume) { {} }
-
- it "does not set #stripes" do
- logical_volume = logical_volume_proc.call(subject.convert)
- expect(logical_volume.stripes).to be_nil
- end
- end
-
- context "if a logical volume does not spicify 'stripeSize'" do
- let(:logical_volume) { {} }
-
- it "does not set #stripe_size" do
- logical_volume = logical_volume_proc.call(subject.convert)
- expect(logical_volume.stripe_size).to be_nil
- end
- end
-
- context "if a logical volume specifies 'lvName'" do
- let(:logical_volume) { { lvName: "lv1" } }
-
- it "sets #name to the expected value" do
- logical_volume = logical_volume_proc.call(subject.convert)
- expect(logical_volume.name).to eq("lv1")
- end
- end
-
- context "if a logical volume specifies 'mountPath'" do
- let(:logical_volume) { { mountPath: mountPath } }
- include_examples "with mountPath", logical_volume_proc
- end
- context "if a logical volume specifies 'filesystem'" do
- let(:logical_volume) { { filesystem: filesystem } }
- include_examples "with filesystem", logical_volume_proc
- end
+ boot = config.boot
+ expect(boot.configure?).to eq(true)
+ expect(boot.device.default?).to eq(true)
- context "if a logical volume specifies both 'mountPath' and 'filesystem'" do
- let(:logical_volume) { { mountPath: mountPath, filesystem: filesystem } }
- include_examples "with mountPath and filesystem", logical_volume_proc
- end
+ drives = config.drives
+ expect(drives.size).to eq(1)
- context "if a logical volume spicifies 'size'" do
- let(:logical_volume) { { size: size } }
- include_examples "with size", logical_volume_proc
- end
+ drive = drives.first
+ expect(drive.search.name).to eq("/dev/vda")
+ expect(drive.partitions).to be_empty
- context "if a logical volume specifies 'stripes'" do
- let(:logical_volume) { { stripes: 4 } }
+ md_raids = config.md_raids
+ expect(md_raids.size).to eq(1)
- it "sets #stripes to the expected value" do
- logical_volume = logical_volume_proc.call(subject.convert)
- expect(logical_volume.stripes).to eq(4)
- end
- end
+ md_raid = md_raids.first
+ expect(md_raid.search.name).to eq("/dev/md0")
+ expect(md_raid.partitions).to be_empty
- context "if a logical volume specifies 'stripeSize'" do
- let(:logical_volume) { { stripeSize: 2.KiB.to_i } }
+ volume_groups = config.volume_groups
+ expect(volume_groups.size).to eq(1)
- it "sets #stripeSize to the expected value" do
- logical_volume = logical_volume_proc.call(subject.convert)
- expect(logical_volume.stripe_size).to eq(2.KiB)
- end
- end
- end
+ volume_group = volume_groups.first
+ expect(volume_group.search.name).to eq("/dev/vg0")
+ expect(volume_group.logical_volumes).to be_empty
end
end
end
diff --git a/service/test/agama/storage/config_conversions/model_support_checker_test.rb b/service/test/agama/storage/config_conversions/model_support_checker_test.rb
index caad48abbd..661a6b9123 100644
--- a/service/test/agama/storage/config_conversions/model_support_checker_test.rb
+++ b/service/test/agama/storage/config_conversions/model_support_checker_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -184,6 +184,129 @@
end
end
+ shared_examples "volume without mount path" do |device_name|
+ context "and the volume has not a search (new volume)" do
+ let(:search) { nil }
+
+ it "returns false" do
+ expect(subject.supported?).to eq(false)
+ end
+ end
+
+ context "and the volume has a search" do
+ let(:search) do
+ {
+ condition: condition,
+ ifNotFound: if_not_found
+ }
+ end
+
+ let(:if_not_found) { nil }
+
+ shared_examples "reused volume" do
+ context "and the volume is set to be deleted" do
+ let(:delete) { true }
+
+ it "returns true" do
+ expect(subject.supported?).to eq(true)
+ end
+ end
+
+ context "and the volume is set to be deleted if needed" do
+ let(:deleteIfNeeded) { true }
+
+ it "returns true" do
+ expect(subject.supported?).to eq(true)
+ end
+ end
+
+ context "and the volume is not set to be deleted" do
+ let(:delete) { false }
+ let(:deleteIfNeeded) { false }
+
+ context "and the volume has encryption" do
+ let(:encryption) do
+ { luks1: { password: "12345" } }
+ end
+
+ it "returns false" do
+ expect(subject.supported?).to eq(false)
+ end
+ end
+
+ context "and the volume has filesystem" do
+ let(:filesystem) { { type: "xfs" } }
+
+ it "returns false" do
+ expect(subject.supported?).to eq(false)
+ end
+ end
+
+ context "and the volume has a size" do
+ let(:size) do
+ {
+ default: false,
+ min: 1.GiB,
+ max: 10.GiB
+ }
+ end
+
+ it "returns false" do
+ expect(subject.supported?).to eq(false)
+ end
+ end
+
+ context "and the volume is only set to be resized if needed" do
+ let(:encryption) { nil }
+ let(:filesystem) { nil }
+ let(:size) do
+ {
+ default: false,
+ min: Y2Storage::DiskSize.zero
+ }
+ end
+
+ it "returns true" do
+ expect(subject.supported?).to eq(true)
+ end
+ end
+ end
+ end
+
+ context "and the volume is found" do
+ let(:condition) { { name: device_name } }
+
+ include_examples "reused volume"
+ end
+
+ context "and the volume is not found" do
+ let(:condition) { { name: "/no/found" } }
+
+ context "and the volume can be skipped" do
+ let(:if_not_found) { "skip" }
+
+ it "returns true" do
+ expect(subject.supported?).to eq(true)
+ end
+ end
+
+ context "and the volume cannot be skipped" do
+ let(:if_not_found) { "error" }
+
+ include_examples "reused volume"
+ end
+
+ context "and the volume can be created" do
+ let(:if_not_found) { "create" }
+
+ it "returns false" do
+ expect(subject.supported?).to eq(false)
+ end
+ end
+ end
+ end
+ end
+
context "if there is a drive with encryption" do
let(:config_json) do
{
@@ -325,143 +448,44 @@
let(:encryption) { nil }
let(:size) { nil }
- context "and the partition has not a search (new partition)" do
- let(:search) { nil }
-
- it "returns false" do
- expect(subject.supported?).to eq(false)
- end
- end
-
- context "and the partition has a search" do
- let(:search) do
- {
- condition: condition,
- ifNotFound: if_not_found
- }
- end
-
- let(:if_not_found) { nil }
-
- shared_examples "reused partition" do
- context "and the partition is set to be deleted" do
- let(:delete) { true }
-
- it "returns true" do
- expect(subject.supported?).to eq(true)
- end
- end
-
- context "and the partition is set to be deleted if needed" do
- let(:deleteIfNeeded) { true }
-
- it "returns true" do
- expect(subject.supported?).to eq(true)
- end
- end
-
- context "and the partition is not set to be deleted" do
- let(:delete) { false }
- let(:deleteIfNeeded) { false }
-
- context "and the partition has encryption" do
- let(:encryption) do
- { luks1: { password: "12345" } }
- end
-
- it "returns false" do
- expect(subject.supported?).to eq(false)
- end
- end
-
- context "and the partition has filesystem" do
- let(:filesystem) { { type: "xfs" } }
-
- it "returns false" do
- expect(subject.supported?).to eq(false)
- end
- end
-
- context "and the partition has a size" do
- let(:size) do
- {
- default: false,
- min: 1.GiB,
- max: 10.GiB
- }
- end
-
- it "returns false" do
- expect(subject.supported?).to eq(false)
- end
- end
-
- context "and the partition is only set to be resized if needed" do
- let(:encryption) { nil }
- let(:filesystem) { nil }
- let(:size) do
- {
- default: false,
- min: Y2Storage::DiskSize.zero
- }
- end
-
- it "returns true" do
- expect(subject.supported?).to eq(true)
- end
- end
- end
- end
-
- context "and the partition is found" do
- let(:condition) { { name: "/dev/vda1" } }
-
- include_examples "reused partition"
- end
-
- context "and the partition is not found" do
- let(:condition) { { name: "/no/found" } }
-
- context "and the partition can be skipped" do
- let(:if_not_found) { "skip" }
-
- it "returns true" do
- expect(subject.supported?).to eq(true)
- end
- end
-
- context "and the partition cannot be skipped" do
- let(:if_not_found) { "error" }
-
- include_examples "reused partition"
- end
-
- context "and the partition can be created" do
- let(:if_not_found) { "create" }
-
- it "returns false" do
- expect(subject.supported?).to eq(false)
- end
- end
- end
- end
+ include_examples "volume without mount path", "/dev/vda1"
end
context "if there is a LVM logical volume without mount path" do
+ let(:scenario) { "several_vgs.yaml" }
+
let(:config_json) do
{
volumeGroups: [
{
name: "system",
- logicalVolumes: [{}]
+ logicalVolumes: [
+ {
+ search: search,
+ delete: delete,
+ deleteIfNeeded: deleteIfNeeded,
+ filesystem: filesystem,
+ encryption: encryption,
+ size: size
+ }
+ ]
}
]
}
end
- it "returns false" do
- expect(subject.supported?).to eq(false)
- end
+ let(:search) { nil }
+ let(:delete) { nil }
+ let(:deleteIfNeeded) { nil }
+ let(:filesystem) { nil }
+ let(:encryption) { nil }
+ let(:size) { nil }
+
+ include_examples "volume without mount path", "/dev/system/root"
+
+ # it "returns false" do
+ # expect(subject.supported?).to eq(false)
+ # end
end
context "if there is a LVM logical volume with encryption" do
diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb
index 2a2df340ed..aecafe19a9 100644
--- a/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb
+++ b/service/test/agama/storage/config_conversions/to_model_conversions/config_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -51,6 +51,15 @@
end
end
+ context "if #md_raids is not configured" do
+ let(:md_raids) { nil }
+
+ it "generates the expected JSON" do
+ config_model = subject.convert
+ expect(config_model[:mdRaids]).to eq([])
+ end
+ end
+
context "if #volume_groups is not configured" do
let(:volume_groups) { nil }
@@ -190,14 +199,19 @@
[
{
vgName: "vg0",
+ spacePolicy: "keep",
targetDevices: [],
logicalVolumes: [
{
- filesystem: {
+ delete: false,
+ deleteIfNeeded: false,
+ resize: false,
+ resizeIfNeeded: false,
+ filesystem: {
reuse: false
},
- mountPath: "/",
- size: {
+ mountPath: "/",
+ size: {
default: true,
min: 0
}
diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb b/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb
index b35556e29e..b43380cf53 100644
--- a/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb
+++ b/service/test/agama/storage/config_conversions/to_model_conversions/examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -19,9 +19,14 @@
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
+require "agama/config"
+require "agama/storage/configs/logical_volume"
+require "agama/storage/configs/volume_group"
+require "agama/storage/volume_templates_builder"
require "y2storage/blk_device"
+require "y2storage/lvm_lv"
+require "y2storage/lvm_vg"
require "y2storage/refinements"
-require "agama/config"
using Y2Storage::Refinements::SizeCasts
@@ -76,6 +81,28 @@
end
end
+shared_examples "without delete" do
+ context "if #delete is not configured" do
+ let(:delete) { nil }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:delete]).to eq(false)
+ end
+ end
+end
+
+shared_examples "without delete_if_needed" do
+ context "if #delete_if_needed is not configured" do
+ let(:delete_if_needed) { nil }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:deleteIfNeeded]).to eq(false)
+ end
+ end
+end
+
shared_examples "with filesystem" do
context "if #filesystem is configured" do
let(:filesystem) do
@@ -212,11 +239,16 @@
context "if #partitions is configured" do
let(:partitions) do
[
- { size: "10 GiB" },
+ {
+ search: search,
+ size: "10 GiB"
+ },
{ filesystem: { path: "/" } }
]
end
+ let(:search) { nil }
+
it "generates the expected JSON" do
model_json = subject.convert
expect(model_json[:partitions]).to eq(
@@ -249,11 +281,78 @@
]
)
end
+
+ context "if there are skipped partitions" do
+ let(:search) do
+ {
+ condition: { name: "not-found" },
+ ifNotFound: "skip"
+ }
+ end
+
+ before do
+ config.partitions.first.search.solve
+ end
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:partitions]).to eq(
+ [
+ {
+ delete: false,
+ deleteIfNeeded: false,
+ resize: false,
+ resizeIfNeeded: false,
+ filesystem: {
+ reuse: false
+ },
+ mountPath: "/",
+ size: {
+ default: true,
+ min: 0
+ }
+ }
+ ]
+ )
+ end
+ end
+ end
+end
+
+shared_examples "with delete" do
+ context "if #delete is configured" do
+ let(:delete) { true }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:delete]).to eq(true)
+ end
end
end
-shared_examples "device name" do
+shared_examples "with delete_if_needed" do
+ context "if #delete_if_needed is not configured" do
+ let(:delete_if_needed) { true }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:deleteIfNeeded]).to eq(true)
+ end
+ end
+end
+
+shared_examples "device name" do |device_config_fn = nil|
context "for the 'name' property" do
+ let(:device_config) { device_config_fn ? device_config_fn.call(config) : config }
+
+ let(:device) do
+ if device_config.is_a?(Agama::Storage::Configs::VolumeGroup)
+ instance_double(Y2Storage::LvmVg, name: "/dev/test")
+ else
+ instance_double(Y2Storage::BlkDevice, name: "/dev/test")
+ end
+ end
+
context "if #search is not configured" do
let(:search) { nil }
@@ -278,7 +377,7 @@
let(:condition) { { name: "/dev/test" } }
context "if the device is not found" do
- before { config.search.solve }
+ before { device_config.search.solve }
context "and the device does not have to be created" do
let(:if_not_found) { "error" }
@@ -300,13 +399,11 @@
end
context "if the device is found" do
- before { config.search.solve(device) }
-
- let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/test") }
+ before { device_config.search.solve(device) }
it "generates the expected JSON" do
model_json = subject.convert
- expect(model_json[:name]).to eq("/dev/test")
+ expect(model_json[:name]).to eq(device.name)
end
end
end
@@ -315,7 +412,7 @@
let(:condition) { nil }
context "if the device is not found" do
- before { config.search.solve }
+ before { device_config.search.solve }
context "and the device does not have to be created" do
let(:if_not_found) { "error" }
@@ -337,13 +434,11 @@
end
context "if the device is found" do
- before { config.search.solve(device) }
-
- let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/test") }
+ before { device_config.search.solve(device) }
it "generates the expected JSON" do
model_json = subject.convert
- expect(model_json[:name]).to eq("/dev/test")
+ expect(model_json[:name]).to eq(device.name)
end
end
end
@@ -351,12 +446,31 @@
end
end
-shared_examples "space policy" do
+shared_examples "space policy" do |device_config_fn = nil|
context "for the 'spacePolicy' property" do
- let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/test") }
+ let(:device_config) { device_config_fn ? device_config_fn.call(config) : config }
+
+ let(:volumes_config) do
+ if device_config.is_a?(Agama::Storage::Configs::VolumeGroup)
+ device_config.logical_volumes
+ else
+ device_config.partitions
+ end
+ end
- context "if there is a 'delete all' partition" do
- let(:partitions) do
+ let(:device) do
+ if device_config.is_a?(Agama::Storage::Configs::VolumeGroup)
+ instance_double(Y2Storage::LvmVg, name: "/dev/test")
+ else
+ instance_double(Y2Storage::BlkDevice, name: "/dev/test")
+ end
+ end
+
+ let(:partitions) { volumes_json }
+ let(:logical_volumes) { volumes_json }
+
+ context "if there is a 'delete all' volume" do
+ let(:volumes_json) do
[
{ search: "*", delete: true },
{ size: "2 GiB" }
@@ -369,8 +483,8 @@
end
end
- context "if there is a 'resize all' partition" do
- let(:partitions) do
+ context "if there is a 'resize all' volume" do
+ let(:volumes_json) do
[
{ search: "*", size: { min: 0, max: "current" } },
{ size: "2 GiB" }
@@ -383,15 +497,15 @@
end
end
- context "if there is a 'delete' partition" do
- let(:partitions) do
+ context "if there is a 'delete' volume" do
+ let(:volumes_json) do
[
{ search: { max: 1 }, delete: true },
{ filesystem: { path: "/" } }
]
end
- before { config.partitions.first.search.solve(device) }
+ before { volumes_config.first.search.solve(device) }
it "generates the expected JSON" do
model_json = subject.convert
@@ -399,15 +513,15 @@
end
end
- context "if there is a 'delete if needed' partition" do
- let(:partitions) do
+ context "if there is a 'delete if needed' volume" do
+ let(:volumes_json) do
[
{ search: { max: 1 }, deleteIfNeeded: true },
{ filesystem: { path: "/" } }
]
end
- before { config.partitions.first.search.solve(device) }
+ before { volumes_config.first.search.solve(device) }
it "generates the expected JSON" do
model_json = subject.convert
@@ -415,15 +529,15 @@
end
end
- context "if there is a 'resize' partition" do
- let(:partitions) do
+ context "if there is a 'resize' volume" do
+ let(:volumes_json) do
[
{ search: { max: 1 }, size: "1 GiB" },
{ filesystem: { path: "/" } }
]
end
- before { config.partitions.first.search.solve(device) }
+ before { volumes_config.first.search.solve(device) }
it "generates the expected JSON" do
model_json = subject.convert
@@ -431,15 +545,15 @@
end
end
- context "if there is a 'resize if needed' partition" do
- let(:partitions) do
+ context "if there is a 'resize if needed' volume" do
+ let(:volumes_json) do
[
{ search: { max: 1 }, size: { min: 0, max: "1 GiB" } },
{ filesystem: { path: "/" } }
]
end
- before { config.partitions.first.search.solve(device) }
+ before { volumes_config.first.search.solve(device) }
it "generates the expected JSON" do
model_json = subject.convert
@@ -447,8 +561,8 @@
end
end
- context "if there is neither 'delete' nor 'resize' partition" do
- let(:partitions) do
+ context "if there is neither 'delete' nor 'resize' volume" do
+ let(:volumes_json) do
[
{ size: { min: "1 GiB" } },
{ filesystem: { path: "/" } }
@@ -462,3 +576,101 @@
end
end
end
+
+shared_examples "resize" do
+ let(:device) do
+ if config.is_a?(Agama::Storage::Configs::LogicalVolume)
+ instance_double(Y2Storage::LvmLv, name: "/dev/test/lv1")
+ else
+ instance_double(Y2Storage::BlkDevice, name: "/dev/vda1")
+ end
+ end
+
+ context "for the 'resize' property" do
+ let(:search) { {} }
+
+ context "if there is not assigned device" do
+ before { config.search.solve }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resize]).to eq(false)
+ end
+ end
+
+ context "if there is an assigned device" do
+ before { config.search.solve(device) }
+
+ context "and the #size is not configured" do
+ let(:size) { nil }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resize]).to eq(false)
+ end
+ end
+
+ context "and the min size is equal to the max size" do
+ let(:size) { "1 GiB" }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resize]).to eq(true)
+ end
+ end
+
+ context "and the min size is not equal to the max size" do
+ let(:size) { { min: "1 GiB", max: "2 GiB" } }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resize]).to eq(false)
+ end
+ end
+ end
+ end
+
+ context "for the 'resizeIfNeeded' property" do
+ let(:search) { {} }
+
+ context "if there is not assigned device" do
+ before { config.search.solve }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resizeIfNeeded]).to eq(false)
+ end
+ end
+
+ context "if there is an assigned device" do
+ before { config.search.solve(device) }
+
+ context "and the #size is not configured" do
+ let(:size) { nil }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resizeIfNeeded]).to eq(false)
+ end
+ end
+
+ context "and the min size is equal to the max size" do
+ let(:size) { "1 GiB" }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resizeIfNeeded]).to eq(false)
+ end
+ end
+
+ context "and the min size is not equal to the max size" do
+ let(:size) { { min: "1 GiB", max: "2 GiB" } }
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:resizeIfNeeded]).to eq(true)
+ end
+ end
+ end
+ end
+end
diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb
index 5ddd7eef56..a714019c85 100644
--- a/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb
+++ b/service/test/agama/storage/config_conversions/to_model_conversions/logical_volume_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -39,21 +39,27 @@
let(:config_json) do
{
- filesystem: filesystem,
- size: size,
- name: name,
- stripes: stripes,
- stripeSize: stripe_size
+ search: search,
+ filesystem: filesystem,
+ size: size,
+ name: name,
+ stripes: stripes,
+ stripeSize: stripe_size,
+ delete: delete,
+ deleteIfNeeded: delete_if_needed
}
end
let(:volumes) { Agama::Storage::VolumeTemplatesBuilder.new([]) }
+ let(:search) { nil }
let(:filesystem) { nil }
let(:size) { nil }
let(:name) { nil }
let(:stripes) { nil }
let(:stripe_size) { nil }
+ let(:delete) { nil }
+ let(:delete_if_needed) { nil }
describe "#convert" do
context "if #name is not configured" do
@@ -83,6 +89,8 @@
end
end
+ include_examples "without delete"
+ include_examples "without delete_if_needed"
include_examples "without filesystem"
include_examples "without size"
@@ -113,7 +121,11 @@
end
end
+ include_examples "with delete"
+ include_examples "with delete_if_needed"
include_examples "with filesystem"
include_examples "with size"
+ include_examples "device name"
+ include_examples "resize"
end
end
diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb
index fd3c936088..bdbf5895fd 100644
--- a/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb
+++ b/service/test/agama/storage/config_conversions/to_model_conversions/md_raid_test.rb
@@ -24,6 +24,7 @@
require "agama/storage/config_conversions/from_json_conversions/md_raid"
require "agama/storage/config_conversions/to_model_conversions/md_raid"
require "agama/storage/volume_templates_builder"
+require "y2storage/md"
describe Agama::Storage::ConfigConversions::ToModelConversions::MdRaid do
subject { described_class.new(config, volumes) }
diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb
index 7622b8ce40..fe36526f79 100644
--- a/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb
+++ b/service/test/agama/storage/config_conversions/to_model_conversions/partition_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -64,24 +64,8 @@
end
end
- context "if #delete is not configured" do
- let(:delete) { nil }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:delete]).to eq(false)
- end
- end
-
- context "if #delete_if_needed is not configured" do
- let(:delete_if_needed) { nil }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:deleteIfNeeded]).to eq(false)
- end
- end
-
+ include_examples "without delete"
+ include_examples "without delete_if_needed"
include_examples "without filesystem"
include_examples "without size"
@@ -94,119 +78,11 @@
end
end
- context "if #delete is configured" do
- let(:delete) { true }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:delete]).to eq(true)
- end
- end
-
- context "if #delete_if_needed is not configured" do
- let(:delete_if_needed) { true }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:deleteIfNeeded]).to eq(true)
- end
- end
-
+ include_examples "with delete"
+ include_examples "with delete_if_needed"
include_examples "with filesystem"
include_examples "with size"
-
include_examples "device name"
-
- context "for the 'resize' property" do
- let(:search) { {} }
-
- context "if there is not assigned device" do
- before { config.search.solve }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resize]).to eq(false)
- end
- end
-
- context "if there is an assigned device" do
- before { config.search.solve(device) }
-
- let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/vda1") }
-
- context "and the #size is not configured" do
- let(:size) { nil }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resize]).to eq(false)
- end
- end
-
- context "and the min size is equal to the max size" do
- let(:size) { "1 GiB" }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resize]).to eq(true)
- end
- end
-
- context "and the min size is not equal to the max size" do
- let(:size) { { min: "1 GiB", max: "2 GiB" } }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resize]).to eq(false)
- end
- end
- end
- end
-
- context "for the 'resizeIfNeeded' property" do
- let(:search) { {} }
-
- context "if there is not assigned device" do
- before { config.search.solve }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resizeIfNeeded]).to eq(false)
- end
- end
-
- context "if there is an assigned device" do
- before { config.search.solve(device) }
-
- let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/vda1") }
-
- context "and the #size is not configured" do
- let(:size) { nil }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resizeIfNeeded]).to eq(false)
- end
- end
-
- context "and the min size is equal to the max size" do
- let(:size) { "1 GiB" }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resizeIfNeeded]).to eq(false)
- end
- end
-
- context "and the min size is not equal to the max size" do
- let(:size) { { min: "1 GiB", max: "2 GiB" } }
-
- it "generates the expected JSON" do
- model_json = subject.convert
- expect(model_json[:resizeIfNeeded]).to eq(true)
- end
- end
- end
- end
+ include_examples "resize"
end
end
diff --git a/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb b/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb
index e35cd7d8e6..69eb042d11 100644
--- a/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb
+++ b/service/test/agama/storage/config_conversions/to_model_conversions/volume_group_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,8 +20,10 @@
# find current contact information at www.suse.com.
require_relative "../../storage_helpers"
+require_relative "./examples"
require "agama/storage/config_conversions/from_json"
require "agama/storage/config_conversions/to_model_conversions/volume_group"
+require "y2storage/lvm_vg"
require "y2storage/refinements"
using Y2Storage::Refinements::SizeCasts
@@ -42,6 +44,7 @@
drives: drives,
volumeGroups: [
{
+ search: search,
name: name,
extentSize: extent_size,
physicalVolumes: physical_volumes,
@@ -54,12 +57,17 @@
let(:volumes) { Agama::Storage::VolumeTemplatesBuilder.new([]) }
let(:drives) { nil }
+ let(:search) { nil }
let(:name) { nil }
let(:extent_size) { nil }
let(:physical_volumes) { nil }
let(:logical_volumes) { nil }
describe "#convert" do
+ include_examples "device name", ->(c) { c.volume_groups.first }
+
+ include_examples "space policy", ->(c) { c.volume_groups.first }
+
context "if #name is not configured" do
let(:name) { nil }
@@ -133,28 +141,41 @@
context "if #logical_volumes is configured" do
let(:logical_volumes) do
[
- { size: "10 GiB" },
+ {
+ search: search,
+ size: "10 GiB"
+ },
{ filesystem: { path: "/" } }
]
end
+ let(:search) { nil }
+
it "generates the expected JSON" do
model_json = subject.convert
expect(model_json[:logicalVolumes]).to eq(
[
{
- size: {
+ delete: false,
+ deleteIfNeeded: false,
+ resize: false,
+ resizeIfNeeded: false,
+ size: {
default: false,
min: 10.GiB.to_i,
max: 10.GiB.to_i
}
},
{
- filesystem: {
+ filesystem: {
reuse: false
},
- mountPath: "/",
- size: {
+ mountPath: "/",
+ delete: false,
+ deleteIfNeeded: false,
+ resize: false,
+ resizeIfNeeded: false,
+ size: {
default: true,
min: 0
}
@@ -162,6 +183,41 @@
]
)
end
+
+ context "if there are skipped logical volumes" do
+ let(:search) do
+ {
+ condition: { name: "not-found" },
+ ifNotFound: "skip"
+ }
+ end
+
+ before do
+ config.logical_volumes.first.search.solve
+ end
+
+ it "generates the expected JSON" do
+ model_json = subject.convert
+ expect(model_json[:logicalVolumes]).to eq(
+ [
+ {
+ filesystem: {
+ reuse: false
+ },
+ mountPath: "/",
+ delete: false,
+ deleteIfNeeded: false,
+ resize: false,
+ resizeIfNeeded: false,
+ size: {
+ default: true,
+ min: 0
+ }
+ }
+ ]
+ )
+ end
+ end
end
end
end
diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb
index 0ccda4499d..38d31793cd 100644
--- a/service/test/agama/storage/config_conversions/to_model_test.rb
+++ b/service/test/agama/storage/config_conversions/to_model_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -54,10 +54,12 @@
],
volumeGroups: [
{
+ search: "/dev/test",
name: "test",
physicalVolumes: [{ generate: ["disk1"] }],
logicalVolumes: [
{
+ search: "/dev/test/lv1",
filesystem: { path: "/" }
}
]
@@ -112,17 +114,24 @@
],
volumeGroups: [
{
+ name: "/dev/test",
vgName: "test",
targetDevices: ["/dev/vda"],
+ spacePolicy: "keep",
logicalVolumes: [
{
- filesystem: {
+ name: "/dev/test/lv1",
+ filesystem: {
reuse: false,
default: true,
type: "btrfs"
},
- mountPath: "/",
- size: {
+ mountPath: "/",
+ delete: false,
+ deleteIfNeeded: false,
+ resize: false,
+ resizeIfNeeded: false,
+ size: {
default: true,
min: 0
}
diff --git a/service/test/agama/storage/config_solvers/md_raids_search_test.rb b/service/test/agama/storage/config_solvers/md_raids_search_test.rb
index abb8eaf699..6ce469d01c 100644
--- a/service/test/agama/storage/config_solvers/md_raids_search_test.rb
+++ b/service/test/agama/storage/config_solvers/md_raids_search_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -68,7 +68,7 @@
context "and any of the devices is not available" do
before do
- allow(storage_system.analyzer).to receive(:available_device?) do |dev|
+ allow(storage_system).to receive(:available?) do |dev|
dev.name != "/dev/md0"
end
end
diff --git a/service/test/agama/storage/config_test.rb b/service/test/agama/storage/config_test.rb
index 428b349cc5..d531aa4a9c 100644
--- a/service/test/agama/storage/config_test.rb
+++ b/service/test/agama/storage/config_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -558,13 +558,17 @@
it "returns all configs with configurable search" do
configs = subject.supporting_search
- expect(configs.size).to eq(6)
+ expect(configs.size).to eq(8)
end
it "includes all drives" do
expect(subject.supporting_search).to include(*subject.drives)
end
+ it "includes all volume groups" do
+ expect(subject.supporting_search).to include(*subject.volume_groups)
+ end
+
it "includes all MD RAIDs" do
expect(subject.supporting_search).to include(*subject.md_raids)
end
@@ -813,13 +817,17 @@
it "returns all configs with configurable delete" do
configs = subject.supporting_delete
- expect(configs.size).to eq(2)
+ expect(configs.size).to eq(3)
end
it "includes all partitions" do
expect(subject.supporting_delete).to include(*subject.partitions)
end
+ it "includes all logical volumes" do
+ expect(subject.supporting_delete).to include(*subject.logical_volumes)
+ end
+
it "does not include drives" do
expect(subject.supporting_delete).to_not include(*subject.drives)
end
@@ -831,10 +839,6 @@
it "does not include volume groups" do
expect(subject.supporting_delete).to_not include(*subject.volume_groups)
end
-
- it "does not include logical volumes" do
- expect(subject.supporting_delete).to_not include(*subject.logical_volumes)
- end
end
describe "#potential_for_md_device" do
diff --git a/service/test/agama/storage/system_test.rb b/service/test/agama/storage/system_test.rb
index d1cb0a36b4..933a08bd36 100644
--- a/service/test/agama/storage/system_test.rb
+++ b/service/test/agama/storage/system_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -67,14 +67,18 @@
end
describe "#available_md_raids" do
- let(:scenario) { "md_raids.yaml" }
+ let(:scenario) { "available_md_raids.yaml" }
- before do
- allow(disk_analyzer).to receive(:available_device?) { |d| d.name != "/dev/md0" }
+ it "includes all software RAIDs that are not in use" do
+ expect(subject.available_md_raids.map(&:name)).to contain_exactly("/dev/md0", "/dev/md1")
end
- it "includes all software RAIDs that are not in use" do
- expect(subject.available_md_raids.map(&:name)).to contain_exactly("/dev/md1", "/dev/md2")
+ it "does not include software RAIDs in use" do
+ expect(subject.available_md_raids.map(&:name)).to_not include("/dev/md2")
+ end
+
+ it "does not include software RAIDs over devices in use" do
+ expect(subject.available_md_raids.map(&:name)).to_not include("/dev/md3")
end
end
@@ -85,4 +89,20 @@
expect(subject.candidate_md_raids).to be_empty
end
end
+
+ describe "#available_volume_groups " do
+ let(:scenario) { "available_volume_groups.yaml" }
+
+ it "includes all volume groups that are not in use" do
+ expect(subject.available_volume_groups.map(&:name)).to contain_exactly("/dev/vg0", "/dev/vg1")
+ end
+
+ it "does not include volume groups in use" do
+ expect(subject.available_volume_groups.map(&:name)).to_not include("/dev/vg2")
+ end
+
+ it "does not include volume groups over devices in use" do
+ expect(subject.available_volume_groups.map(&:name)).to_not include("/dev/vg3")
+ end
+ end
end
diff --git a/service/test/fixtures/available_md_raids.yaml b/service/test/fixtures/available_md_raids.yaml
new file mode 100644
index 0000000000..f61f07d918
--- /dev/null
+++ b/service/test/fixtures/available_md_raids.yaml
@@ -0,0 +1,108 @@
+---
+- disk:
+ name: /dev/vda
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vda1
+ - partition:
+ size: 10 GiB
+ name: /dev/vda2
+- disk:
+ name: /dev/vdb
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vdb1
+ - partition:
+ size: 10 GiB
+ name: /dev/vdb2
+- disk:
+ name: /dev/vdc
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vdc1
+- disk:
+ name: /dev/vdd
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vdd1
+- disk:
+ name: /dev/vde
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vde1
+- disk:
+ name: /dev/vdf
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vdf1
+ - partition:
+ size: 10 GiB
+ name: /dev/vdf2
+ file_system: ext4
+ mount_point: /test1
+
+- md:
+ name: "/dev/md0"
+ chunk_size: 16 KiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 1 GiB
+ name: /dev/md0p1
+ - partition:
+ size: 1 GiB
+ name: /dev/md0p2
+ md_devices:
+ - md_device:
+ blk_device: /dev/vda1
+ - md_device:
+ blk_device: /dev/vdb1
+- md:
+ name: "/dev/md1"
+ chunk_size: 16 KiB
+ md_devices:
+ - md_device:
+ blk_device: /dev/vda2
+ - md_device:
+ blk_device: /dev/vdb2
+- md:
+ name: "/dev/md2"
+ chunk_size: 16 KiB
+ md_devices:
+ - md_device:
+ blk_device: /dev/vdc1
+ - md_device:
+ blk_device: /dev/vdd1
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/md2p1
+ file_system: ext4
+ mount_point: /test1
+- md:
+ name: "/dev/md3"
+ chunk_size: 16 KiB
+ md_devices:
+ - md_device:
+ blk_device: /dev/vde1
+ - md_device:
+ blk_device: /dev/vdf1
diff --git a/service/test/fixtures/available_volume_groups.yaml b/service/test/fixtures/available_volume_groups.yaml
new file mode 100644
index 0000000000..c3f43fc3f3
--- /dev/null
+++ b/service/test/fixtures/available_volume_groups.yaml
@@ -0,0 +1,72 @@
+---
+- disk:
+ name: /dev/vda
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vda1
+ - partition:
+ size: 10 GiB
+ name: /dev/vda2
+- disk:
+ name: /dev/vdb
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vdb1
+- disk:
+ name: /dev/vdc
+ size: 500 GiB
+ partition_table: gpt
+ partitions:
+ - partition:
+ size: 10 GiB
+ name: /dev/vdc1
+ - partition:
+ size: 10 GiB
+ name: /dev/vdc2
+ file_system: ext4
+ mount_point: /test1
+- lvm_vg:
+ vg_name: vg0
+ lvm_pvs:
+ - lvm_pv:
+ blk_device: /dev/vda1
+ lvm_lvs:
+ - lvm_lv:
+ size: 10 GiB
+ lv_name: lv1
+- lvm_vg:
+ vg_name: vg1
+ lvm_pvs:
+ - lvm_pv:
+ blk_device: /dev/vda2
+ lvm_lvs:
+ - lvm_lv:
+ size: 10 GiB
+ lv_name: lv1
+ file_system: btrfs
+- lvm_vg:
+ vg_name: vg2
+ lvm_pvs:
+ - lvm_pv:
+ blk_device: /dev/vdb1
+ lvm_lvs:
+ - lvm_lv:
+ size: 10 GiB
+ lv_name: lv1
+ file_system: btrfs
+ mount_point: /test2
+- lvm_vg:
+ vg_name: vg3
+ lvm_pvs:
+ - lvm_pv:
+ blk_device: /dev/vdc1
+ lvm_lvs:
+ - lvm_lv:
+ size: 10 GiB
+ lv_name: lv1
diff --git a/service/test/y2storage/agama_proposal_lvm_test.rb b/service/test/y2storage/agama_proposal_lvm_test.rb
index ae5ec88034..de26496170 100644
--- a/service/test/y2storage/agama_proposal_lvm_test.rb
+++ b/service/test/y2storage/agama_proposal_lvm_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2024-2025] SUSE LLC
+# Copyright (c) [2024-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -784,5 +784,47 @@
expect(lv.size).to be > Y2Storage::DiskSize.GiB(50)
end
end
+
+ context "when deleting volumes in a new volume group" do
+ let(:config_json) do
+ {
+ boot: { configure: false },
+ drives: [
+ {
+ partitions: [
+ {
+ alias: "system-pv",
+ size: "40 GiB"
+ }
+ ]
+ }
+ ],
+ volumeGroups: [
+ {
+ name: "system",
+ physicalVolumes: ["system-pv"],
+ logicalVolumes: [
+ { search: "*", delete: true },
+ {
+ name: "root",
+ size: "5 GiB",
+ filesystem: {
+ path: "/",
+ type: "btrfs"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ end
+
+ it "proposes the expected devices" do
+ devicegraph = proposal.propose
+
+ vg = devicegraph.find_by_name("/dev/system")
+ expect(vg.lvm_lvs.map { |lv| lv.mount_point.path }).to contain_exactly("/")
+ end
+ end
end
end
diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes
index 3c8cb36172..018fffdee4 100644
--- a/web/package/agama-web-ui.changes
+++ b/web/package/agama-web-ui.changes
@@ -1,3 +1,9 @@
+-------------------------------------------------------------------
+Mon Apr 13 15:25:11 UTC 2026 - José Iván López González
+
+- Extend storage UI to reuse LVM volume groups
+ (gh#agama-project/agama#3380).
+
-------------------------------------------------------------------
Tue Apr 7 14:19:47 UTC 2026 - Imobach Gonzalez Sosa
diff --git a/web/src/components/core/Annotation.test.tsx b/web/src/components/core/Annotation.test.tsx
index 67436501f0..75c3bab92e 100644
--- a/web/src/components/core/Annotation.test.tsx
+++ b/web/src/components/core/Annotation.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -48,4 +48,9 @@ describe("Annotation", () => {
const content = screen.getByText("Configured for installation only");
expect(content.tagName).toBe("STRONG");
});
+
+ it("renders nothing when children is empty", () => {
+ const { container } = plainRender({undefined});
+ expect(container).toBeEmptyDOMElement();
+ });
});
diff --git a/web/src/components/core/Annotation.tsx b/web/src/components/core/Annotation.tsx
index a5f27e4370..46b5a5f7ef 100644
--- a/web/src/components/core/Annotation.tsx
+++ b/web/src/components/core/Annotation.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -45,6 +45,8 @@ type AnnotationProps = React.PropsWithChildren<{
* ```
*/
export default function Annotation({ icon = "emergency", children }: AnnotationProps) {
+ if (!children) return null;
+
return (
{children}
diff --git a/web/src/components/core/Popup.test.tsx b/web/src/components/core/Popup.test.tsx
index 8e11362f70..953eab3a74 100644
--- a/web/src/components/core/Popup.test.tsx
+++ b/web/src/components/core/Popup.test.tsx
@@ -172,6 +172,13 @@ describe("Popup.SecondaryAction", () => {
const button = screen.queryByRole("button", { name: "Do something" });
expect(button.classList.contains("pf-m-secondary")).toBe(true);
});
+
+ it("renders a 'link' button when asLink is set", async () => {
+ installerRender(Do something);
+
+ const button = screen.queryByRole("button", { name: "Do something" });
+ expect(button.classList.contains("pf-m-link")).toBe(true);
+ });
});
describe("Popup.AncillaryAction", () => {
@@ -234,4 +241,14 @@ describe("Popup.Cancel", () => {
expect(button.classList.contains("pf-m-secondary")).toBe(true);
});
});
+
+ describe("when asLink is set", () => {
+ it("renders a 'link' button", async () => {
+ installerRender();
+
+ const button = screen.queryByRole("button", { name: "Cancel" });
+ expect(button).not.toBeNull();
+ expect(button.classList.contains("pf-m-link")).toBe(true);
+ });
+ });
});
diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx
index 91ed9906e2..b5839b1900 100644
--- a/web/src/components/core/Popup.tsx
+++ b/web/src/components/core/Popup.tsx
@@ -36,7 +36,7 @@ import { fork } from "radashi";
import { _, TranslatedString } from "~/i18n";
type ButtonWithoutVariantProps = Omit;
-type PredefinedAction = React.PropsWithChildren;
+type PredefinedAction = React.PropsWithChildren;
export type PopupProps = {
/** The dialog title */
title?: ModalHeaderProps["title"];
@@ -122,8 +122,8 @@ const Confirm = ({ children = _("Confirm"), ...actionProps }: PredefinedAction)
* Dismiss
*
*/
-const SecondaryAction = ({ children, ...actionProps }: PredefinedAction) => (
-
+const SecondaryAction = ({ children, asLink, ...actionProps }: PredefinedAction) => (
+
{children}
);
diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx
index 08e3c5a3a8..b1fd3004cb 100644
--- a/web/src/components/layout/Icon.tsx
+++ b/web/src/components/layout/Icon.tsx
@@ -60,6 +60,7 @@ import MoreVert from "@bolderIcons/more_vert.svg?component";
import NetworkWifi from "@icons/network_wifi.svg?component";
import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component";
import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component";
+import NotificationsActive from "@icons/notifications_active.svg?component";
import Report from "@icons/report.svg?component";
import RestartAlt from "@icons/restart_alt.svg?component";
import SearchOff from "@icons/search_off.svg?component";
@@ -109,6 +110,7 @@ const icons = {
network_wifi: NetworkWifi,
network_wifi_1_bar: NetworkWifi1Bar,
network_wifi_3_bar: NetworkWifi3Bar,
+ notifications_ative: NotificationsActive,
report: Report,
restart_alt: RestartAlt,
search_off: SearchOff,
diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx
index eeb604e971..5cfd721246 100644
--- a/web/src/components/storage/ConfigEditor.tsx
+++ b/web/src/components/storage/ConfigEditor.tsx
@@ -58,6 +58,8 @@ const NoDevicesConfiguredAlert = () => {
export default function ConfigEditor() {
const config = useConfigModel();
+ if (!config) return;
+
const drives = config.drives;
const mdRaids = config.mdRaids;
const volumeGroups = config.volumeGroups;
@@ -70,8 +72,8 @@ export default function ConfigEditor() {
<>
{/* FIXME add arial label */}
}
+ title="Select"
onCancel={onCancelMock}
onConfirm={onConfirmMock}
/>,
);
+ screen.getByText("Introductory text");
+ });
- const table = screen.getByRole("grid");
- const sortByDeviceButton = within(table).getByRole("button", { name: "Device" });
+ describe("initial tab", () => {
+ it("opens the Disks tab by default", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "Disks" })).toHaveAttribute("aria-selected", "true");
+ });
- expect(getColumnValues(table, "Device")).toEqual(["/dev/sda", "/dev/sdb"]);
+ it("opens the tab matching initialTab", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true");
+ });
- await user.click(sortByDeviceButton);
+ it("opens the tab containing the selected device", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "LVM" })).toHaveAttribute("aria-selected", "true");
+ });
- expect(getColumnValues(table, "Device")).toEqual(["/dev/sdb", "/dev/sda"]);
+ it("opens the tab of the auto-selected device when no device is given", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true");
+ });
});
- it("allows sorting by device size", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("sideEffectsAlert", () => {
+ it("shows the disks alert in the footer when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ Disk selection note }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ screen.getByText("Disk selection note");
+ });
+
+ it("shows the RAID alert in the footer when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ RAID selection note }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ const mdRow = screen.getByRole("row", { name: /md0/ });
+ await user.click(within(mdRow).getByRole("radio"));
+ screen.getByText("RAID selection note");
+ });
- const table = screen.getByRole("grid");
- const sortBySizeButton = within(table).getByRole("button", { name: "Size" });
+ it("shows the LVM alert in the footer when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ LVM selection note }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ const vgRow = screen.getByRole("row", { name: /vg0/ });
+ await user.click(within(vgRow).getByRole("radio"));
+ screen.getByText("LVM selection note");
+ });
+
+ it("does not show the alert when the selection matches the given device", () => {
+ installerRender(
+ Disk selection note }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ expect(screen.queryByText("Disk selection note")).toBeNull();
+ });
+ });
- // By default, table is sorted by device name. Switch sorting to size in asc direction
- await user.click(sortBySizeButton);
+ describe("empty states", () => {
+ it("shows an empty state in the Disks tab when no disks are given", () => {
+ installerRender(
+ ,
+ );
+ screen.getByText("No disks found");
+ });
- expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]);
+ it("shows an empty state in the RAID tab when no RAID devices are given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByText("No RAID devices found");
+ });
- // Now keep sorting by size, but in desc direction
- await user.click(sortBySizeButton);
+ it("shows an empty state in the LVM tab when no volume groups are given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByText("No LVM volume groups found");
+ });
- expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]);
+ it("shows the create link in the empty LVM state when newVolumeGroupLinkText is given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByRole("link", { name: "Define a new LVM" });
+ });
+
+ it("does not show a create link in the empty LVM state when newVolumeGroupLinkText is not given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ expect(screen.queryByRole("link", { name: /create/i })).toBeNull();
+ });
+
+ it("does not show a create link in the empty RAID state", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ expect(screen.queryByRole("link", { name: /create/i })).toBeNull();
+ });
});
- it("triggers onCancel callback when users selects `Cancel` action", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("LVM tab with volume groups", () => {
+ it("shows the create link when newVolumeGroupLinkText is given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByRole("link", { name: "Define a new LVM" });
+ });
- const cancelAction = screen.getByRole("button", { name: "Cancel" });
- await user.click(cancelAction);
- expect(onCancelMock).toHaveBeenCalled();
+ it("does not show a create link when newVolumeGroupLinkText is not given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ expect(screen.queryByRole("link", { name: /create/i })).toBeNull();
+ });
});
- it("triggers `onCancel` callback when users selects `Cancel` action", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("autoSelectOnTabChange", () => {
+ it("auto-selects the first device of the new tab by default", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("button", { name: /Add.*md0/ });
+ });
+
+ it("clears the selection when switching to an empty tab by default", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("button", { name: "Change" });
+ });
- const cancelAction = screen.getByRole("button", { name: "Cancel" });
- await user.click(cancelAction);
- expect(onCancelMock).toHaveBeenCalled();
+ it("keeps the current selection when false", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("button", { name: /Add.*sda/ });
+ });
});
- it("triggers `onConfirm` callback with selected devices when users selects `Confirm` action", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("tabIntros", () => {
+ it("shows intro text in the Disks tab when devices are present", () => {
+ installerRender(
+ Disk intro text }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ screen.getByText("Disk intro text");
+ });
+
+ it("shows intro text in the RAID tab when devices are present", async () => {
+ const { user } = installerRender(
+ RAID intro text }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByText("RAID intro text");
+ });
+
+ it("shows intro text in the LVM tab when devices are present", async () => {
+ const { user } = installerRender(
+ LVM intro text }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByText("LVM intro text");
+ });
+
+ it("does not show intro text when tab is empty", async () => {
+ const { user } = installerRender(
+ RAID intro text }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ expect(screen.queryByText("RAID intro text")).toBeNull();
+ });
+ });
+
+ describe("custom empty states", () => {
+ it("shows custom empty state title for Disks tab", () => {
+ installerRender(
+ ,
+ );
+ screen.getByText("Custom disk title");
+ });
+
+ it("shows custom empty state body for RAID tab", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByText("Custom RAID body text");
+ });
+
+ it("shows custom empty state for LVM tab", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByText("No VGs available");
+ screen.getByText("Cannot format volume groups");
+ });
+
+ it("falls back to default empty state when custom not provided", () => {
+ installerRender(
+ ,
+ );
+ screen.getByText("No disks found");
+ screen.getByText("No disks are available for selection.");
+ });
+ });
+
+ describe("newDeviceLinkTexts", () => {
+ // RAID device creation is not yet implemented (no STORAGE.mdRaid.add route exists)
+ it.skip("shows create link for RAID in empty state", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("link", { name: "Create new RAID" });
+ });
+
+ it("shows create link for LVM in empty state", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByRole("link", { name: "Create new LVM" });
+ });
+
+ // RAID device creation is not yet implemented (no STORAGE.mdRaid.add route exists)
+ it.skip("shows create link for RAID when devices exist", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("link", { name: "Add another RAID" });
+ });
+
+ it("shows both tabIntros and newDeviceLinkTexts together", async () => {
+ const { user } = installerRender(
+ Choose a volume group }}
+ newDeviceLinkTexts={{ volumeGroups: "Create new VG" }}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByText("Choose a volume group");
+ screen.getByRole("link", { name: "Create new VG" });
+ });
+ });
+
+ describe("actions", () => {
+ it("triggers onCancel when user selects Cancel", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onCancelMock).toHaveBeenCalled();
+ });
+
+ it("shows 'Add' when there is no prior device", () => {
+ installerRender(
+ ,
+ );
+ screen.getByRole("button", { name: /Add/ });
+ });
+
+ it("shows 'Keep' when the selection matches the given device", () => {
+ installerRender(
+ ,
+ );
+ screen.getByRole("button", { name: /Keep/ });
+ });
+
+ it("shows 'Change to' when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ screen.getByRole("button", { name: /Change to/ });
+ });
+
+ it("shows a 'Select a device' hint when no devices are available", () => {
+ installerRender(
+ ,
+ );
+ screen.getByText("Select a device");
+ });
- const sdbRow = screen.getByRole("row", { name: /\/dev\/sdb/ });
- const sdbRadio = within(sdbRow).getByRole("radio");
- await user.click(sdbRadio);
- const confirmAction = screen.getByRole("button", { name: "Confirm" });
- await user.click(confirmAction);
- expect(onConfirmMock).toHaveBeenCalledWith([sdb]);
+ it("triggers onConfirm with the selected device when the user confirms", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ await user.click(screen.getByRole("button", { name: /Change to/ }));
+ expect(onConfirmMock).toHaveBeenCalledWith([sdb]);
+ });
});
});
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index 9639ad8fc7..3697465bf4 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -20,134 +20,400 @@
* find current contact information at www.suse.com.
*/
-import React, { useState } from "react";
-import { ButtonProps, Flex, Label } from "@patternfly/react-core";
-import Popup, { PopupProps } from "~/components/core/Popup";
-import SelectableDataTable, {
- SortedBy,
- SelectableDataTableProps,
-} from "~/components/core/SelectableDataTable";
+import React, { useId, useState } from "react";
+import { first } from "radashi";
+import { sprintf } from "sprintf-js";
import {
- typeDescription,
- contentDescription,
- filesystemLabels,
-} from "~/components/storage/utils/device";
-import { deviceSize } from "~/components/storage/utils";
-import { sortCollection } from "~/utils";
+ ButtonProps,
+ EmptyState,
+ EmptyStateActions,
+ EmptyStateBody,
+ EmptyStateFooter,
+ Flex,
+ HelperText,
+ HelperTextItem,
+ PageSection,
+ Stack,
+ Tab,
+ Tabs,
+} from "@patternfly/react-core";
+import Annotation from "~/components/core/Annotation";
+import Link from "~/components/core/Link";
+import NestedContent from "~/components/core/NestedContent";
+import Popup from "~/components/core/Popup";
+import SubtleContent from "~/components/core/SubtleContent";
+import DrivesTable from "~/components/storage/DrivesTable";
+import MdRaidsTable from "~/components/storage/MdRaidsTable";
+import VolumeGroupsTable from "~/components/storage/VolumeGroupsTable";
+import { STORAGE } from "~/routes/paths";
+import { deviceLabel } from "~/components/storage/utils";
import { _ } from "~/i18n";
-import { deviceSystems } from "~/model/storage/device";
+
+import type { PopupProps } from "~/components/core/Popup";
import type { Storage } from "~/model/system";
-type DeviceSelectorProps = {
- devices: Storage.Device[];
- selectedDevices?: Storage.Device[];
- onSelectionChange: SelectableDataTableProps["onSelectionChange"];
- selectionMode?: SelectableDataTableProps["selectionMode"];
-};
+/** Identifies which tab is active in {@link DeviceSelectorModal}. */
+export type TabKey = "disks" | "mdRaids" | "volumeGroups";
-const size = (device: Storage.Device) => {
- return deviceSize(device.block.size);
-};
+/** Tab keys for device types that can be created (excludes disks). */
+export type CreatableTabKey = "mdRaids" | "volumeGroups";
-const description = (device: Storage.Device) => {
- const model = device.drive?.model;
- if (model && model.length) return model;
+/** Side effects shown when selecting a device from a specific tab. */
+export type SideEffects = {
+ [K in TabKey]?: React.ReactNode;
+};
- return typeDescription(device);
+/** Introductory content shown at the top of each tab. */
+export type TabIntros = {
+ [K in TabKey]?: React.ReactNode;
};
-const details = (device: Storage.Device) => {
- return (
-
- {contentDescription(device)}
- {deviceSystems(device).map((s, i) => (
-
- ))}
- {filesystemLabels(device).map((s, i) => (
-
- ))}
-
- );
+/** Empty state titles for each tab when no devices are available. */
+export type EmptyStateTitles = {
+ [K in TabKey]?: React.ReactNode;
};
-// TODO: document
-const DeviceSelector = ({
- devices,
- selectedDevices,
- onSelectionChange,
- selectionMode = "single",
-}: DeviceSelectorProps) => {
- const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
-
- const columns = [
- { name: _("Device"), value: (device: Storage.Device) => device.name, sortingKey: "name" },
- {
- name: _("Size"),
- value: size,
- sortingKey: (d: Storage.Device) => d.block.size,
- pfTdProps: { style: { width: "10ch" } },
- },
- { name: _("Description"), value: description },
- { name: _("Current content"), value: details },
- ];
-
- // Sorting
- const sortingKey = columns[sortedBy.index].sortingKey;
- const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+/** Empty state body text for each tab when no devices are available. */
+export type EmptyStateBodies = {
+ [K in TabKey]?: React.ReactNode;
+};
- return (
- <>
-
- >
- );
+/**
+ * Link text for creating new devices.
+ * Only available for device types that can be created (excludes disks).
+ */
+export type NewDeviceLinkTexts = {
+ [K in CreatableTabKey]?: React.ReactNode;
};
-type DeviceSelectorModalProps = Omit & {
+/** Props for {@link DeviceSelectorModal}. */
+export type DeviceSelectorModalProps = Omit & {
+ /** General information shown at the top of the modal, above the tabs. */
+ intro?: React.ReactNode;
+ /** Tab to open initially. Takes precedence over the tab derived from {@link selected}. */
+ initialTab?: TabKey;
+ /** Currently selected device. Determines the initial tab and initial selection. */
selected?: Storage.Device;
- devices: Storage.Device[];
+ /** Available disks. */
+ disks?: Storage.Device[];
+ /** Available software RAID devices. */
+ mdRaids?: Storage.Device[];
+ /** Available LVM volume groups. */
+ volumeGroups?: Storage.Device[];
+ /**
+ * Side effects shown when selecting a device from a specific tab.
+ * Only shown when the selection differs from {@link selected}.
+ */
+ sideEffects?: SideEffects;
+ /**
+ * Introductory content shown at the top of each tab.
+ * Not rendered when the tab is empty (empty state is shown instead).
+ */
+ tabIntros?: TabIntros;
+ /** Custom titles for empty states. Defaults are provided for each tab. */
+ emptyStateTitles?: EmptyStateTitles;
+ /** Custom body text for empty states. Defaults are provided for each tab. */
+ emptyStateBodies?: EmptyStateBodies;
+ /**
+ * Link text for creating new devices in each tab.
+ * When set, a link is shown with this text. Only available for creatable device types.
+ */
+ newDeviceLinkTexts?: NewDeviceLinkTexts;
+ /**
+ * Whether switching tabs auto-selects the first device of the new tab,
+ * or clears the selection when the tab is empty. Defaults to `true`.
+ */
+ autoSelectOnTabChange?: boolean;
+ /** Called with the new selection when the user confirms. */
onConfirm: (selection: Storage.Device[]) => void;
+ /** Called when the user cancels. */
onCancel: ButtonProps["onClick"];
};
+const TABS: Record = { disks: 0, mdRaids: 1, volumeGroups: 2 };
+
+/** Empty state shown in a tab when no devices of that type are available. */
+const NoDevicesFound = ({
+ title,
+ body,
+ action,
+}: {
+ title: React.ReactNode;
+ body: React.ReactNode;
+ action?: React.ReactNode;
+}) => (
+
+ {body}
+ {action && (
+
+ {action}
+
+ )}
+
+);
+
+/**
+ * Renders a link to create a new device, styled as subtle content.
+ * The link position and text are extracted from `text` using `[text]` markers.
+ */
+const CreateDeviceLink = ({ text, to }: { text: string; to: string }) => {
+ const [before, linkText, after] = text.split(/[[\]]/);
+ return (
+
+ {before}
+
+ {linkText}
+
+ {after}
+
+ );
+};
+
+/**
+ * Wrapper for a tab's scrollable content area. Renders `children` when given,
+ * or falls back to {@link NoDevicesFound} built from the `empty*` props.
+ */
+const TabContent = ({
+ emptyTitle,
+ emptyBody,
+ emptyAction,
+ intro,
+ children,
+}: {
+ emptyTitle: React.ReactNode;
+ emptyBody: React.ReactNode;
+ emptyAction?: React.ReactNode;
+ intro?: React.ReactNode;
+ children?: React.ReactNode;
+}) => (
+
+
+ {children ? (
+ <>
+ {intro}
+ {children}
+ >
+ ) : (
+
+ )}
+
+
+);
+
+/**
+ * Returns the tab index to activate when the modal opens.
+ *
+ * Resolution order:
+ * 1. Explicit `initialTab` key.
+ * 2. Tab that contains `selected`.
+ * 3. First tab (index 0).
+ */
+function getInitialTabIndex(
+ initialTab?: TabKey,
+ selected?: Storage.Device,
+ deviceLists?: Storage.Device[][],
+): number {
+ if (initialTab) return TABS[initialTab];
+
+ if (selected && deviceLists) {
+ const index = deviceLists.findIndex((list) => list.some((d) => d.sid === selected.sid));
+ return index !== -1 ? index : 0;
+ }
+
+ return 0;
+}
+
+/**
+ * Modal for selecting a storage device across three categories: disks,
+ * software RAID devices, and LVM volume groups.
+ *
+ * The confirm button label reflects the state of the selection:
+ *
+ * - "Add X" when there is no prior device and one is selected,
+ * - "Keep X" when the selection matches {@link
+ * DeviceSelectorModalProps.selected},
+ * - "Change to X" when a different device is picked,
+ * - "Add" or "Change" when no device is selected (e.g. after switching to an
+ * empty tab).
+ *
+ * An optional side-effects alert is displayed near the confirm button when the
+ * user switches to a different device. Both the alert and the "Select a device"
+ * hint are live regions linked to the confirm button via `aria-describedby` so
+ * assistive technologies announce changes.
+ */
export default function DeviceSelectorModal({
- selected = undefined,
+ selected: previousDevice,
+ initialTab,
onConfirm,
onCancel,
- devices,
+ intro,
+ disks = [],
+ mdRaids = [],
+ volumeGroups = [],
+ sideEffects,
+ tabIntros,
+ emptyStateTitles,
+ emptyStateBodies,
+ newDeviceLinkTexts,
+ autoSelectOnTabChange = true,
...popupProps
}: DeviceSelectorModalProps): React.ReactNode {
- // FIXME: improve initial selection handling
+ const confirmHintId = useId();
+ const initialDevice = previousDevice ?? first([...disks, ...mdRaids, ...volumeGroups]);
const [selectedDevices, setSelectedDevices] = useState(
- selected ? [selected] : [devices[0]],
+ initialDevice ? [initialDevice] : [],
+ );
+ const [activeTab, setActiveTab] = useState(() =>
+ getInitialTabIndex(initialTab, initialDevice, [disks, mdRaids, volumeGroups]),
);
+ const tabLists = [disks, mdRaids, volumeGroups];
+
+ const currentDevice = selectedDevices[0];
+ const deviceSideEffectsAlert =
+ currentDevice &&
+ [
+ { list: disks, alert: sideEffects?.disks },
+ { list: mdRaids, alert: sideEffects?.mdRaids },
+ { list: volumeGroups, alert: sideEffects?.volumeGroups },
+ ].find(({ list }) => list.some((d) => d.sid === currentDevice.sid))?.alert;
+
+ const deviceInInitialTab =
+ currentDevice && tabLists[activeTab].some((d) => d.sid === currentDevice.sid);
+
+ const onTabClick = (_, tabIndex: number) => {
+ setActiveTab(tabIndex);
+ if (autoSelectOnTabChange) {
+ const device = first(tabLists[tabIndex]);
+ setSelectedDevices(device ? [device] : []);
+ }
+ };
- const onAccept = () => {
- selectedDevices !== Array(selected) && onConfirm(selectedDevices);
+ const confirmLabel = (): string => {
+ if (!currentDevice) return previousDevice ? _("Change") : _("Add");
+ // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)"
+ if (!previousDevice) return sprintf(_("Add %s"), deviceLabel(currentDevice));
+ // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)"
+ if (currentDevice.sid === previousDevice.sid)
+ return sprintf(_("Keep %s"), deviceLabel(currentDevice));
+ // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)"
+ return sprintf(_("Change to %s"), deviceLabel(currentDevice));
};
return (
-
-
+
+
+ {intro}
+
+
+
+
+ {disks.length > 0 && (
+
+ )}
+
+
+
+
+ {mdRaids.length > 0 && (
+
+ )}
+
+
+
+ {newDeviceLinkTexts.volumeGroups}
+ )
+ }
+ >
+ {volumeGroups.length > 0 && (
+ <>
+ {newDeviceLinkTexts?.volumeGroups && (
+
+ )}
+
+ >
+ )}
+
+
+
+
+
-
-
+
+ {!currentDevice && (
+
+ {_("Select a device")}
+
+ )}
+ {currentDevice && currentDevice.sid !== previousDevice?.sid && deviceSideEffectsAlert && (
+
+
+ {deviceSideEffectsAlert}
+
+
+ )}
+
+ onConfirm(selectedDevices)}
+ isDisabled={!currentDevice}
+ aria-describedby={confirmHintId}
+ >
+ {confirmLabel()}
+
+
+
+
);
diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx
index e04b7ea246..7cc42fd47c 100644
--- a/web/src/components/storage/DriveEditor.test.tsx
+++ b/web/src/components/storage/DriveEditor.test.tsx
@@ -43,7 +43,8 @@ jest.mock("~/hooks/model/storage/config-model", () => ({
useAddDriveFromMdRaid: jest.fn(),
useAddMdRaidFromDrive: jest.fn(),
useDeleteDrive: () => mockDeleteDrive,
- useAddVolumeGroupFromPartitionable: () => mockAddVolumeGroupFromPartitionable,
+ useConvertPartitionableToVolumeGroup: () => mockAddVolumeGroupFromPartitionable,
+ useConvertDevice: () => jest.fn(),
}));
const mockSystemDevice = jest.fn();
diff --git a/web/src/components/storage/DrivesTable.test.tsx b/web/src/components/storage/DrivesTable.test.tsx
new file mode 100644
index 0000000000..ad2fa0b507
--- /dev/null
+++ b/web/src/components/storage/DrivesTable.test.tsx
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen, within } from "@testing-library/react";
+import { getColumnValues, plainRender } from "~/test-utils";
+import type { Storage } from "~/model/system";
+import DrivesTable from "./DrivesTable";
+
+const sda: Storage.Device = {
+ sid: 59,
+ class: "drive",
+ name: "/dev/sda",
+ description: "SDA drive",
+ drive: {
+ model: "Micron 1100 SATA",
+ vendor: "Micron",
+ bus: "SATA",
+ busId: "",
+ transport: "sata",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 1024,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const sdb: Storage.Device = {
+ sid: 62,
+ class: "drive",
+ name: "/dev/sdb",
+ description: "SDB drive",
+ drive: {
+ model: "Samsung Evo 8 Pro",
+ vendor: "Samsung",
+ bus: "USB",
+ busId: "",
+ transport: "usb",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 2048,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const onSelectionChangeMock = jest.fn();
+
+describe("DrivesTable", () => {
+ it("renders Device, Size, Description, and Current content columns", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ within(table).getByRole("columnheader", { name: "Device" });
+ within(table).getByRole("columnheader", { name: "Size" });
+ within(table).getByRole("columnheader", { name: "Description" });
+ within(table).getByRole("columnheader", { name: "Current content" });
+ });
+
+ it("renders a row per device", () => {
+ plainRender();
+ screen.getByRole("row", { name: /sda/ });
+ screen.getByRole("row", { name: /sdb/ });
+ });
+
+ it("allows sorting by device name", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Device" });
+
+ expect(getColumnValues(table, "Device")).toEqual(["sda", "sdb"]);
+
+ await user.click(sortButton);
+
+ expect(getColumnValues(table, "Device")).toEqual(["sdb", "sda"]);
+ });
+
+ it("allows sorting by size", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Size" });
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]);
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]);
+ });
+
+ it("calls onSelectionChange when a device is selected", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ expect(onSelectionChangeMock).toHaveBeenCalledWith([sdb]);
+ });
+});
diff --git a/web/src/components/storage/DrivesTable.tsx b/web/src/components/storage/DrivesTable.tsx
new file mode 100644
index 0000000000..99d6bd6e70
--- /dev/null
+++ b/web/src/components/storage/DrivesTable.tsx
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React, { useState } from "react";
+import SelectableDataTable from "~/components/core/SelectableDataTable";
+import DeviceContent from "~/components/storage/DeviceContent";
+import { deviceBaseName, deviceSize } from "~/components/storage/utils";
+import { typeDescription } from "~/components/storage/utils/device";
+import { sortCollection } from "~/utils";
+import { _ } from "~/i18n";
+
+import type { Storage } from "~/model/system";
+import type { SortedBy, SelectableDataTableProps } from "~/components/core/SelectableDataTable";
+
+/** Props for {@link DrivesTable}. */
+type DrivesTableProps = {
+ /** Available drives. */
+ devices: Storage.Device[];
+ /** Currently selected drives. */
+ selectedDevices?: Storage.Device[];
+ /** Called when the selection changes. */
+ onSelectionChange: SelectableDataTableProps["onSelectionChange"];
+ /** Selection mode. Defaults to `"single"`. */
+ selectionMode?: SelectableDataTableProps["selectionMode"];
+};
+
+const size = (device: Storage.Device) => {
+ const bytes = device.volumeGroup?.size || device.block?.size || 0;
+ return deviceSize(bytes);
+};
+
+const description = (device: Storage.Device) => {
+ const model = device.drive?.model;
+ if (model && model.length) return model;
+
+ return typeDescription(device);
+};
+
+/**
+ * Table for selecting among available drives.
+ */
+export default function DrivesTable({
+ devices,
+ selectedDevices,
+ onSelectionChange,
+ selectionMode = "single",
+}: DrivesTableProps) {
+ const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
+
+ const columns = [
+ {
+ name: _("Device"),
+ value: (device: Storage.Device) => deviceBaseName(device),
+ sortingKey: "name",
+ pfTdProps: { style: { width: "15ch" } },
+ },
+ {
+ name: _("Size"),
+ value: size,
+ sortingKey: (d: Storage.Device) => d.block.size,
+ pfTdProps: { style: { width: "10ch" } },
+ },
+ { name: _("Description"), value: description },
+ { name: _("Current content"), value: (d: Storage.Device) => },
+ ];
+
+ const sortingKey = columns[sortedBy.index].sortingKey;
+ const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx
index 697d476f49..ee742c3b9e 100644
--- a/web/src/components/storage/LogicalVolumePage.tsx
+++ b/web/src/components/storage/LogicalVolumePage.tsx
@@ -20,16 +20,11 @@
* find current contact information at www.suse.com.
*/
-/**
- * @fixme This code was done in a hurry for including LVM managent in SLE16 beta3. It must be
- * completely refactored. There are a lot of duplications with PartitionPage. Both PartitionPage
- * and LogicalVolumePage should be adapted to share as much functionality as possible.
- */
-
-import React, { useCallback, useEffect, useId, useMemo, useState } from "react";
-import { useParams, useNavigate } from "react-router";
+import React, { useState } from "react";
+import { useParams, useNavigate, useLocation } from "react-router";
import {
ActionGroup,
+ Divider,
Flex,
FlexItem,
Form,
@@ -37,44 +32,50 @@ import {
FormHelperText,
HelperText,
HelperTextItem,
+ Label,
SelectGroup,
SelectList,
SelectOption,
SelectOptionProps,
+ Split,
+ SplitItem,
Stack,
- StackItem,
TextInput,
} from "@patternfly/react-core";
import { Page, SelectWrapper as Select, SubtleContent } from "~/components/core/";
import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper";
import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable";
import AutoSizeText from "~/components/storage/AutoSizeText";
-import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils";
+import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect";
+import ResourceNotFound from "~/components/core/ResourceNotFound";
import configModel from "~/model/storage/config-model";
+import { useVolumeTemplate, useDevice } from "~/hooks/model/system/storage";
import {
- useSolvedConfigModel,
useConfigModel,
+ useSolvedConfigModel,
useMissingMountPaths,
- useVolumeGroup,
+ useVolumeGroup as useConfigModelVolumeGroup,
useAddLogicalVolume,
useEditLogicalVolume,
} from "~/hooks/model/storage/config-model";
-import { useVolumeTemplate } from "~/hooks/model/system/storage";
+import { deviceSize, deviceLabel, filesystemLabel, parseToBytes } from "~/components/storage/utils";
+import { _ } from "~/i18n";
+import { sprintf } from "sprintf-js";
import { STORAGE as PATHS, STORAGE } from "~/routes/paths";
import { unique } from "radashi";
import { compact } from "~/utils";
-import { sprintf } from "sprintf-js";
-import { _ } from "~/i18n";
-import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect";
-import type { ConfigModel, Data } from "~/model/storage/config-model";
+import type { ConfigModel } from "~/model/storage/config-model";
import type { Storage as System } from "~/model/system";
const NO_VALUE = "";
+const NEW_LOGICAL_VOLUME = "new";
+const REUSE_FILESYSTEM = "reuse";
type SizeOptionValue = "" | SizeMode;
type FormValue = {
mountPoint: string;
name: string;
+ target: string;
filesystem: string;
filesystemLabel: string;
sizeOption: SizeOptionValue;
@@ -93,7 +94,26 @@ type ErrorsHandler = {
getVisibleError: (id: string) => Error | undefined;
};
-function toData(value: FormValue): Data.LogicalVolume {
+function configuredLogicalVolumes(
+ volumeGroupConfig: ConfigModel.VolumeGroup,
+): ConfigModel.LogicalVolume[] {
+ if (volumeGroupConfig.spacePolicy === "custom")
+ return volumeGroupConfig.logicalVolumes.filter(
+ (l) =>
+ !configModel.volume.isNew(l) &&
+ (configModel.volume.isUsed(l) || configModel.volume.isUsedBySpacePolicy(l)),
+ );
+
+ return volumeGroupConfig.logicalVolumes.filter(configModel.volume.isReused);
+}
+
+function createLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume {
+ const name = (): string | undefined => {
+ if (value.target === NO_VALUE || value.target === NEW_LOGICAL_VOLUME) return undefined;
+
+ return value.target;
+ };
+
const filesystemType = (): ConfigModel.FilesystemType | undefined => {
if (value.filesystem === NO_VALUE) return undefined;
@@ -107,11 +127,14 @@ function toData(value: FormValue): Data.LogicalVolume {
return value.filesystem as ConfigModel.FilesystemType;
};
- const filesystem = (): Data.Filesystem | undefined => {
+ const filesystem = (): ConfigModel.Filesystem | undefined => {
+ if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true, default: true };
+
const type = filesystemType();
if (type === undefined) return undefined;
return {
+ default: false,
type,
label: value.filesystemLabel,
};
@@ -131,26 +154,32 @@ function toData(value: FormValue): Data.LogicalVolume {
return {
mountPath: value.mountPoint,
lvName: value.name,
+ name: name(),
filesystem: filesystem(),
size: size(),
};
}
-function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue {
- const mountPoint = (): string => logicalVolume.mountPath || NO_VALUE;
+function createFormValue(logicalVolumeConfig: ConfigModel.LogicalVolume): FormValue {
+ const mountPoint = (): string => logicalVolumeConfig.mountPath || NO_VALUE;
+
+ const target = (): string => logicalVolumeConfig.name || NEW_LOGICAL_VOLUME;
const filesystem = (): string => {
- const fs = logicalVolume.filesystem;
- if (!fs.type) return NO_VALUE;
+ const fsConfig = logicalVolumeConfig.filesystem;
+ if (fsConfig.reuse) return REUSE_FILESYSTEM;
+ if (!fsConfig.type) return NO_VALUE;
- return fs.type;
+ return fsConfig.type;
};
- const filesystemLabel = (): string => logicalVolume.filesystem?.label || NO_VALUE;
+ const filesystemLabel = (): string => logicalVolumeConfig.filesystem?.label || NO_VALUE;
const sizeOption = (): SizeOptionValue => {
- const size = logicalVolume.size;
- if (!size || size.default) return "auto";
+ const reuse = logicalVolumeConfig.name !== undefined;
+ const sizeConfig = logicalVolumeConfig.size;
+ if (reuse) return NO_VALUE;
+ if (!sizeConfig || sizeConfig.default) return "auto";
return "custom";
};
@@ -160,24 +189,49 @@ function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue {
return {
mountPoint: mountPoint(),
- name: logicalVolume.lvName,
+ name: logicalVolumeConfig.lvName,
+ target: target(),
filesystem: filesystem(),
filesystemLabel: filesystemLabel(),
sizeOption: sizeOption(),
- minSize: size(logicalVolume.size?.min),
- maxSize: size(logicalVolume.size?.max),
+ minSize: size(logicalVolumeConfig.size?.min),
+ maxSize: size(logicalVolumeConfig.size?.max),
};
}
+function useVolumeGroupConfig(): ConfigModel.VolumeGroup | null {
+ const { id: index } = useParams();
+
+ return useConfigModelVolumeGroup(Number(index)) ?? null;
+}
+
+function useVolumeGroup(): System.Device {
+ const volumeGroupConfig = useVolumeGroupConfig();
+ return useDevice(volumeGroupConfig.name);
+}
+
+function useLogicalVolume(target: string): System.Device | null {
+ const volumeGroup = useVolumeGroup();
+
+ if (target === NEW_LOGICAL_VOLUME) return null;
+
+ const logicalVolumes = volumeGroup.logicalVolumes || [];
+ return logicalVolumes.find((p: System.Device) => p.name === target);
+}
+
+function useLogicalVolumeFilesystem(target: string): string | null {
+ const logicalVolume = useLogicalVolume(target);
+ return logicalVolume?.filesystem?.type || null;
+}
+
function useDefaultFilesystem(mountPoint: string): string {
const volume = useVolumeTemplate(mountPoint);
return volume.fsType;
}
-function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null {
- const { id: vgName, logicalVolumeId: mountPath } = useParams();
- const volumeGroup = useVolumeGroup(vgName);
-
+function useInitialLogicalVolumeConfig(): ConfigModel.LogicalVolume | null {
+ const { logicalVolumeId: mountPath } = useParams();
+ const volumeGroup = useVolumeGroupConfig();
if (!volumeGroup || !mountPath) return null;
const logicalVolume = volumeGroup.logicalVolumes.find((l) => l.mountPath === mountPath);
@@ -185,23 +239,41 @@ function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null {
}
function useInitialFormValue(): FormValue | null {
- const logicalVolume = useInitialLogicalVolume();
- const value = useMemo(() => (logicalVolume ? toFormValue(logicalVolume) : null), [logicalVolume]);
+ const logicalVolumeConfig = useInitialLogicalVolumeConfig();
+
+ const value = React.useMemo(
+ () => (logicalVolumeConfig ? createFormValue(logicalVolumeConfig) : null),
+ [logicalVolumeConfig],
+ );
+
return value;
}
/** Unused predefined mount points. Includes the currently used mount point when editing. */
function useUnusedMountPoints(): string[] {
- const missingMountPaths = useMissingMountPaths();
- const initialLogicalVolume = useInitialLogicalVolume();
- return compact([initialLogicalVolume?.mountPath, ...missingMountPaths]);
+ const unusedMountPaths = useMissingMountPaths();
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
+ return compact([initialLogicalVolumeConfig?.mountPath, ...unusedMountPaths]);
+}
+
+/** Unused logical volumes. Includes the currently used logical volume when editing (if any). */
+function useUnusedLogicalVolumes(): System.Device[] {
+ const volumeGroup = useVolumeGroup();
+ const allLogicalVolumes = volumeGroup.logicalVolumes || [];
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
+ const volumeGroupConfig = useVolumeGroupConfig();
+ const configuredNames = configuredLogicalVolumes(volumeGroupConfig)
+ .filter((l) => l.name !== initialLogicalVolumeConfig?.name)
+ .map((l) => l.name);
+
+ return allLogicalVolumes.filter((l) => !configuredNames.includes(l.name));
}
function useUsableFilesystems(mountPoint: string): string[] {
const volume = useVolumeTemplate(mountPoint);
const defaultFilesystem = useDefaultFilesystem(mountPoint);
- const usableFilesystems = useMemo(() => {
+ const usableFilesystems = React.useMemo(() => {
const volumeFilesystems = (): string[] => {
return volume.outline.fsTypes;
};
@@ -215,7 +287,7 @@ function useUsableFilesystems(mountPoint: string): string[] {
function useMountPointError(value: FormValue): Error | undefined {
const config = useConfigModel();
const mountPoints = config ? configModel.usedMountPaths(config) : [];
- const initialLogicalVolume = useInitialLogicalVolume();
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
const mountPoint = value.mountPoint;
if (mountPoint === NO_VALUE) {
@@ -235,7 +307,7 @@ function useMountPointError(value: FormValue): Error | undefined {
}
// Exclude itself when editing
- const initialMountPoint = initialLogicalVolume?.mountPath;
+ const initialMountPoint = initialLogicalVolumeConfig?.mountPath;
if (mountPoint !== initialMountPoint && mountPoints.includes(mountPoint)) {
return {
id: "mountPoint",
@@ -246,7 +318,7 @@ function useMountPointError(value: FormValue): Error | undefined {
}
function checkLogicalVolumeName(value: FormValue): Error | undefined {
- if (value.name?.length) return;
+ if (value.target !== NEW_LOGICAL_VOLUME || value.name?.length) return;
return {
id: "logicalVolumeName",
@@ -255,7 +327,7 @@ function checkLogicalVolumeName(value: FormValue): Error | undefined {
};
}
-function checkSize(value: FormValue): Error | undefined {
+function checkSizeError(value: FormValue): Error | undefined {
if (value.sizeOption !== "custom") return;
const min = value.minSize;
@@ -268,7 +340,7 @@ function checkSize(value: FormValue): Error | undefined {
};
}
- const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$/;
+ const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])$/;
const validMin = regexp.test(min);
const validMax = max ? regexp.test(max) : true;
@@ -285,7 +357,7 @@ function checkSize(value: FormValue): Error | undefined {
if (validMin) {
return {
id: "customSize",
- message: _("The maximum must be a number optionally followed by a unit like GiB or GB"),
+ message: _("The maximum must be a number followed by a unit like GiB or GB"),
isVisible: true,
};
}
@@ -293,14 +365,14 @@ function checkSize(value: FormValue): Error | undefined {
if (validMax) {
return {
id: "customSize",
- message: _("The minimum must be a number optionally followed by a unit like GiB or GB"),
+ message: _("The minimum must be a number followed by a unit like GiB or GB"),
isVisible: true,
};
}
return {
id: "customSize",
- message: _("Size limits must be numbers optionally followed by a unit like GiB or GB"),
+ message: _("Size limits must be numbers followed by a unit like GiB or GB"),
isVisible: true,
};
}
@@ -308,7 +380,7 @@ function checkSize(value: FormValue): Error | undefined {
function useErrors(value: FormValue): ErrorsHandler {
const mountPointError = useMountPointError(value);
const nameError = checkLogicalVolumeName(value);
- const sizeError = checkSize(value);
+ const sizeError = checkSizeError(value);
const errors = compact([mountPointError, nameError, sizeError]);
const getError = (id: string): Error | undefined => errors.find((e) => e.id === id);
@@ -321,24 +393,36 @@ function useErrors(value: FormValue): ErrorsHandler {
return { errors, getError, getVisibleError };
}
-function useSolvedModel(value: FormValue): ConfigModel.Config | null {
- const { id: vgName, logicalVolumeId: mountPath } = useParams();
+function useSolvedConfig(value: FormValue): ConfigModel.Config | null {
+ const { id: index } = useParams();
+ const volumeGroupConfig = useVolumeGroupConfig();
const config = useConfigModel();
- const { getError } = useErrors(value);
- const mountPointError = getError("mountPoint");
- const data = toData(value);
+ const { errors } = useErrors(value);
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
+ const logicalVolumeConfig = createLogicalVolumeConfig(value);
+ logicalVolumeConfig.size = undefined;
// Avoid recalculating the solved model because changes in label.
- if (data.filesystem) data.filesystem.label = undefined;
+ if (logicalVolumeConfig.filesystem) logicalVolumeConfig.filesystem.label = undefined;
// Avoid recalculating the solved model because changes in name.
- data.lvName = undefined;
+ logicalVolumeConfig.lvName = undefined;
let sparseModel: ConfigModel.Config | undefined;
- if (data.filesystem && !mountPointError) {
- if (mountPath) {
- sparseModel = configModel.logicalVolume.edit(config, vgName, mountPath, data);
+ if (
+ volumeGroupConfig &&
+ !errors.length &&
+ value.target === NEW_LOGICAL_VOLUME &&
+ value.filesystem !== NO_VALUE
+ ) {
+ if (initialLogicalVolumeConfig) {
+ sparseModel = configModel.logicalVolume.edit(
+ config,
+ Number(index),
+ initialLogicalVolumeConfig.mountPath,
+ logicalVolumeConfig,
+ );
} else {
- sparseModel = configModel.logicalVolume.add(config, vgName, data);
+ sparseModel = configModel.logicalVolume.add(config, Number(index), logicalVolumeConfig);
}
}
@@ -346,11 +430,17 @@ function useSolvedModel(value: FormValue): ConfigModel.Config | null {
return solvedModel;
}
-function useSolvedLogicalVolume(value: FormValue): ConfigModel.LogicalVolume | undefined {
- const { id: vgName } = useParams();
- const config = useSolvedModel(value);
- const volumeGroup = config?.volumeGroups?.find((v) => v.vgName === vgName);
- return volumeGroup?.logicalVolumes?.find((l) => l.mountPath === value.mountPoint);
+function useSolvedLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume | undefined {
+ const volumeGroupConfig = useVolumeGroupConfig();
+ const solvedConfig = useSolvedConfig(value);
+ if (!solvedConfig) return;
+
+ const solvedVolumeGroupConfig = configModel.volumeGroup.findByName(
+ solvedConfig,
+ volumeGroupConfig.vgName,
+ );
+
+ return configModel.device.findVolumeByMountPath(solvedVolumeGroupConfig, value.mountPoint);
}
function useSolvedSizes(value: FormValue): SizeRange {
@@ -362,45 +452,123 @@ function useSolvedSizes(value: FormValue): SizeRange {
maxSize: NO_VALUE,
};
- const logicalVolume = useSolvedLogicalVolume(valueWithoutSizes);
+ const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(valueWithoutSizes);
- const solvedSizes = useMemo(() => {
- const min = logicalVolume?.size?.min;
- const max = logicalVolume?.size?.max;
+ const solvedSizes = React.useMemo(() => {
+ const min = solvedLogicalVolumeConfig?.size?.min;
+ const max = solvedLogicalVolumeConfig?.size?.max;
return {
min: min ? deviceSize(min) : NO_VALUE,
max: max ? deviceSize(max) : NO_VALUE,
};
- }, [logicalVolume]);
+ }, [solvedLogicalVolumeConfig]);
return solvedSizes;
}
function useAutoRefreshFilesystem(handler, value: FormValue) {
- const { mountPoint } = value;
+ const { mountPoint, target } = value;
const defaultFilesystem = useDefaultFilesystem(mountPoint);
+ const usableFilesystems = useUsableFilesystems(mountPoint);
+ const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target);
- useEffect(() => {
+ React.useEffect(() => {
// Reset filesystem if there is no mount point yet.
if (mountPoint === NO_VALUE) handler(NO_VALUE);
// Select default filesystem for the mount point.
- if (mountPoint !== NO_VALUE) handler(defaultFilesystem);
- }, [handler, mountPoint, defaultFilesystem]);
+ if (mountPoint !== NO_VALUE && target === NEW_LOGICAL_VOLUME) handler(defaultFilesystem);
+ // Select default filesystem for the mount point if the logical volume has no filesystem.
+ if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && !logicalVolumeFilesystem)
+ handler(defaultFilesystem);
+ // Reuse the filesystem from the logical volume if possible.
+ if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && logicalVolumeFilesystem) {
+ const reuse = usableFilesystems.includes(logicalVolumeFilesystem);
+ handler(reuse ? REUSE_FILESYSTEM : defaultFilesystem);
+ }
+ }, [handler, mountPoint, target, defaultFilesystem, usableFilesystems, logicalVolumeFilesystem]);
}
function useAutoRefreshSize(handler, value: FormValue) {
+ const target = value.target;
const solvedSizes = useSolvedSizes(value);
- useEffect(() => {
- handler("auto", solvedSizes.min, solvedSizes.max);
- }, [handler, solvedSizes]);
+ React.useEffect(() => {
+ const sizeOption = target === NEW_LOGICAL_VOLUME ? "auto" : "";
+ handler(sizeOption, solvedSizes.min, solvedSizes.max);
+ }, [handler, target, solvedSizes]);
}
function mountPointSelectOptions(mountPoints: string[]): SelectOptionProps[] {
return mountPoints.map((p) => ({ value: p, children: p }));
}
+type TargetOptionLabelProps = {
+ value: string;
+};
+
+function TargetOptionLabel({ value }: TargetOptionLabelProps): React.ReactNode {
+ const device = useVolumeGroup();
+ const logicalVolume = useLogicalVolume(value);
+
+ if (value === NEW_LOGICAL_VOLUME) {
+ // TRANSLATORS: %s is a disk name with its size (eg. "sda, 10 GiB"
+ return sprintf(_("As a new logical volume on %s"), deviceLabel(device, true));
+ } else {
+ return sprintf(_("Using logical volume %s"), deviceLabel(logicalVolume, true));
+ }
+}
+
+type LogicalVolumeDescriptionProps = {
+ logicalVolume: System.Device;
+};
+
+function LogicalVolumeDescription({
+ logicalVolume,
+}: LogicalVolumeDescriptionProps): React.ReactNode {
+ const label = logicalVolume.filesystem?.label;
+
+ return (
+
+ {logicalVolume.description}
+ {label && (
+
+
+
+ )}
+
+ );
+}
+
+function TargetOptions(): React.ReactNode {
+ const logicalVolumes = useUnusedLogicalVolumes();
+
+ return (
+
+
+
+
+
+
+ {logicalVolumes.map((logicalVolume, index) => (
+ }
+ >
+ {deviceLabel(logicalVolume)}
+
+ ))}
+ {logicalVolumes.length === 0 && (
+ {_("There are not usable logical volumes")}
+ )}
+
+
+ );
+}
+
type LogicalVolumeNameProps = {
id?: string;
value: FormValue;
@@ -444,37 +612,58 @@ function LogicalVolumeName({
type FilesystemOptionLabelProps = {
value: string;
+ target: string;
volume: System.Volume;
};
-function FilesystemOptionLabel({ value }: FilesystemOptionLabelProps): React.ReactNode {
+function FilesystemOptionLabel({ value, target }: FilesystemOptionLabelProps): React.ReactNode {
+ const logicalVolume = useLogicalVolume(target);
+ const filesystem = logicalVolume?.filesystem?.type;
+
if (value === NO_VALUE) return _("Waiting for a mount point");
+ // TRANSLATORS: %s is a filesystem type, like Btrfs
+ if (value === REUSE_FILESYSTEM && filesystem)
+ return sprintf(_("Current %s"), filesystemLabel(filesystem));
+
return filesystemLabel(value);
}
type FilesystemOptionsProps = {
mountPoint: string;
+ target: string;
};
-function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode {
+function FilesystemOptions({ mountPoint, target }: FilesystemOptionsProps): React.ReactNode {
+ const volume = useVolumeTemplate(mountPoint);
const defaultFilesystem = useDefaultFilesystem(mountPoint);
const usableFilesystems = useUsableFilesystems(mountPoint);
- const volume = useVolumeTemplate(mountPoint);
-
- const defaultOptText =
- mountPoint !== NO_VALUE && volume.mountPath
- ? sprintf(_("Default file system for %s"), mountPoint)
- : _("Default file system for generic logical volumes");
+ const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target);
+ const canReuse = logicalVolumeFilesystem && usableFilesystems.includes(logicalVolumeFilesystem);
- const formatText = _("Format logical volume as");
+ const defaultOptText = volume.mountPath
+ ? sprintf(_("Default file system for %s"), mountPoint)
+ : _("Default file system for generic logical volume");
+ const formatText = logicalVolumeFilesystem
+ ? _("Destroy current data and format logical volume as")
+ : _("Format logical volume as");
return (
{mountPoint === NO_VALUE && (
-
+
)}
+ {mountPoint !== NO_VALUE && canReuse && (
+
+
+
+ )}
+ {mountPoint !== NO_VALUE && canReuse && usableFilesystems.length && }
{mountPoint !== NO_VALUE && (
{usableFilesystems.map((fsType, index) => (
@@ -483,7 +672,7 @@ function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactN
value={fsType}
description={fsType === defaultFilesystem && defaultOptText}
>
-
+
))}
@@ -496,6 +685,7 @@ type FilesystemSelectProps = {
id?: string;
value: string;
mountPoint: string;
+ target: string;
onChange: SelectProps["onChange"];
};
@@ -503,6 +693,7 @@ function FilesystemSelect({
id,
value,
mountPoint,
+ target,
onChange,
}: FilesystemSelectProps): React.ReactNode {
const volume = useVolumeTemplate(mountPoint);
@@ -512,11 +703,11 @@ function FilesystemSelect({
}
+ label={}
onChange={onChange}
isDisabled={mountPoint === NO_VALUE}
>
-
+
);
}
@@ -546,46 +737,63 @@ type AutoSizeInfoProps = {
function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode {
const volume = useVolumeTemplate(value.mountPoint);
- const logicalVolume = useSolvedLogicalVolume(value);
- const size = logicalVolume?.size;
+ const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(value);
+ const size = solvedLogicalVolumeConfig?.size;
if (!size) return;
return (
-
+
);
}
-export default function LogicalVolumePage() {
+const LogicalVolumeForm = () => {
+ const { id: index } = useParams();
const navigate = useNavigate();
- const headingId = useId();
- const { id: vgName } = useParams();
- const addLogicalVolume = useAddLogicalVolume();
- const editLogicalVolume = useEditLogicalVolume();
+ const location = useLocation();
const [mountPoint, setMountPoint] = useState(NO_VALUE);
const [name, setName] = useState(NO_VALUE);
+ const [target, setTarget] = useState(NEW_LOGICAL_VOLUME);
const [filesystem, setFilesystem] = useState(NO_VALUE);
const [filesystemLabel, setFilesystemLabel] = useState(NO_VALUE);
const [sizeOption, setSizeOption] = useState(NO_VALUE);
const [minSize, setMinSize] = useState(NO_VALUE);
const [maxSize, setMaxSize] = useState(NO_VALUE);
- // Filesystem and size selectors should not be auto refreshed before the user interacts with the
- // mount point selector.
+ // Filesystem and size selectors should not be auto refreshed before the user interacts with other
+ // selectors like the mount point or the target selectors.
const [autoRefreshFilesystem, setAutoRefreshFilesystem] = useState(false);
const [autoRefreshSize, setAutoRefreshSize] = useState(false);
const initialValue = useInitialFormValue();
- const value = { mountPoint, name, filesystem, filesystemLabel, sizeOption, minSize, maxSize };
+ const value = {
+ mountPoint,
+ name,
+ target,
+ filesystem,
+ filesystemLabel,
+ sizeOption,
+ minSize,
+ maxSize,
+ };
const { errors, getVisibleError } = useErrors(value);
+
+ const volumeGroupConfig = useVolumeGroupConfig();
+ const volumeGroup = useVolumeGroup();
+ const logicalVolume = useLogicalVolume(target);
+
const unusedMountPoints = useUnusedMountPoints();
+ const addLogicalVolume = useAddLogicalVolume();
+ const editLogicalVolume = useEditLogicalVolume();
+
// Initializes the form values if there is an initial value (i.e., when editing a logical volume).
React.useEffect(() => {
if (initialValue) {
setMountPoint(initialValue.mountPoint);
setName(initialValue.name);
+ setTarget(initialValue.target);
setFilesystem(initialValue.filesystem);
setFilesystemLabel(initialValue.filesystemLabel);
setSizeOption(initialValue.sizeOption);
@@ -595,6 +803,7 @@ export default function LogicalVolumePage() {
}, [
initialValue,
setMountPoint,
+ setTarget,
setFilesystem,
setFilesystemLabel,
setSizeOption,
@@ -602,14 +811,14 @@ export default function LogicalVolumePage() {
setMaxSize,
]);
- const refreshFilesystemHandler = useCallback(
+ const refreshFilesystemHandler = React.useCallback(
(filesystem: string) => autoRefreshFilesystem && setFilesystem(filesystem),
[autoRefreshFilesystem, setFilesystem],
);
useAutoRefreshFilesystem(refreshFilesystemHandler, value);
- const refreshSizeHandler = useCallback(
+ const refreshSizeHandler = React.useCallback(
(sizeOption: SizeOptionValue, minSize: string, maxSize: string) => {
if (autoRefreshSize) {
setSizeOption(sizeOption);
@@ -627,10 +836,15 @@ export default function LogicalVolumePage() {
setAutoRefreshFilesystem(true);
setAutoRefreshSize(true);
setMountPoint(value);
- setName(configModel.logicalVolume.generateName(value));
}
};
+ const changeTarget = (value: string) => {
+ setAutoRefreshFilesystem(true);
+ setAutoRefreshSize(true);
+ setTarget(value);
+ };
+
const changeFilesystem = (value: string) => {
setAutoRefreshFilesystem(false);
setAutoRefreshSize(false);
@@ -649,18 +863,19 @@ export default function LogicalVolumePage() {
};
const onSubmit = () => {
- const data = toData(value);
+ const logicalVolumeConfig = createLogicalVolumeConfig(value);
- if (initialValue) editLogicalVolume(vgName, initialValue.mountPoint, data);
- else addLogicalVolume(vgName, data);
+ if (initialValue)
+ editLogicalVolume(Number(index), initialValue.mountPoint, logicalVolumeConfig);
+ else addLogicalVolume(Number(index), logicalVolumeConfig);
- navigate(PATHS.root);
+ navigate({ pathname: PATHS.root, search: location.search });
};
const isFormValid = errors.length === 0;
const mountPointError = getVisibleError("mountPoint");
const usedMountPt = mountPointError ? NO_VALUE : mountPoint;
- const showLabel = filesystem !== NO_VALUE && usedMountPt !== NO_VALUE;
+ const showLabel = filesystem !== NO_VALUE && filesystem !== REUSE_FILESYSTEM;
const sizeMode: SizeMode = sizeOption === "" ? "auto" : sizeOption;
const sizeRange: SizeRange = { min: minSize, max: maxSize };
@@ -668,45 +883,58 @@ export default function LogicalVolumePage() {
-
);
+};
+
+export default function LogicalVolumePage() {
+ const volumeGroupConfig = useVolumeGroupConfig();
+
+ if (!volumeGroupConfig)
+ return ;
+
+ return ;
}
diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx
index 9ad6c3d6ee..a16c0f7b12 100644
--- a/web/src/components/storage/LvmPage.tsx
+++ b/web/src/components/storage/LvmPage.tsx
@@ -41,7 +41,7 @@ import { contentDescription, filesystemLabels, typeDescription } from "./utils/d
import { STORAGE } from "~/routes/paths";
import { sprintf } from "sprintf-js";
import { _ } from "~/i18n";
-import { deviceSystems, isDrive } from "~/model/storage/device";
+import { deviceSystems, isDrive, isMd } from "~/model/storage/device";
import configModel from "~/model/storage/config-model";
import {
useConfigModel,
@@ -50,23 +50,21 @@ import {
useEditVolumeGroup,
} from "~/hooks/model/storage/config-model";
import type { ConfigModel, Data } from "~/model/storage/config-model";
-import type { Storage } from "~/model/system";
+import type { Storage as System } from "~/model/system";
/**
* Hook that returns the devices that can be selected as target to automatically create LVM PVs.
*
* Filters out devices that are going to be directly formatted.
*/
-function useLvmTargetDevices(): Storage.Device[] {
+function useLvmTargetDevices(): System.Device[] {
const availableDevices = useAvailableDevices();
const config = useConfigModel();
const targetDevices = useMemo(() => {
- return availableDevices.filter((candidate) => {
- const collection = isDrive(candidate) ? config.drives : config.mdRaids;
- const device = collection.find((d) => d.name === candidate.name);
- return !device || !device.filesystem;
- });
+ return availableDevices
+ .filter((d) => isDrive(d) || isMd(d))
+ .filter((d) => !configModel.partitionable.findByName(config, d.name)?.filesystem);
}, [availableDevices, config]);
return targetDevices;
@@ -84,7 +82,7 @@ function vgNameError(
return sprintf(_("Volume group '%s' already exists. Enter a different name."), vgName);
}
-function targetDevicesError(targetDevices: Storage.Device[]): string | undefined {
+function targetDevicesError(targetDevices: System.Device[]): string | undefined {
if (!targetDevices.length) return _("Select at least one disk.");
}
@@ -95,15 +93,15 @@ function targetDevicesError(targetDevices: Storage.Device[]): string | undefined
* model.VolumeGroup (build data.VolumeGroup from model.VolumeGroup).
*/
export default function LvmPage() {
- const { id } = useParams();
+ const { id: index } = useParams();
const navigate = useNavigate();
const config = useConfigModel();
- const volumeGroup = useVolumeGroup(id);
+ const volumeGroup = useVolumeGroup(Number(index));
const addVolumeGroup = useAddVolumeGroup();
const editVolumeGroup = useEditVolumeGroup();
const allDevices = useLvmTargetDevices();
const [name, setName] = useState("");
- const [selectedDevices, setSelectedDevices] = useState([]);
+ const [selectedDevices, setSelectedDevices] = useState([]);
const [moveMountPoints, setMoveMountPoints] = useState(true);
const [errors, setErrors] = useState([]);
diff --git a/web/src/components/storage/MdRaidsTable.test.tsx b/web/src/components/storage/MdRaidsTable.test.tsx
new file mode 100644
index 0000000000..d18eacfa44
--- /dev/null
+++ b/web/src/components/storage/MdRaidsTable.test.tsx
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen, within } from "@testing-library/react";
+import { getColumnValues, plainRender } from "~/test-utils";
+import type { Storage } from "~/model/system";
+import MdRaidsTable from "./MdRaidsTable";
+
+const sda: Storage.Device = {
+ sid: 1,
+ class: "drive",
+ name: "/dev/sda",
+ description: "SDA",
+ drive: {
+ model: "",
+ vendor: "",
+ bus: "SATA",
+ busId: "",
+ transport: "",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 10240,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const sdb: Storage.Device = { ...sda, sid: 2, name: "/dev/sdb", description: "SDB" };
+const sdc: Storage.Device = { ...sda, sid: 3, name: "/dev/sdc", description: "SDC" };
+const sdd: Storage.Device = { ...sda, sid: 4, name: "/dev/sdd", description: "SDD" };
+const sde: Storage.Device = { ...sda, sid: 5, name: "/dev/sde", description: "SDE" };
+
+jest.mock("~/hooks/model/system/storage", () => ({
+ ...jest.requireActual("~/hooks/model/system/storage"),
+ useFlattenDevices: () => [sda, sdb, sdc, sdd, sde],
+}));
+
+const md0: Storage.Device = {
+ sid: 70,
+ class: "mdRaid",
+ name: "/dev/md0",
+ description: "MD RAID 0",
+ md: { level: "raid1", devices: [1, 2] },
+ block: {
+ start: 0,
+ size: 10240,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const md1: Storage.Device = {
+ sid: 71,
+ class: "mdRaid",
+ name: "/dev/md1",
+ description: "MD RAID 1",
+ md: { level: "raid5", devices: [3, 4, 5] },
+ block: {
+ start: 0,
+ size: 20480,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const onSelectionChangeMock = jest.fn();
+
+describe("MdRaidsTable", () => {
+ it("renders Device, Size, Level, Members, and Current content columns", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ within(table).getByRole("columnheader", { name: "Device" });
+ within(table).getByRole("columnheader", { name: "Size" });
+ within(table).getByRole("columnheader", { name: "Level" });
+ within(table).getByRole("columnheader", { name: "Members" });
+ within(table).getByRole("columnheader", { name: "Current content" });
+ });
+
+ it("renders a row per RAID device", () => {
+ plainRender();
+ screen.getByRole("row", { name: /md0/ });
+ screen.getByRole("row", { name: /md1/ });
+ });
+
+ it("renders the RAID level in uppercase", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ expect(getColumnValues(table, "Level")).toEqual(["RAID1", "RAID5"]);
+ });
+
+ it("renders the current content of each member device", () => {
+ plainRender();
+ const md0Row = screen.getByRole("row", { name: /md0/ });
+ within(md0Row).getByText("MD RAID 0");
+ });
+
+ it("renders the member names", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ expect(getColumnValues(table, "Members")).toEqual(["sda, sdb", "sdc, sdd, sde"]);
+ });
+
+ it("allows sorting by device name", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Device" });
+
+ expect(getColumnValues(table, "Device")).toEqual(["md0", "md1"]);
+
+ await user.click(sortButton);
+
+ expect(getColumnValues(table, "Device")).toEqual(["md1", "md0"]);
+ });
+
+ it("allows sorting by size", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Size" });
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["10 KiB", "20 KiB"]);
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["20 KiB", "10 KiB"]);
+ });
+
+ it("calls onSelectionChange when a device is selected", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const md1Row = screen.getByRole("row", { name: /md1/ });
+ await user.click(within(md1Row).getByRole("radio"));
+ expect(onSelectionChangeMock).toHaveBeenCalledWith([md1]);
+ });
+});
diff --git a/web/src/components/storage/MdRaidsTable.tsx b/web/src/components/storage/MdRaidsTable.tsx
new file mode 100644
index 0000000000..2a7d7f7292
--- /dev/null
+++ b/web/src/components/storage/MdRaidsTable.tsx
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React, { useState } from "react";
+import SelectableDataTable from "~/components/core/SelectableDataTable";
+import DeviceContent from "~/components/storage/DeviceContent";
+import { useFlattenDevices } from "~/hooks/model/system/storage";
+import { deviceBaseName, deviceSize } from "~/components/storage/utils";
+import { sortCollection } from "~/utils";
+import { _ } from "~/i18n";
+
+import type { Storage } from "~/model/system";
+import type { SelectableDataTableProps, SortedBy } from "~/components/core/SelectableDataTable";
+
+/** Props for {@link MdRaidsTable}. */
+type MdRaidsTableProps = {
+ /** Available software RAID devices. */
+ devices: Storage.Device[];
+ /** Currently selected devices. */
+ selectedDevices?: Storage.Device[];
+ /** Called when the selection changes. */
+ onSelectionChange: SelectableDataTableProps["onSelectionChange"];
+ /** Selection mode. Defaults to `"single"`. */
+ selectionMode?: SelectableDataTableProps["selectionMode"];
+};
+
+const level = (device: Storage.Device): string => device.md.level.toUpperCase();
+
+const memberNames = (device: Storage.Device, systemDevices: Storage.Device[]): string =>
+ device.md.devices
+ .map((sid) => {
+ const pv = systemDevices.find((d) => d.sid === sid);
+ return pv ? deviceBaseName(pv) : sid;
+ })
+ .join(", ");
+
+/**
+ * Table for selecting among available software RAID devices.
+ */
+export default function MdRaidsTable({
+ devices,
+ selectedDevices,
+ onSelectionChange,
+ selectionMode = "single",
+}: MdRaidsTableProps) {
+ const systemDevices = useFlattenDevices();
+ const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
+
+ const columns = [
+ {
+ name: _("Device"),
+ value: (device: Storage.Device) => deviceBaseName(device),
+ sortingKey: "name",
+ pfTdProps: { style: { width: "15ch" } },
+ },
+ {
+ name: _("Size"),
+ value: (device: Storage.Device) => deviceSize(device.block.size),
+ sortingKey: (d: Storage.Device) => d.block.size,
+ pfTdProps: { style: { width: "10ch" } },
+ },
+ { name: _("Level"), value: level, sortingKey: level },
+ {
+ name: _("Members"),
+ value: (device: Storage.Device) => memberNames(device, systemDevices),
+ },
+ { name: _("Current content"), value: (d: Storage.Device) => },
+ ];
+
+ const sortingKey = columns[sortedBy.index].sortingKey;
+ const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/storage/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/ProposalFailedInfo.tsx b/web/src/components/storage/ProposalFailedInfo.tsx
index 057bc43909..cb1218a6be 100644
--- a/web/src/components/storage/ProposalFailedInfo.tsx
+++ b/web/src/components/storage/ProposalFailedInfo.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -31,17 +31,9 @@ const Description = () => {
const model = useConfigModel();
const partitions = model.drives.flatMap((d) => d.partitions || []);
const logicalVolumes = model.volumeGroups.flatMap((vg) => vg.logicalVolumes || []);
-
- const newPartitions = partitions.filter((p) => !p.name);
-
- // FIXME: Currently, it's not possible to reuse a logical volume, so all
- // volumes are treated as new. This code cannot be made future-proof due to an
- // internal decision not to expose unused properties, even though "#name" is
- // used to infer whether a "device" is new or not.
- // const newLogicalVolumes = logicalVolumes.filter((lv) => !lv.name);
-
const isBootConfigured = !!model.boot?.configure;
- const mountPaths = [newPartitions, logicalVolumes]
+ const mountPaths = [...partitions, ...logicalVolumes]
+ .filter((p) => !p.name)
.flat()
.map((d) => partitionUtils.pathWithSize(d));
diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx
index 97660e4910..51675fecfb 100644
--- a/web/src/components/storage/SearchedDeviceMenu.tsx
+++ b/web/src/components/storage/SearchedDeviceMenu.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -21,26 +21,46 @@
*/
import React, { useState } from "react";
-import MenuButton, { CustomToggleProps, MenuButtonItem } from "~/components/core/MenuButton";
-import NewVgMenuOption from "./NewVgMenuOption";
+import { sprintf } from "sprintf-js";
+import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton";
+import NewVgMenuOption from "~/components/storage/NewVgMenuOption";
+import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal";
+import configModel from "~/model/storage/config-model";
+import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device";
import { useAvailableDevices } from "~/hooks/model/system/storage";
-import {
- useConfigModel,
- useAddDriveFromMdRaid,
- useAddMdRaidFromDrive,
-} from "~/hooks/model/storage/config-model";
+import { useConfigModel, useConvertDevice } from "~/hooks/model/storage/config-model";
import { deviceBaseName, formattedPath } from "~/components/storage/utils";
-import configModel from "~/model/storage/config-model";
-import { sprintf } from "sprintf-js";
-import { _, formatList } from "~/i18n";
-import DeviceSelectorModal from "./DeviceSelectorModal";
-import { MenuItemProps } from "@patternfly/react-core";
-import { isDrive } from "~/model/storage/device";
+import { _, n_, formatList } from "~/i18n";
+
+import type { MenuItemProps } from "@patternfly/react-core";
+import type { CustomToggleProps } from "~/components/core/MenuButton";
import type { Storage } from "~/model/system";
import type { ConfigModel } from "~/model/storage/config-model";
const baseName = (device: Storage.Device): string => deviceBaseName(device, true);
+const targetDevices = (
+ deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid,
+ config: ConfigModel.Config,
+ availableDevices: Storage.Device[],
+): Storage.Device[] => {
+ return availableDevices.filter((availableDevice) => {
+ if (deviceConfig.name === availableDevice.name) return true;
+
+ const availableDeviceConfig = configModel.findDeviceByName(config, availableDevice.name);
+
+ if (deviceConfig.filesystem) {
+ if (isVolumeGroup(availableDevice)) return false;
+ if (!availableDeviceConfig) return true;
+ return !configModel.partitionable.isUsed(config, availableDeviceConfig.name);
+ } else {
+ if (!availableDeviceConfig) return true;
+ if ("filesystem" in availableDeviceConfig) return !availableDeviceConfig.filesystem;
+ return true;
+ }
+ });
+};
+
const useOnlyOneOption = (
config: ConfigModel.Config,
device: ConfigModel.Drive | ConfigModel.MdRaid,
@@ -71,18 +91,18 @@ const ChangeDeviceTitle = ({ modelDevice }: ChangeDeviceTitleProps) => {
if (modelDevice.filesystem) {
// TRANSLATORS: %s is a formatted mount point like '"/home"'
- return sprintf(_("Change the disk to format as %s"), formattedPath(modelDevice.mountPath));
+ return sprintf(_("Change the device to format as %s"), formattedPath(modelDevice.mountPath));
}
const mountPaths = configModel.partitionable.usedMountPaths(modelDevice);
const hasMountPaths = mountPaths.length > 0;
if (!hasMountPaths) {
- return _("Change the disk to configure");
+ return _("Change the device to configure");
}
if (mountPaths.includes("/")) {
- return _("Change the disk to install the system");
+ return _("Change the device to install the system");
}
const newMountPaths = modelDevice.partitions
@@ -92,7 +112,7 @@ const ChangeDeviceTitle = ({ modelDevice }: ChangeDeviceTitleProps) => {
return sprintf(
// TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a
// single mount point in the singular case).
- _("Change the disk to create %s"),
+ _("Change the device to create %s"),
formatList(newMountPaths),
);
};
@@ -190,6 +210,37 @@ const ChangeDeviceDescription = ({ modelDevice, device }: ChangeDeviceDescriptio
}
};
+/**
+ * Returns a string describing what will be created as logical volumes when
+ * reusing a volume group, or `undefined` when no new partitions are being
+ * added.
+ *
+ * A plain function (not a component) because a React element's emptiness cannot
+ * be checked without rendering it, making it difficult for callers to decide
+ * whether to render anything at all (e.g. {@link Annotation} guards against no
+ * children to avoid displaying just an icon with no text)
+ */
+const reuseVgSideEffect = (
+ deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid,
+): string | undefined => {
+ const paths = deviceConfig.partitions
+ .filter((p) => !p.name)
+ .map((p) => formattedPath(p.mountPath));
+
+ if (paths.length) {
+ return sprintf(
+ n_(
+ // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a
+ // single mount point in the singular case).
+ "%s will be created as a logical volume",
+ "%s will be created as logical volumes",
+ paths.length,
+ ),
+ formatList(paths),
+ );
+ }
+};
+
type ChangeDeviceMenuItemProps = {
modelDevice: ConfigModel.Drive | ConfigModel.MdRaid;
device: Storage.Device;
@@ -218,40 +269,19 @@ const ChangeDeviceMenuItem = ({
);
};
-type RemoveEntryOptionProps = {
+type RemoveDeviceMenuItemProps = {
device: ConfigModel.Drive | ConfigModel.MdRaid;
onClick: (device: ConfigModel.Drive | ConfigModel.MdRaid) => void;
};
-const RemoveEntryOption = ({ device, onClick }: RemoveEntryOptionProps): React.ReactNode => {
+const RemoveDeviceMenuItem = ({ device, onClick }: RemoveDeviceMenuItemProps): React.ReactNode => {
const config = useConfigModel();
- /*
- * Pretty artificial logic used to decide whether the UI should display buttons to remove
- * some drives.
- */
- const hasAdditionalDrives = (config: ConfigModel.Config): boolean => {
- const entries = config.drives.concat(config.mdRaids);
-
- if (entries.length <= 1) return false;
- if (entries.length > 2) return true;
-
- // If there are only two drives, the following logic avoids the corner case in which first
- // deleting one of them and then changing the boot settings can lead to zero disks. But it is far
- // from being fully reasonable or understandable for the user.
- const onlyToBoot = entries.find(
- (e) =>
- configModel.boot.hasExplicitDevice(config, e.name) &&
- !configModel.partitionable.isUsed(config, e.name),
- );
- return !onlyToBoot;
- };
-
// When no additional drives has been added, the "Do not use" button can be confusing so it is
// omitted for all drives.
- if (!hasAdditionalDrives(config)) return;
+ if (!configModel.hasAdditionalDevices(config)) return;
- let description;
+ let description: string;
const isExplicitBoot = configModel.boot.hasExplicitDevice(config, device.name);
const hasPv = configModel.isTargetDevice(config, device.name);
const isDisabled = isExplicitBoot || hasPv;
@@ -287,24 +317,6 @@ const RemoveEntryOption = ({ device, onClick }: RemoveEntryOptionProps): React.R
);
};
-const targetDevices = (
- modelDevice: ConfigModel.Drive | ConfigModel.MdRaid,
- config: ConfigModel.Config,
- availableDevices: Storage.Device[],
-): Storage.Device[] => {
- return availableDevices.filter((availableDev) => {
- if (modelDevice.name === availableDev.name) return true;
-
- const collection = isDrive(availableDev) ? config.drives : config.mdRaids;
- const device = collection.find((d) => d.name === availableDev.name);
- if (!device) return true;
-
- if (modelDevice.filesystem) return !configModel.partitionable.isUsed(config, device.name);
-
- return !device.filesystem;
- });
-};
-
export type SearchedDeviceMenuProps = {
selected: Storage.Device;
modelDevice: ConfigModel.Drive | ConfigModel.MdRaid;
@@ -323,18 +335,22 @@ export default function SearchedDeviceMenu({
toggle,
deleteFn,
}: SearchedDeviceMenuProps): React.ReactNode {
+ const config = useConfigModel();
+ const convertDevice = useConvertDevice();
const [isSelectorOpen, setIsSelectorOpen] = useState(false);
- const switchToDrive = useAddDriveFromMdRaid();
- const switchToMdRaid = useAddMdRaidFromDrive();
- const changeTargetFn = (device: Storage.Device) => {
- const hook = isDrive(device) ? switchToDrive : switchToMdRaid;
- hook(modelDevice.name, { name: device.name });
+ const availableTargets = targetDevices(modelDevice, config, useAvailableDevices());
+ const disks = availableTargets.filter(isDrive);
+ const mdRaids = availableTargets.filter(isMd);
+ const volumeGroups = availableTargets.filter(isVolumeGroup);
+ const vgSelectionSideEffect = reuseVgSideEffect(modelDevice);
+
+ const openSelector = () => {
+ setIsSelectorOpen(true);
};
- const devices = targetDevices(modelDevice, useConfigModel(), useAvailableDevices());
- const onDeviceChange = ([drive]: Storage.Device[]) => {
+ const onDeviceChange = ([device]: Storage.Device[]) => {
setIsSelectorOpen(false);
- changeTargetFn(drive);
+ convertDevice(selected.name, device.name);
};
return (
@@ -342,7 +358,6 @@ export default function SearchedDeviceMenu({
setIsSelectorOpen(true)}
+ onClick={() => openSelector()}
/>,
,
- ,
+ ,
]}
/>
{isSelectorOpen && (
}
- description={}
+ intro={}
selected={selected}
- devices={devices}
+ disks={disks}
+ mdRaids={mdRaids}
+ volumeGroups={volumeGroups}
+ sideEffects={{ volumeGroups: vgSelectionSideEffect }}
+ emptyStateTitles={{ volumeGroups: _("Volume groups cannot be formatted") }}
onConfirm={onDeviceChange}
onCancel={() => setIsSelectorOpen(false)}
/>
diff --git a/web/src/components/storage/SearchedVolumeGroupMenu.tsx b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
new file mode 100644
index 0000000000..93ddc5f030
--- /dev/null
+++ b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
@@ -0,0 +1,284 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React, { useState } from "react";
+import { sprintf } from "sprintf-js";
+import { isEmpty, isNullish } from "radashi";
+import { MenuItemProps } from "@patternfly/react-core";
+import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton";
+import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal";
+import configModel from "~/model/storage/config-model";
+import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device";
+import {
+ useConfigModel,
+ useConvertDevice,
+ useDeleteVolumeGroup,
+} from "~/hooks/model/storage/config-model";
+import { useAvailableDevices } from "~/hooks/model/system/storage";
+import { formattedPath } from "~/components/storage/utils";
+import { _, n_, formatList } from "~/i18n";
+
+import type { CustomToggleProps } from "~/components/core/MenuButton";
+import type { Storage } from "~/model/system";
+import type { ConfigModel } from "~/model/storage/config-model";
+import type { DeviceSelectorModalProps } from "~/components/storage/DeviceSelectorModal";
+
+/**
+ * Filters devices that can be selected as target for the volume group.
+ */
+const targetDevices = (
+ config: ConfigModel.Config,
+ availableDevices: Storage.Device[],
+): Storage.Device[] => {
+ return availableDevices.filter((availableDevice) => {
+ const availableDeviceConfig = configModel.findDeviceByName(config, availableDevice.name);
+
+ // Allow to select the available device if it is not configured yet.
+ if (isNullish(availableDeviceConfig)) return true;
+
+ // The available device cannot be selected if it is configured to be formatted.
+ if ("filesystem" in availableDeviceConfig) return !availableDeviceConfig.filesystem;
+
+ return true;
+ });
+};
+
+const isUnchangeable = (deviceConfig: ConfigModel.VolumeGroup): boolean => {
+ return configModel.volumeGroup.isReusingLogicalVolumes(deviceConfig);
+};
+
+type ChangeVolumeGroupTitleProps = {
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+const ChangeVolumeGroupTitle = ({ deviceConfig }: ChangeVolumeGroupTitleProps) => {
+ if (isUnchangeable(deviceConfig)) {
+ return _("Selected volume group cannot be changed");
+ }
+
+ const mountPaths = configModel.volumeGroup.usedMountPaths(deviceConfig);
+ const hasMountPaths = !isEmpty(mountPaths.length);
+
+ if (!hasMountPaths) {
+ return _("Change the device to configure");
+ }
+
+ if (mountPaths.includes("/")) {
+ return _("Change the device to install the system");
+ }
+
+ const newMountPaths = deviceConfig.logicalVolumes
+ .filter((l) => !l.name)
+ .map((l) => formattedPath(l.mountPath));
+
+ return sprintf(
+ // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a
+ // single mount point in the singular case).
+ _("Change the device to create %s"),
+ formatList(newMountPaths),
+ );
+};
+
+type ChangeVolumeGroupDescriptionProps = {
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+const ChangeVolumeGroupDescription = ({ deviceConfig }: ChangeVolumeGroupDescriptionProps) => {
+ const config = useConfigModel();
+ const isReusingLogicalVolumes = configModel.volumeGroup.isReusingLogicalVolumes(deviceConfig);
+ const mountPaths = configModel.volumeGroup.usedMountPaths(deviceConfig);
+ const bootFollowsRoot = configModel.boot.isFollowingRoot(config);
+
+ if (isReusingLogicalVolumes) {
+ // The current volume group will be the only option to choose from
+ return _("This uses existing logical volumes at the volume group");
+ }
+
+ if (mountPaths.includes("/") && bootFollowsRoot) {
+ return _("Partitions needed for booting will also be adapted");
+ }
+};
+
+type DiskSelectionSideEffectProps = {
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+const DiskSelectionSideEffect = ({ deviceConfig }: DiskSelectionSideEffectProps) => {
+ const paths = deviceConfig.logicalVolumes
+ .filter((l) => !l.name)
+ .map((l) => formattedPath(l.mountPath));
+
+ if (paths.length) {
+ return sprintf(
+ n_(
+ // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a
+ // single mount point in the singular case).
+ "%s will be created as a partition",
+ "%s will be created as partitions",
+ paths.length,
+ ),
+ formatList(paths),
+ );
+ }
+};
+
+type ChangeVolumeGroupMenuItemProps = {
+ deviceConfig: ConfigModel.VolumeGroup;
+ device: Storage.Device;
+} & MenuItemProps;
+
+const ChangeVolumeGroupMenuItem = ({
+ deviceConfig,
+ device,
+ ...props
+}: ChangeVolumeGroupMenuItemProps): React.ReactNode => {
+ const unchangeable = isUnchangeable(deviceConfig);
+
+ return (
+ }
+ isDisabled={unchangeable}
+ {...props}
+ >
+
+
+ );
+};
+
+type RemoveVolumeGroupMenuItemProps = {
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+const RemoveVolumeGroupMenuItem = ({
+ deviceConfig,
+}: RemoveVolumeGroupMenuItemProps): React.ReactNode => {
+ const config = useConfigModel();
+ const deleteVolumeGroup = useDeleteVolumeGroup();
+
+ // When no additional devices has been added, the "Do not use" button can be confusing so it is
+ // omitted for all volume groups.
+ if (!configModel.hasAdditionalDevices(config)) return;
+
+ const deleteFn = () => deleteVolumeGroup(deviceConfig.vgName, false);
+ const description = _("Remove the configuration for this volume group");
+
+ return (
+
+ {_("Do not use")}
+
+ );
+};
+
+type SearchedDeviceSelectorProps = Omit<
+ DeviceSelectorModalProps,
+ "disks" | "mdRaids" | "volumeGroups" | "selected"
+> & {
+ device: Storage.Device;
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+const SearchedDeviceSelector = ({
+ device,
+ deviceConfig,
+ ...deviceSelectorModalProps
+}: SearchedDeviceSelectorProps): React.ReactNode => {
+ const availableTargets = targetDevices(useConfigModel(), useAvailableDevices());
+ const disks = availableTargets.filter(isDrive);
+ const mdRaids = availableTargets.filter(isMd);
+ const volumeGroups = availableTargets.filter(isVolumeGroup);
+
+ return (
+
+ );
+};
+
+export type SearchedVolumeGroupMenuProps = {
+ deviceConfig: ConfigModel.VolumeGroup;
+ device: Storage.Device;
+ toggle?: React.ReactElement;
+};
+
+/**
+ * Menu that provides options for users to configure the device used by a configuration
+ * entry that represents a volume group previously existing in the system.
+ */
+export default function SearchedVolumeGroupMenu({
+ deviceConfig,
+ device,
+ toggle,
+}: SearchedVolumeGroupMenuProps): React.ReactNode {
+ const [isSelectorOpen, setIsSelectorOpen] = useState(false);
+ const convertDevice = useConvertDevice();
+
+ const openSelector = () => {
+ setIsSelectorOpen(true);
+ };
+
+ const onDeviceChange = ([targetDevice]: Storage.Device[]) => {
+ setIsSelectorOpen(false);
+ convertDevice(device.name, targetDevice.name);
+ };
+
+ return (
+ <>
+ openSelector()}
+ />,
+ ,
+ ]}
+ />
+ {isSelectorOpen && (
+ }
+ intro={}
+ device={device}
+ deviceConfig={deviceConfig}
+ sideEffects={{
+ disks: ,
+ mdRaids: ,
+ }}
+ onConfirm={onDeviceChange}
+ onCancel={() => setIsSelectorOpen(false)}
+ />
+ )}
+ >
+ );
+}
diff --git a/web/src/components/storage/SpaceActionsTable.tsx b/web/src/components/storage/SpaceActionsTable.tsx
index 65c71951cc..0cedefe0c9 100644
--- a/web/src/components/storage/SpaceActionsTable.tsx
+++ b/web/src/components/storage/SpaceActionsTable.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2024] SUSE LLC
+ * Copyright (c) [2024-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -40,6 +40,7 @@ import { Icon } from "~/components/layout";
import { TreeTableColumn } from "~/components/core/TreeTable";
import { Table, Td, Th, Tr, Thead, Tbody } from "@patternfly/react-table";
import { useConfigModel } from "~/hooks/model/storage/config-model";
+import configModel from "~/model/storage/config-model";
import { supportShrink } from "~/model/storage/device";
import type { Storage as Proposal } from "~/model/proposal";
import type { ConfigModel } from "~/model/storage/config-model";
@@ -49,19 +50,15 @@ export type SpacePolicyAction = {
value: "delete" | "resizeIfNeeded";
};
-const isUsedPartition = (partition: ConfigModel.Partition): boolean => {
- return partition.filesystem !== undefined;
-};
-
// FIXME: there is too much logic here. This is one of those cases that should be considered
// when restructuring the hooks and queries.
const useReusedPartition = (name: string): ConfigModel.Partition | undefined => {
- const model = useConfigModel();
+ const config = useConfigModel();
- if (!model || !name) return;
+ if (!config || !name) return;
- const allPartitions = model.drives.flatMap((d) => d.partitions);
- return allPartitions.find((p) => p.name === name && isUsedPartition(p));
+ const volumes = configModel.devices(config).flatMap((d) => configModel.device.volumes(d));
+ return volumes.find((v) => v.name === name && configModel.volume.isUsed(v));
};
/**
@@ -223,7 +220,7 @@ export default function SpaceActionsTable({
}: SpaceActionsTableProps) {
const columns: TreeTableColumn[] = [
{
- name: _("proposal.Device"),
+ name: _("Device"),
value: (item: Proposal.UnusedSlot | Proposal.Device) => ,
},
{
diff --git a/web/src/components/storage/SpacePolicyMenu.test.tsx b/web/src/components/storage/SpacePolicyMenu.test.tsx
index b307de704b..2dd6b09e56 100644
--- a/web/src/components/storage/SpacePolicyMenu.test.tsx
+++ b/web/src/components/storage/SpacePolicyMenu.test.tsx
@@ -41,12 +41,12 @@ jest.mock("~/hooks/model/system/storage", () => ({
}));
const mockConfigModel = jest.fn();
-const mockPartitionable = jest.fn();
+const mockDeviceConfig = jest.fn();
const mockSetSpacePolicy = jest.fn();
jest.mock("~/hooks/model/storage/config-model", () => ({
useConfigModel: () => mockConfigModel(),
- usePartitionable: () => mockPartitionable(),
+ useDevice: () => mockDeviceConfig(),
useSetSpacePolicy: () => mockSetSpacePolicy,
}));
@@ -66,7 +66,7 @@ describe("SpacePolicyMenu", () => {
beforeEach(() => {
mockSystemDevice.mockReturnValue(vda);
mockConfigModel.mockReturnValue({ drives: [deviceModel] });
- mockPartitionable.mockReturnValue(deviceModel);
+ mockDeviceConfig.mockReturnValue(deviceModel);
});
it("should render the SpacePolicyMenu with correct initial state", async () => {
diff --git a/web/src/components/storage/SpacePolicyMenu.tsx b/web/src/components/storage/SpacePolicyMenu.tsx
index 6759f36e0c..1347e5bcee 100644
--- a/web/src/components/storage/SpacePolicyMenu.tsx
+++ b/web/src/components/storage/SpacePolicyMenu.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -26,22 +26,65 @@ import MenuButton, { CustomToggleProps } from "~/components/core/MenuButton";
import Text from "~/components/core/Text";
import Icon from "~/components/layout/Icon";
import { useNavigate } from "react-router";
-import { SPACE_POLICIES } from "~/components/storage/utils";
+import {
+ PARTITIONABLE_SPACE_POLICIES,
+ VOLUME_GROUP_SPACE_POLICIES,
+} from "~/components/storage/utils";
import { STORAGE as PATHS } from "~/routes/paths";
import * as driveUtils from "~/components/storage/utils/drive";
+import * as volumeGroupUtils from "~/components/storage/utils/volume-group";
import { generateEncodedPath } from "~/utils";
import { isEmpty } from "radashi";
-import { usePartitionable, useSetSpacePolicy } from "~/hooks/model/storage/config-model";
+import {
+ useDevice as useDeviceConfig,
+ useSetSpacePolicy,
+} from "~/hooks/model/storage/config-model";
import { useDevice } from "~/hooks/model/system/storage";
import type { ConfigModel } from "~/model/storage/config-model";
+import type { SpacePolicy } from "~/components/storage/utils";
+
+type PolicyItemProps = {
+ policy: SpacePolicy;
+ collection: "drives" | "mdRaids" | "volumeGroups";
+ index: number;
+};
+
+const PolicyItem = ({ policy, collection, index }: PolicyItemProps) => {
+ const navigate = useNavigate();
+ const setSpacePolicy = useSetSpacePolicy();
+ const deviceConfig = useDeviceConfig(collection, index);
+
+ const changePolicy = () => {
+ if (policy.id === "custom") {
+ return navigate(generateEncodedPath(PATHS.editSpacePolicy, { collection, index }));
+ } else {
+ setSpacePolicy(collection, index, { type: policy.id });
+ }
+ };
+
+ const description = (): string | null => {
+ switch (collection) {
+ case "drives":
+ case "mdRaids": {
+ return driveUtils.contentActionsDescription(deviceConfig as ConfigModel.Drive, policy.id);
+ }
+ case "volumeGroups": {
+ return volumeGroupUtils.contentActionsDescription(
+ deviceConfig as ConfigModel.VolumeGroup,
+ policy.id,
+ );
+ }
+ }
+ };
+
+ const isSelected = policy.id === deviceConfig.spacePolicy;
-const PolicyItem = ({ policy, modelDevice, isSelected, onClick }) => {
return (
onClick(policy.id)}
+ description={description()}
+ onClick={changePolicy}
>
{policy.label}
@@ -49,66 +92,82 @@ const PolicyItem = ({ policy, modelDevice, isSelected, onClick }) => {
};
type SpacePolicyMenuToggleProps = CustomToggleProps & {
- drive: ConfigModel.Drive;
+ collection: "drives" | "volumeGroups" | "mdRaids";
+ index: number;
};
-const SpacePolicyMenuToggle = forwardRef(({ drive, ...props }: SpacePolicyMenuToggleProps, ref) => {
- return (
-
- );
-});
+
+ {summary()}
+
+
+
+
+
+ );
+ },
+);
type SpacePolicyMenuProps = {
- collection: "drives" | "mdRaids";
+ collection: "drives" | "mdRaids" | "volumeGroups";
index: number;
};
export default function SpacePolicyMenu({ collection, index }: SpacePolicyMenuProps) {
- const navigate = useNavigate();
- const setSpacePolicy = useSetSpacePolicy();
- const deviceModel = usePartitionable(collection, index);
- const device = useDevice(deviceModel.name);
- const existingPartitions = device.partitions?.length;
+ const deviceConfig = useDeviceConfig(collection, index);
+ const device = useDevice(deviceConfig.name);
+ const hasVolumes = device && !isEmpty(device.partitions || device.logicalVolumes || []);
- if (isEmpty(existingPartitions)) return;
+ if (!hasVolumes) return;
- const onSpacePolicyChange = (spacePolicy: ConfigModel.SpacePolicy) => {
- if (spacePolicy === "custom") {
- return navigate(generateEncodedPath(PATHS.editSpacePolicy, { collection, index }));
- } else {
- setSpacePolicy(collection, index, { type: spacePolicy });
+ const policies = (): SpacePolicy[] => {
+ switch (collection) {
+ case "drives":
+ case "mdRaids": {
+ return PARTITIONABLE_SPACE_POLICIES;
+ }
+ case "volumeGroups": {
+ return VOLUME_GROUP_SPACE_POLICIES;
+ }
}
};
- const currentPolicy = driveUtils.spacePolicyEntry(deviceModel);
-
return (
(
-
+ items={policies().map((policy) => (
+
))}
- customToggle={}
+ customToggle={}
/>
);
}
diff --git a/web/src/components/storage/SpacePolicySelectionPage.tsx b/web/src/components/storage/SpacePolicySelectionPage.tsx
index 00ceee64f8..1854fb3bcd 100644
--- a/web/src/components/storage/SpacePolicySelectionPage.tsx
+++ b/web/src/components/storage/SpacePolicySelectionPage.tsx
@@ -26,62 +26,64 @@ import { sprintf } from "sprintf-js";
import { useNavigate, useParams } from "react-router";
import { Page, SubtleContent } from "~/components/core";
import SpaceActionsTable, { SpacePolicyAction } from "~/components/storage/SpaceActionsTable";
-import { createPartitionableLocation, deviceChildren } from "~/components/storage/utils";
-import { useDevices } from "~/hooks/model/system/storage";
-import { usePartitionable, useSetSpacePolicy } from "~/hooks/model/storage/config-model";
+import { deviceChildren } from "~/components/storage/utils";
+import { useDevice } from "~/hooks/model/system/storage";
+import {
+ useDevice as useDeviceConfig,
+ useSetSpacePolicy,
+} from "~/hooks/model/storage/config-model";
import { toDevice } from "~/components/storage/device-utils";
import { STORAGE } from "~/routes/paths";
import { _ } from "~/i18n";
+import configModel from "~/model/storage/config-model";
+import type { Storage as System } from "~/model/system";
+import type { DeviceCollection } from "~/model/storage/config-model";
+import { isVolumeGroup } from "~/model/storage/device";
-import type { Storage as Proposal } from "~/model/proposal";
-import type { ConfigModel, Partitionable } from "~/model/storage/config-model";
+type Action = "delete" | "resizeIfNeeded";
-const partitionAction = (partition: ConfigModel.Partition) => {
- if (partition.delete) return "delete";
- if (partition.resizeIfNeeded) return "resizeIfNeeded";
-
- return undefined;
-};
-
-function useDeviceModelFromParams(): Partitionable.Device | null {
+function useDeviceParams(): [DeviceCollection, number] {
const { collection, index } = useParams();
- const location = createPartitionableLocation(collection, index);
- const deviceModel = usePartitionable(location.collection, location.index);
- return deviceModel;
+ return [collection as DeviceCollection, Number(index)];
}
/**
* Renders a page that allows the user to select the space policy and actions.
*/
export default function SpacePolicySelectionPage() {
- const deviceModel = useDeviceModelFromParams();
- const devices = useDevices();
- const device = devices.find((d) => d.name === deviceModel.name);
- const children = deviceChildren(device);
+ const [collection, index] = useDeviceParams();
+ const deviceConfig = useDeviceConfig(collection, index);
+ const device = useDevice(deviceConfig.name);
const setSpacePolicy = useSetSpacePolicy();
- const { collection, index } = useParams();
+ const navigate = useNavigate();
+
+ const children = deviceChildren(device);
- const partitionDeviceAction = (device: Proposal.Device) => {
- const partition = deviceModel.partitions?.find((p) => p.name === device.name);
+ const volumeDeviceAction = (volumeDevice: System.Device): Action | null => {
+ const volumeConfig = configModel.device
+ .volumes(deviceConfig)
+ .find((v) => v.name === volumeDevice.name);
- return partition ? partitionAction(partition) : undefined;
+ if (!volumeConfig) return null;
+
+ if (volumeConfig.delete) return "delete";
+
+ if (volumeConfig.resizeIfNeeded) return "resizeIfNeeded";
};
const [actions, setActions] = useState(
children
- .filter((d) => toDevice(d) && partitionDeviceAction(toDevice(d)))
+ .filter((d) => toDevice(d) && volumeDeviceAction(toDevice(d)))
.map(
- (d: Proposal.Device): SpacePolicyAction => ({
+ (d: System.Device): SpacePolicyAction => ({
deviceName: toDevice(d).name,
- value: partitionDeviceAction(toDevice(d)),
+ value: volumeDeviceAction(toDevice(d)),
}),
),
);
- const navigate = useNavigate();
-
- const deviceAction = (device: Proposal.Device | Proposal.UnusedSlot) => {
+ const deviceAction = (device: System.Device | System.UnusedSlot) => {
if (toDevice(device) === undefined) return "keep";
return actions.find((a) => a.deviceName === toDevice(device).name)?.value || "keep";
@@ -96,16 +98,22 @@ export default function SpacePolicySelectionPage() {
const onSubmit = (e) => {
e.preventDefault();
- const location = createPartitionableLocation(collection, index);
- if (!location) return;
- setSpacePolicy(location.collection, location.index, { type: "custom", actions });
+ setSpacePolicy(collection, index, { type: "custom", actions });
navigate("..");
};
- const description = _(
- "Select what to do with each partition in order to find space for allocating the new system.",
- );
+ const description = (): string => {
+ if (isVolumeGroup(device)) {
+ return _(
+ "Select what to do with each logical volume in order to find space for allocating the new system.",
+ );
+ }
+
+ return _(
+ "Select what to do with each partition in order to find space for allocating the new system.",
+ );
+ };
return (
- {description}
+ {description()}