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/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..a40b756f8b 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 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/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/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..cd4b603dbf 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 @@ -252,8 +279,40 @@ end end -shared_examples "device name" do +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 "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 +337,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 +359,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 +372,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 +394,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 +406,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 +443,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 +457,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 +473,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 +489,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 +505,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 +521,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 +536,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..0fad2b3d27 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 } @@ -143,18 +151,26 @@ 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 } 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 }