diff --git a/doc/auto_storage.md b/doc/auto_storage.md index e7cd92ebea..e060a309e4 100644 --- a/doc/auto_storage.md +++ b/doc/auto_storage.md @@ -37,8 +37,8 @@ Storage volumeGroups mdRaids btrfsRaids - bcacheDevices nfsMounts + boot [BootSettings] guided ``` @@ -67,6 +67,7 @@ Drive format [] mount [] ptableType [] + space <'delete'|'resize'|'keep'> partitions [] VolumeGroup @@ -89,6 +90,7 @@ MdRaid format [] mount [] ptableType [] + space <'delete'|'resize'|'keep'> partitions [] delete [] @@ -162,6 +164,10 @@ Size <'default'|string|SizeRange> SizeRange min max + +BootSettings + configure + device ``` To illustrate how all that fits together, let's see the following example in which the first disk of @@ -420,6 +426,41 @@ above, it would be possible to use the key as name of the property, resulting in } ``` +## Making Space and Specifying what to do with Existing Partitions (under discussion) + +The `space` subsection of each drive or RAID can be used to specify what to do with existing +partitions, if any. That can also be combined with more specific actions indicated by "searching" a +given partition within the `partitions` subsection. + +Theoretically, we could do the following actions for each given partition during the algorithm +execution. We just need to decide how to define all that using `space` and `partitions`. + +- Delete the partition (mandatory action executed as soon as we process the disk) +- Shrink the partition to a given size (same than above, note we need to clearly define the way to + specify a partition must be resized or grown and likely both things will happen at different + stages of the algorithm). +- Shrink the partition if needed (optional action done if needed and with a calculated target size). +- Delete the partition if needed (optional action done if needed). +- Shrink or delete if needed (first an optional resize will be attemped, deleting if it's not + enough). + +Note also we may need to consider which resize actions are possible depending on the content of the +partition, the filesystem type, etc. + +Maybe a `space` action is not needed since the same behavior can be specified only using something +like this: + +```json +"storage": { + "drives": [ + { + "partitions": + { "search": {}, "delete": true } + } + ] +} +``` + ## Referencing Other Devices Sometimes is necessary to reference other devices as part of the specification of an LVM volume @@ -518,28 +559,21 @@ system (so the same conditions can be matched by a disk, a partition, an LVM dev ## Partitions needed for Booting -When relying on the Agama proposal (see below), there are some options to configure whether (and -where) Agama should calculate and create the extra partitions needed for booting. - -If the proposal is not used, Agama will always try to calculate and create those partitions taking -the location of the root file system as a reference. That's the same approach that AutoYaST has -followed for years. +The `boot` section can be used to configure whether (and where) Agama should calculate and create +the extra partitions needed for booting. If the device is not specified, Agama will take the +location of the root file system as a reference. ## Using the Automatic Proposal Agama can rely on the process known as Guided Proposal to calculate all the needed partitions, LVM -devices and file systems based on some general product settings and some user preferences. That -mechanism can also be used as part of the profile and will be executed as a last step, after -processing all the explicit sections that describe devices. +devices and file systems based on some general product settings and some user preferences. The `guided` section conforms to the following specification. ``` Guided device [TargetDevice] - boot [BootSettings] encryption [EncryptionSettings] - space <'delete'|'resize'|'keep'> volumes [Volume[]] TargetDevice @@ -553,10 +587,6 @@ TargetNewLvm TargetReusedLvm reusedLvmVg -BootSettings - configure - device - EncryptionSettings password method diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb new file mode 100644 index 0000000000..1deeba2a6e --- /dev/null +++ b/service/lib/agama/storage/config.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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" + +module Agama + module Storage + # Settings used to calculate an storage proposal. + class Config + # Boot settings. + # + # @return [Configs::Boot] + attr_accessor :boot + + # @return [Array] + attr_accessor :drives + + # @return [Array] + attr_accessor :volume_groups + + # @return [Array] + attr_accessor :md_raids + + # @return [Array] + attr_accessor :btrfs_raids + + # @return [Array] + attr_accessor :nfs_mounts + + def initialize + @boot = Configs::Boot.new + @drives = [] + @volume_groups = [] + @md_raids = [] + @btrfs_raids = [] + @nfs_mounts = [] + end + + # Creates a config from JSON hash according to schema. + # + # @param config_json [Hash] + # @param product_config [Agama::Config] + # + # @return [Storage::Config] + def self.new_from_json(config_json, product_config:) + ConfigConversions::FromJSON.new(config_json, product_config: product_config).convert + end + + # Name of the device that will presumably be used to boot the target system + # + # @return [String, nil] nil if there is no enough information to infer a possible boot disk + def boot_device + explicit_boot_device || implicit_boot_device + end + + # Device used for booting the target system + # + # @return [String, nil] nil if no disk is explicitly chosen + def explicit_boot_device + return nil unless boot.configure? + + boot.device + end + + # Device that seems to be expected to be used for booting, according to the drive definitions + # + # @return [String, nil] nil if the information cannot be inferred from the list of drives + def implicit_boot_device + # NOTE: preliminary implementation with very simplistic checks + root_drive = drives.find do |drive| + drive.partitions.any? { |p| p.filesystem.root? } + end + + root_drive&.found_device&.name + end + + # Sets min and max sizes for all partitions and logical volumes with default size + # + # @param volume_builder [VolumeTemplatesBuilder] used to check the configuration of the + # product volume templates + def calculate_default_sizes(volume_builder) + default_size_devices.each do |dev| + dev.size.min = default_size(dev, :min, volume_builder) + dev.size.max = default_size(dev, :max, volume_builder) + end + end + + private + + # return [Array] + def filesystems + (drives + partitions).map(&:filesystem).compact + end + + # return [Array] + def partitions + drives.flat_map(&:partitions) + end + + # return [Array] + def default_size_devices + partitions.select { |p| p.size&.default? } + end + + # Min or max size that should be used for the given partition or logical volume + # + # @param device [Configs::Partition] device configured to have a default size + # @param attr [Symbol] :min or :max + # @param builder [VolumeTemplatesBuilder] see {#calculate_default_sizes} + def default_size(device, attr, builder) + path = device.filesystem&.path || "" + vol = builder.for(path) + return fallback_size(attr) unless vol + + # Theoretically, neither Volume#min_size or Volume#max_size can be nil + # At most they will be zero or unlimited, respectively + return vol.send(:"#{attr}_size") unless vol.auto_size? + + outline = vol.outline + size = size_with_fallbacks(outline, attr, builder) + size = size_with_ram(size, outline) + size_with_snapshots(size, device, outline) + end + + # TODO: these are the fallbacks used when constructing volumes, not sure if repeating them + # here is right + def fallback_size(attr) + return Y2Storage::DiskSize.zero if attr == :min + + Y2Storage::DiskSize.unlimited + end + + # @see #default_size + def size_with_fallbacks(outline, attr, builder) + fallback_paths = outline.send(:"#{attr}_size_fallback_for") + missing_paths = fallback_paths.reject { |p| proposed_path?(p) } + + size = outline.send(:"base_#{attr}_size") + missing_paths.inject(size) { |total, p| total + builder.for(p).send(:"#{attr}_size") } + end + + # @see #default_size + def size_with_ram(initial_size, outline) + return initial_size unless outline.adjust_by_ram? + + [initial_size, ram_size].max + end + + # @see #default_size + def size_with_snapshots(initial_size, device, outline) + return initial_size unless device.filesystem.btrfs_snapshots? + return initial_size unless outline.snapshots_affect_sizes? + + if outline.snapshots_size && outline.snapshots_size > DiskSize.zero + initial_size + outline.snapshots_size + else + multiplicator = 1.0 + (outline.snapshots_percentage / 100.0) + initial_size * multiplicator + end + end + + # Whether there is a separate filesystem configured for the given path + # + # @param path [String, Pathname] + # @return [Boolean] + def proposed_path?(path) + filesystems.any? { |fs| fs.path?(path) } + end + + # Return the total amount of RAM as DiskSize + # + # @return [DiskSize] current RAM size + def ram_size + @ram_size ||= Y2Storage::DiskSize.new(Y2Storage::StorageManager.instance.arch.ram_size) + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions.rb b/service/lib/agama/storage/config_conversions.rb new file mode 100644 index 0000000000..c17ee67683 --- /dev/null +++ b/service/lib/agama/storage/config_conversions.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/block_device" +require "agama/storage/config_conversions/drive" +require "agama/storage/config_conversions/encrypt" +require "agama/storage/config_conversions/filesystem" +require "agama/storage/config_conversions/format" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_conversions/mount" +require "agama/storage/config_conversions/partition" +require "agama/storage/config_conversions/partitionable" +require "agama/storage/config_conversions/size" + +module Agama + module Storage + # Conversions for the storage config. + module ConfigConversions + end + end +end diff --git a/service/lib/agama/storage/config_conversions/block_device.rb b/service/lib/agama/storage/config_conversions/block_device.rb new file mode 100644 index 0000000000..7fcbb01077 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/block_device.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/block_device/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for block device. + module BlockDevice + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/block_device/from_json.rb b/service/lib/agama/storage/config_conversions/block_device/from_json.rb new file mode 100644 index 0000000000..768c2ba358 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/block_device/from_json.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/encryption/from_json" +require "agama/storage/config_conversions/filesystem/from_json" +require "agama/storage/config_conversions/filesystem_type/from_json" +require "agama/storage/configs/encryption" +require "agama/storage/configs/filesystem" +require "agama/storage/configs/filesystem_type" + +module Agama + module Storage + module ConfigConversions + module BlockDevice + # Block device conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param blk_device_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(blk_device_json, settings:, volume_builder:) + @blk_device_json = blk_device_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param config [#encrypt=, #format=, #mount=] + def convert(config) + config.encryption = convert_encrypt + config.filesystem = convert_filesystem + config + end + + private + + # @return [Hash] + attr_reader :blk_device_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @return [Configs::Encrypt, nil] + def convert_encrypt + encrypt_json = blk_device_json[:encryption] + return unless encrypt_json + + Encryption::FromJSON.new(encrypt_json, default: default_encrypt_config).convert + end + + # @return [Configs::Filesystem, nil] + def convert_filesystem + filesystem_json = blk_device_json[:filesystem] + return if filesystem_json.nil? + + default = default_filesystem_config(filesystem_json&.dig(:path) || "") + + # @todo Check whether the given filesystem can be used for the mount point. + # @todo Check whether snapshots can be configured and restore to default if needed. + + Filesystem::FromJSON.new(filesystem_json).convert(default) + end + + # @todo Recover values from ProductDefinition instead of ProposalSettings. + # + # Default encryption config from the product definition. + # + # @return [Configs::Encryption] + def default_encrypt_config + Configs::Encryption.new.tap do |config| + config.password = settings.encryption.password + config.method = settings.encryption.method + config.pbkd_function = settings.encryption.pbkd_function + end + end + + # Default format config from the product definition. + # + # @param mount_path [String] + # @return [Configs::Filesystem] + def default_filesystem_config(mount_path) + Configs::Filesystem.new.tap do |config| + config.type = default_fstype_config(mount_path) + end + end + + # @todo Recover values from ProductDefinition instead of VolumeTemplatesBuilder. + # + # Default filesystem config from the product definition. + # + # @param mount_path [String] + # @return [Configs::FilesystemType] + def default_fstype_config(mount_path) + volume = volume_builder.for(mount_path) + + Configs::FilesystemType.new.tap do |config| + config.fs_type = volume.fs_type + config.btrfs = volume.btrfs + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/drive.rb b/service/lib/agama/storage/config_conversions/drive.rb new file mode 100644 index 0000000000..f42dd41358 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/drive.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/drive/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for drive. + module Drive + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/drive/from_json.rb b/service/lib/agama/storage/config_conversions/drive/from_json.rb new file mode 100644 index 0000000000..698e74c69e --- /dev/null +++ b/service/lib/agama/storage/config_conversions/drive/from_json.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/block_device/from_json" +require "agama/storage/config_conversions/partitionable/from_json" +require "agama/storage/configs/drive" + +module Agama + module Storage + module ConfigConversions + module Drive + # Drive conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param drive_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(drive_json, settings:, volume_builder:) + @drive_json = drive_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @return [Configs::Drive] + def convert + Configs::Drive.new.tap do |config| + convert_block_device(config) + convert_partitionable(config) + end + end + + private + + # @return [Hash] + attr_reader :drive_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @param config [Configs::Drive] + def convert_block_device(config) + converter = BlockDevice::FromJSON.new(drive_json, + settings: settings, volume_builder: volume_builder) + + converter.convert(config) + end + + # @param config [Configs::Drive] + def convert_partitionable(config) + converter = Partitionable::FromJSON.new(drive_json, + settings: settings, volume_builder: volume_builder) + + converter.convert(config) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/encryption.rb b/service/lib/agama/storage/config_conversions/encryption.rb new file mode 100644 index 0000000000..288818bb96 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/encryption.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/encryption/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for encryption. + module Encryption + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/encryption/from_json.rb b/service/lib/agama/storage/config_conversions/encryption/from_json.rb new file mode 100644 index 0000000000..c9e193d694 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/encryption/from_json.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/encryption" +require "y2storage/encryption_method" +require "y2storage/pbkd_function" + +module Agama + module Storage + module ConfigConversions + module Encryption + # Encryption conversion from JSON hash according to schema. + class FromJSON + # @param encryption_json [Hash, String] + # @param default [Configs::Encrypt] + def initialize(encryption_json, default: nil) + @encryption_json = encryption_json + @default_config = default || Configs::Encryption.new + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @return [Configs::Encryption] + def convert + default_config.dup.tap do |config| + convert_luks1(config) || + convert_luks2(config) || + convert_pervasive_luks2(config) || + convert_swap_encryption(config) + end + end + + private + + # @return [Hash, String] + attr_reader :encryption_json + + # @return [Configs::Encryption] + attr_reader :default_config + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match LUKS1 schema. + def convert_luks1(config) + luks1_json = encryption_json.is_a?(Hash) && encryption_json[:luks1] + return unless luks1_json + + key_size = convert_key_size(luks1_json) + cipher = convert_cipher(luks1_json) + + config.method = Y2Storage::EncryptionMethod::LUKS1 + config.password = convert_password(luks1_json) + config.key_size = key_size if key_size + config.cipher = cipher if cipher + end + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match LUKS2 schema. + def convert_luks2(config) + luks2_json = encryption_json.is_a?(Hash) && encryption_json[:luks2] + return unless luks2_json + + key_size = convert_key_size(luks2_json) + cipher = convert_cipher(luks2_json) + label = convert_label + pbkdf = convert_pbkd_function + + config.method = Y2Storage::EncryptionMethod::LUKS2 + config.password = convert_password(luks2_json) + config.key_size = key_size if key_size + config.cipher = cipher if cipher + config.label = label if label + config.pbkd_function = pbkdf if pbkdf + end + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match pervasive LUKS2 schema. + def convert_pervasive_luks2(config) + pervasive_json = encryption_json.is_a?(Hash) && encryption_json[:pervasive_luks2] + return unless pervasive_json + + config.method = Y2Storage::EncryptionMethod::PERVASIVE_LUKS2 + config.password = convert_password(pervasive_json) + end + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match a swap encryption schema. + def convert_swap_encryption(config) + return unless encryption_json.is_a?(String) + + # @todo Report issue if the schema admits an unknown method. + method = Y2Storage::EncryptionMethod.find(encryption_json.to_sym) + return unless method + + config.method = method + end + + # @param method_json [Hash] + # @return [String, nil] + def convert_password(method_json) + method_json[:password] + end + + # @param method_json [Hash] + # @return [Integer, nil] + def convert_key_size(method_json) + method_json[:keySize] + end + + # @param method_json [Hash] + # @return [String, nil] + def convert_cipher(method_json) + method_json[:cipher] + end + + # @return [String, nil] + def convert_label + encryption_json.dig(:luks2, :label) + end + + # @return [Y2Storage::PbkdFunction, nil] + def convert_pbkd_function + Y2Storage::PbkdFunction.find(encryption_json.dig(:luks2, :pbkdFunction)) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/filesystem.rb new file mode 100644 index 0000000000..35828920a5 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/filesystem/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for filesystem. + module Filesystem + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem/from_json.rb b/service/lib/agama/storage/config_conversions/filesystem/from_json.rb new file mode 100644 index 0000000000..ac637f2434 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem/from_json.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/filesystem_type/from_json" +require "agama/storage/configs/filesystem" + +module Agama + module Storage + module ConfigConversions + module Filesystem + # Filesystem conversion from JSON hash according to schema. + class FromJSON + # @param filesystem_json [Hash] + def initialize(filesystem_json) + @filesystem_json = filesystem_json + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Filesystem, nil] + # @return [Configs::Format] + def convert(default = nil) + default_config = default.dup || Configs::Filesystem.new + + default_config.tap do |config| + label = filesystem_json[:label] + mkfs_options = filesystem_json[:mkfsOptions] + + config.path = filesystem_json[:path] + config.mount_options = filesystem_json[:mountOptions] || [] + config.type = convert_type(config.type) + config.label = label if label + config.mkfs_options = mkfs_options if mkfs_options + end + end + + private + + # @return [Hash] + attr_reader :filesystem_json + + # @param default [Configs::Filesystem, nil] + # @return [Configs::FilesystemType] + def convert_type(default = nil) + FilesystemType::FromJSON.new(filesystem_json[:type]).convert(default) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem_type.rb b/service/lib/agama/storage/config_conversions/filesystem_type.rb new file mode 100644 index 0000000000..ace22105b4 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/filesystem_type/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for filesystem types + module FilesystemType + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem_type/from_json.rb b/service/lib/agama/storage/config_conversions/filesystem_type/from_json.rb new file mode 100644 index 0000000000..5df77eb2d9 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem_type/from_json.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/btrfs" +require "agama/storage/configs/filesystem_type" +require "y2storage/filesystems/type" + +module Agama + module Storage + module ConfigConversions + module FilesystemType + # Filesystem conversion from JSON hash according to schema. + class FromJSON + # @param filesystem_json [Hash, String, nil] + def initialize(filesystem_json) + @filesystem_json = filesystem_json + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Filesystem, nil] + # @return [Configs::Filesystem] + def convert(default = nil) + default_config = default.dup || Configs::FilesystemType.new + + default_config.tap do |config| + btrfs = convert_btrfs(config.btrfs) + type = convert_type + + config.fs_type = type if type + config.btrfs = btrfs if btrfs + end + end + + private + + # @return [Hash] + attr_reader :filesystem_json + + # @return [Y2Storage::Filesystems::Type] + def convert_type + return if filesystem_json.nil? + + value = filesystem_json.is_a?(String) ? filesystem_json : "btrfs" + Y2Storage::Filesystems::Type.find(value.to_sym) + end + + # @param default [Configs::Btrfs] + # @return [Configs::Btrfs, nil] + def convert_btrfs(default = nil) + return if filesystem_json.nil? || filesystem_json.is_a?(String) + + btrfs_json = filesystem_json[:btrfs] + default_config = default.dup || Configs::Btrfs.new + + default_config.tap do |config| + snapshots = btrfs_json[:snapshots] + + config.snapshots = snapshots unless snapshots.nil? + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_json.rb b/service/lib/agama/storage/config_conversions/from_json.rb new file mode 100644 index 0000000000..6c1cace51d --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_json.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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" +require "agama/storage/config_conversions/drive/from_json" +require "agama/storage/proposal_settings_reader" +require "agama/storage/configs/boot" + +module Agama + module Storage + module ConfigConversions + # Config conversion from JSON hash according to schema. + # + # TODO: The approach for generating a Config from JSON could be improved: + # * All the FromJSON classes receive only a JSON and an optional default config to start + # converting from it. + # * There should be a "config generator" class which knows the ProductDefinition and creates + # config objects calling to the proper FromJSON classes, passing the default config for + # each case (drive, partition, etc). + # + # For example: + # + # def generate_drive(drive_json) + # default = default_drive(drive_json.dig(:filesystem, :path)) + # drive = Drive::FromJson.new(drive_json).convert(default) + # drive.partitions = drive_json[:partitions].map do |partition_json| + # default = default_partition(partition_json.dig(:fileystem, :path)) + # Partition::FromJSON.new(partition_json).convert(default) + # end + # drive + # end + # + # This improvement could be done at the time of introducing the ProductDefinition class. + class FromJSON + # @todo Replace product_config param by a ProductDefinition. + # + # @param config_json [Hash] + # @param product_config [Agama::Config] + def initialize(config_json, product_config:) + @config_json = config_json + @product_config = product_config + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @return [Storage::Config] + def convert + # @todo Raise error if config_json does not match the JSON schema. + Storage::Config.new.tap do |config| + boot = convert_boot + drives = convert_drives + + config.boot = boot if boot + config.drives = drives if drives + config.calculate_default_sizes(volume_builder) + end + end + + private + + # @return [Hash] + attr_reader :config_json + + # @return [Agama::Config] + attr_reader :product_config + + # @return [Configs::Boot, nil] + def convert_boot + boot_json = config_json[:boot] + return unless boot_json + + Configs::Boot.new.tap do |config| + config.configure = boot_json[:configure] + config.device = boot_json[:device] + end + end + + # @return [Array, nil] + def convert_drives + drives_json = config_json[:drives] + return unless drives_json + + drives_json.map { |d| convert_drive(d) } + end + + # @return [Configs::Drive] + def convert_drive(drive_json) + Drive::FromJSON.new(drive_json, + settings: settings, volume_builder: volume_builder).convert + end + + # @return [ProposalSettings] + def settings + @settings ||= ProposalSettingsReader.new(product_config).read + end + + # @return [VolumeTemplatesBuilder] + def volume_builder + @volume_builder ||= VolumeTemplatesBuilder.new_from_config(product_config) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partition.rb b/service/lib/agama/storage/config_conversions/partition.rb new file mode 100644 index 0000000000..52b67d2f50 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partition.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/partition/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for partition. + module Partition + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partition/from_json.rb b/service/lib/agama/storage/config_conversions/partition/from_json.rb new file mode 100644 index 0000000000..47e7736d4e --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partition/from_json.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/block_device/from_json" +require "agama/storage/config_conversions/size/from_json" +require "agama/storage/configs/partition" +require "y2storage/partition_id" + +module Agama + module Storage + module ConfigConversions + module Partition + # Partition conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param partition_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(partition_json, settings:, volume_builder:) + @partition_json = partition_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @return [Configs::Partition] + def convert + Configs::Partition.new.tap do |config| + config.id = convert_id + config.size = convert_size + convert_block_device(config) + end + end + + private + + # @return [Hash] + attr_reader :partition_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @return [Y2Storage::PartitionId, nil] + def convert_id + value = partition_json[:id] + return unless value + + Y2Storage::PartitionId.find(value) + end + + # @return [Configs::Size] + def convert_size + size_json = partition_json[:size] + return Configs::Size.new unless size_json + + Size::FromJSON.new(size_json).convert + end + + # @param config [Configs::Partition] + def convert_block_device(config) + converter = BlockDevice::FromJSON.new(partition_json, + settings: settings, volume_builder: volume_builder) + + converter.convert(config) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partitionable.rb b/service/lib/agama/storage/config_conversions/partitionable.rb new file mode 100644 index 0000000000..95094cb0a4 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partitionable.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/partitionable/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for partitionable. + module Partitionable + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partitionable/from_json.rb b/service/lib/agama/storage/config_conversions/partitionable/from_json.rb new file mode 100644 index 0000000000..fd6141b249 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partitionable/from_json.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/partition/from_json" +require "y2storage/partition_tables/type" + +module Agama + module Storage + module ConfigConversions + module Partitionable + # Partitionable device conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param partitionable_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(partitionable_json, settings:, volume_builder:) + @partitionable_json = partitionable_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param config [#ptable_type=, #partitions=] + def convert(config) + config.ptable_type = convert_ptable_type + config.partitions = convert_partitions + config + end + + private + + # @return [Hash] + attr_reader :partitionable_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @return [Y2Storage::PartitionTables::Type, nil] + def convert_ptable_type + value = partitionable_json[:ptableType] + return unless value + + Y2Storage::PartitionTables::Type.find(value) + end + + # @return [Array] + def convert_partitions + partitions_json = partitionable_json[:partitions] + return [] unless partitions_json + + partitions_json.map { |p| convert_partition(p) } + end + + # @param partition_json [Hash] + # @return [Configs::Partition] + def convert_partition(partition_json) + Partition::FromJSON.new(partition_json, + settings: settings, volume_builder: volume_builder).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/size.rb b/service/lib/agama/storage/config_conversions/size.rb new file mode 100644 index 0000000000..b85b9e734d --- /dev/null +++ b/service/lib/agama/storage/config_conversions/size.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/size/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for size. + module Size + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/size/from_json.rb b/service/lib/agama/storage/config_conversions/size/from_json.rb new file mode 100644 index 0000000000..f140ff9ed4 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/size/from_json.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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 "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module Size + # Size conversion from JSON hash according to schema. + class FromJSON + # @param size_json [Hash] + def initialize(size_json) + @size_json = size_json + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @return [Configs::Size] + def convert + return default_size if size_json.is_a?(String) && size_json.casecmp?("default") + + Configs::Size.new.tap do |config| + config.default = false + config.min = convert_size(:min) + config.max = convert_size(:max) || Y2Storage::DiskSize.unlimited + end + end + + private + + # @return [Hash] + attr_reader :size_json + + # @return [Y2Storage::DiskSize, nil] + def convert_size(field) + value = case size_json + when Hash + size_json[field] + when Array + field == :max ? size_json[1] : size_json[0] + else + size_json + end + + return unless value + + begin + # This parses without legacy_units, ie. "1 GiB" != "1 GB" + Y2Storage::DiskSize.new(value) + rescue TypeError + # JSON schema validations should prevent this from happening + end + end + + def default_size + Configs::Size.new.tap { |c| c.default = true } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/configs.rb b/service/lib/agama/storage/configs.rb new file mode 100644 index 0000000000..7f276374c0 --- /dev/null +++ b/service/lib/agama/storage/configs.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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 + # Namespace for all the supported settings to configure storage + module Configs + end + end +end + +require "agama/storage/configs/boot" +require "agama/storage/configs/btrfs" +require "agama/storage/configs/drive" +require "agama/storage/configs/encryption" +require "agama/storage/configs/filesystem" +require "agama/storage/configs/filesystem_type" +require "agama/storage/configs/partition" +require "agama/storage/configs/search" +require "agama/storage/configs/size" diff --git a/service/lib/agama/storage/btrfs_settings.rb b/service/lib/agama/storage/configs/boot.rb similarity index 52% rename from service/lib/agama/storage/btrfs_settings.rb rename to service/lib/agama/storage/configs/boot.rb index 51b990b330..88071b086d 100644 --- a/service/lib/agama/storage/btrfs_settings.rb +++ b/service/lib/agama/storage/configs/boot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2023] SUSE LLC +# Copyright (c) [2024] SUSE LLC # # All Rights Reserved. # @@ -21,29 +21,25 @@ module Agama module Storage - # Settings regarding Btrfs for a given Volume - class BtrfsSettings - # Whether the volume contains Btrfs snapshots - # - # @return [Boolean] - attr_accessor :snapshots - alias_method :snapshots?, :snapshots + module Configs + # Boot configuration. + class Boot + # Whether to configure partitions for booting. + # + # @return [Boolean] + attr_accessor :configure + alias_method :configure?, :configure - # @return [Boolean] - attr_accessor :read_only - alias_method :read_only?, :read_only + # Device to use for booting. + # + # @return [String, nil] if nil, then the proposal decides the booting device, normally the + # device for allocating root. + attr_accessor :device - # @return [Array, nil] if nil, a historical fallback list may - # be applied depending on the mount path of the volume - attr_accessor :subvolumes - - # @return [String] - attr_accessor :default_subvolume - - def initialize - @snapshots = false - @read_only = false - @default_subvolume = "" + # Constructor + def initialize + @configure = true + end end end end diff --git a/service/lib/agama/storage/configs/btrfs.rb b/service/lib/agama/storage/configs/btrfs.rb new file mode 100644 index 0000000000..1dd3cf4dec --- /dev/null +++ b/service/lib/agama/storage/configs/btrfs.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + # Btrfs configuration. + class Btrfs + # Whether there are snapshots. + # + # @return [Boolean] + attr_accessor :snapshots + alias_method :snapshots?, :snapshots + + # @return [Boolean] + attr_accessor :read_only + alias_method :read_only?, :read_only + + # @return [Array, nil] if nil, a historical fallback list + # may be applied depending on the mount path of the volume + attr_accessor :subvolumes + + # @return [String] + attr_accessor :default_subvolume + + # Constructor + def initialize + @snapshots = false + @read_only = false + @default_subvolume = "" + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/drive.rb b/service/lib/agama/storage/configs/drive.rb new file mode 100644 index 0000000000..36ddff9351 --- /dev/null +++ b/service/lib/agama/storage/configs/drive.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/search" + +module Agama + module Storage + module Configs + # Section of the configuration representing a device that is expected to exist in the target + # system and that can be used as a regular disk. + class Drive + # @return [Search, nil] + attr_accessor :search + + # @return [Encryption, nil] + attr_accessor :encryption + + # @return [Filesystem, nil] + attr_accessor :filesystem + + # @return [Y2Storage::PartitionTables::Type, nil] + attr_accessor :ptable_type + + # @return [Array] + attr_accessor :partitions + + # Constructor + def initialize + @partitions = [] + end + + # Resolves the search, so a devices of the given devicegraph is associated to the drive if + # possible + # + # Since all drives are expected to match a real device in the system, this creates a default + # search if that was ommited. + # + # @param devicegraph [Y2Storage::Devicegraph] source of the search + # @param used_sids [Array] SIDs of the devices that are already associated to + # another drive, so they cannot be associated to this + def search_device(devicegraph, used_sids) + @search ||= default_search + devs = devicegraph.blk_devices.select { |d| d.is?(:disk_device, :stray_blk_device) } + search.find(devs, used_sids) + end + + # @return [Search] + def default_search + Search.new + end + + # Device resulting from a previous call to {#search_device} + # + # @return [Y2Storage::Device, nil] + def found_device + search&.device + end + + # Whether the drive definition contains partition definitions + # + # @return [Boolean] + def partitions? + partitions.any? + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/encryption.rb b/service/lib/agama/storage/configs/encryption.rb new file mode 100644 index 0000000000..5b28ed4ea6 --- /dev/null +++ b/service/lib/agama/storage/configs/encryption.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/secret_attributes" + +module Agama + module Storage + module Configs + # Configuration setting describing the desired encryption for a device + class Encryption + include Y2Storage::SecretAttributes + + # @return [Y2Storage::EncryptionMethod::Base] + attr_accessor :method + + # @!attribute password + # Password to use if the encryption method requires one + # @return [String, nil] nil if undetermined or not needed + secret_attr :password + + # PBKD function to use for LUKS2 + # + # @return [Y2Storage::PbkdFunction, nil] Can be nil for methods that are not LUKS2 + attr_accessor :pbkd_function + + # Optional LUKS2 label + # + # @return [String, nil] + attr_accessor :label + + # Optional cipher if LUKS is going to be used + # + # @return [String, nil] + attr_accessor :cipher + + # Specific key size (in bits) if LUKS is going to be used + # + # @return [Integer,nil] If nil, the default key size will be used. If an integer + # value is used, it has to be a multiple of 8 + attr_accessor :key_size + end + end + end +end diff --git a/service/lib/agama/storage/configs/filesystem.rb b/service/lib/agama/storage/configs/filesystem.rb new file mode 100644 index 0000000000..4b126ddd26 --- /dev/null +++ b/service/lib/agama/storage/configs/filesystem.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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 "pathname" + +module Agama + module Storage + module Configs + # File system configuration. + class Filesystem + # @return [Pathname] Object that represents the root path. + ROOT_PATH = Pathname.new("/").freeze + + # @return [String, nil] + attr_accessor :path + + # @return [Configs::FilesystemType, nil] + attr_accessor :type + + # @return [String, nil] + attr_accessor :label + + # @return [Array] + attr_accessor :mkfs_options + + # @return [Array] + attr_accessor :mount_options + + # @return [Y2Storage::Filesystems::MountByType, nil] + attr_accessor :mount_by + + def initialize + @mount_options = [] + @mkfs_options = [] + end + + # Whether the given path is equivalent to {#path} + # + # This method is more robust than a simple string comparison, since it takes + # into account trailing slashes and similar potential problems. + # + # @param other_path [String, Pathname] + # @return [Boolean] + def path?(other_path) + return false unless path + + Pathname.new(other_path).cleanpath == Pathname.new(path).cleanpath + end + + # Whether the mount point is root + # @return [Boolean] + def root? + path?(ROOT_PATH) + end + + # @return [Boolean] + def btrfs_snapshots? + return false unless type&.fs_type&.is?(:btrfs) + + type.btrfs&.snapshots? + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/filesystem_type.rb b/service/lib/agama/storage/configs/filesystem_type.rb new file mode 100644 index 0000000000..6895f4e26e --- /dev/null +++ b/service/lib/agama/storage/configs/filesystem_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + class FilesystemType + # @return [Y2Storage::Filesystems::Type] + attr_accessor :fs_type + + # @return [Configs::Btrfs, nil] + attr_accessor :btrfs + end + end + end +end diff --git a/service/lib/agama/storage/configs/partition.rb b/service/lib/agama/storage/configs/partition.rb new file mode 100644 index 0000000000..fbe38f6335 --- /dev/null +++ b/service/lib/agama/storage/configs/partition.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + # Section of the configuration representing a partition + class Partition + # @return [Search, nil] + attr_accessor :search + + # @return [Y2Storage::PartitionId, nil] + attr_accessor :id + + # @return [Size, nil] can be nil for reused partitions + attr_accessor :size + + # @return [Encryption, nil] + attr_accessor :encryption + + # @return [Filesystem, nil] + attr_accessor :filesystem + + # Resolves the search if the partition specification contains any, associating a partition + # of the given device if possible + # + # @param partitionable [Y2Storage::Partitionable] scope for the search + # @param used_sids [Array] SIDs of the devices that are already associated to + # another partition definition, so they cannot be associated to this + def search_device(partitionable, used_sids) + return unless search + + search.find(partitionable.partitions, used_sids) + end + + # Device resulting from a previous call to {#search_device} + # + # @return [Y2Storage::Device, nil] + def found_device + search&.device + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/search.rb b/service/lib/agama/storage/configs/search.rb new file mode 100644 index 0000000000..2c99bf7f77 --- /dev/null +++ b/service/lib/agama/storage/configs/search.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + # Configuration used to match drives, partitions and other device definition with devices + # from the initial devicegraph + class Search + # Found device, if any + # @return [Y2Storage::Device, nil] + attr_reader :device + + # What to do if the search does not match with the expected number of devices + # @return [Symbol] :create, :skip or :error + attr_accessor :if_not_found + + # Constructor + def initialize + @if_not_found = :skip + end + + # Whether {#find} was already called + # + # @return [Boolean] + def resolved? + !!@resolved + end + + # Whether the section containing the search should be skipped + # + # @return [Boolean] + def skip_device? + resolved? && device.nil? && if_not_found == :skip + end + + # Resolve the search, associating the corresponding device to {#device} + # + # @param candidate_devs [Array] candidate devices + # @param used_sids [Array] SIDs of the devices that are already used elsewhere + def find(candidate_devs, used_sids) + devices = candidate_devs.reject { |d| used_sids.include?(d.sid) } + @resolved = true + @device = devices.min_by(&:name) + end + end + end + end +end diff --git a/service/lib/agama/storage/boot_settings.rb b/service/lib/agama/storage/configs/size.rb similarity index 65% rename from service/lib/agama/storage/boot_settings.rb rename to service/lib/agama/storage/configs/size.rb index 228668280e..5c3d408d84 100644 --- a/service/lib/agama/storage/boot_settings.rb +++ b/service/lib/agama/storage/configs/size.rb @@ -21,21 +21,27 @@ module Agama module Storage - # Class for configuring the boot settings of the Agama storage proposal. - class BootSettings - # Whether to configure partitions for booting. - # - # @return [Boolean] - attr_accessor :configure - alias_method :configure?, :configure + module Configs + # Size configuration. + class Size + # @return [Boolean] + attr_accessor :default - # Device to use for booting. - # - # @return [String, nil] nil means use installation device. - attr_accessor :device + # @return [Y2Storage::DiskSize, nil] + attr_accessor :min - def initialize - @configure = true + # @return [Y2Storage::DiskSize, nil] + attr_accessor :max + + # Constructor + def initialize + @default = true + end + + # @return [Boolean] + def default? + !!@default + end end end end diff --git a/service/lib/agama/storage/encryption_settings.rb b/service/lib/agama/storage/encryption_settings.rb index eb755d5b62..2d6e2748e9 100644 --- a/service/lib/agama/storage/encryption_settings.rb +++ b/service/lib/agama/storage/encryption_settings.rb @@ -35,7 +35,7 @@ class EncryptionSettings ].freeze private_constant :METHODS - # @!attribute encryption_password + # @!attribute password # Password to use when creating new encryption devices # @return [String, nil] nil if undetermined secret_attr :password diff --git a/service/lib/agama/storage/proposal_settings.rb b/service/lib/agama/storage/proposal_settings.rb index 8ec12201ae..595d8da953 100644 --- a/service/lib/agama/storage/proposal_settings.rb +++ b/service/lib/agama/storage/proposal_settings.rb @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/boot_settings" +require "agama/storage/configs/boot" require "agama/storage/device_settings" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings_conversions" @@ -34,9 +34,9 @@ class ProposalSettings # @return [DeviceSettings::Disk, DeviceSettings::NewLvmVg, DeviceSettings::ReusedLvmVg] attr_accessor :device - # Boot settings. + # Boot config. # - # @return [BootSettings] + # @return [Configs::Boot] attr_accessor :boot # Encryption settings. @@ -56,7 +56,7 @@ class ProposalSettings def initialize @device = DeviceSettings::Disk.new - @boot = BootSettings.new + @boot = Configs::Boot.new @encryption = EncryptionSettings.new @space = SpaceSettings.new @volumes = [] diff --git a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb b/service/lib/agama/storage/proposal_settings_conversions/from_json.rb index 6cd1a0b872..e709c98aa7 100644 --- a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb +++ b/service/lib/agama/storage/proposal_settings_conversions/from_json.rb @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/boot_settings" +require "agama/storage/configs/boot" require "agama/storage/device_settings" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings_reader" @@ -87,7 +87,7 @@ def boot_conversion boot_json = settings_json[:boot] return unless boot_json - Agama::Storage::BootSettings.new.tap do |boot_settings| + Agama::Storage::Configs::Boot.new.tap do |boot_settings| boot_settings.configure = boot_json[:configure] boot_settings.device = boot_json[:device] end diff --git a/service/lib/agama/storage/proposal_settings_reader.rb b/service/lib/agama/storage/proposal_settings_reader.rb index e0c038fd6f..90cdda64a4 100644 --- a/service/lib/agama/storage/proposal_settings_reader.rb +++ b/service/lib/agama/storage/proposal_settings_reader.rb @@ -19,6 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "agama/storage/proposal_settings" require "agama/storage/device_settings" require "agama/storage/space_settings" require "agama/storage/volume_templates_builder" diff --git a/service/lib/agama/storage/volume.rb b/service/lib/agama/storage/volume.rb index 023c4df78a..c1ce6e3614 100644 --- a/service/lib/agama/storage/volume.rb +++ b/service/lib/agama/storage/volume.rb @@ -22,7 +22,7 @@ require "forwardable" require "json" require "y2storage/disk_size" -require "agama/storage/btrfs_settings" +require "agama/storage/configs/btrfs" require "agama/storage/volume_conversions" require "agama/storage/volume_location" require "agama/storage/volume_outline" @@ -52,7 +52,7 @@ class Volume # # Only relevant if #fs_type is Btrfs # - # @return [BtrfsSettings] + # @return [Configs::Btrfs] attr_accessor :btrfs # @return [Array] @@ -91,7 +91,7 @@ def initialize(mount_path) @auto_size = false @min_size = Y2Storage::DiskSize.zero @max_size = Y2Storage::DiskSize.unlimited - @btrfs = BtrfsSettings.new + @btrfs = Configs::Btrfs.new @outline = VolumeOutline.new @location = VolumeLocation.new end diff --git a/service/lib/agama/storage/volume_templates_builder.rb b/service/lib/agama/storage/volume_templates_builder.rb index d955c691c7..062d94dcfe 100644 --- a/service/lib/agama/storage/volume_templates_builder.rb +++ b/service/lib/agama/storage/volume_templates_builder.rb @@ -21,9 +21,9 @@ require "pathname" require "y2storage" +require "agama/storage/configs/btrfs" require "agama/storage/volume" require "agama/storage/volume_outline" -require "agama/storage/btrfs_settings" module Agama module Storage @@ -102,7 +102,7 @@ def path_key(path) # Temporary method to avoid crashes if there is no default template def empty_data { - btrfs: BtrfsSettings.new, + btrfs: Configs::Btrfs.new, outline: VolumeOutline.new, mount_options: [], filesystem: Y2Storage::Filesystems::Type::EXT4 @@ -130,7 +130,7 @@ def values(data) def btrfs(data) btrfs_data = fetch(data, "btrfs", {}) - BtrfsSettings.new.tap do |btrfs| + Configs::Btrfs.new.tap do |btrfs| btrfs.snapshots = fetch(btrfs_data, "snapshots", false) btrfs.read_only = fetch(btrfs_data, "read_only", false) btrfs.default_subvolume = fetch(btrfs_data, "default_subvolume", "") diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb new file mode 100644 index 0000000000..1590074104 --- /dev/null +++ b/service/lib/y2storage/agama_proposal.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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 "yast" +require "y2storage/proposal" +require "y2storage/proposal/agama_searcher" +require "y2storage/proposal/agama_space_maker" +require "y2storage/proposal/agama_devices_planner" +require "y2storage/proposal/agama_devices_creator" +require "y2storage/proposal/planned_devices_handler" +require "y2storage/exceptions" +require "y2storage/planned" + +module Y2Storage + # Class to calculate a storage proposal for autoinstallation using Agama + # + # @example Creating a proposal from the current Agama configuration + # config = Agama::Storage::Config.new_from_json(config_json) + # proposal = Y2Storage::AgamaProposal.new(config) + # proposal.proposed? # => false + # proposal.devices # => nil + # proposal.planned_devices # => nil + # + # proposal.propose # Performs the calculation + # + # proposal.proposed? # => true + # proposal.devices # => Proposed layout + # + class AgamaProposal < Proposal::Base + include Proposal::PlannedDevicesHandler + + # @return [Agama::Storage::Config] + attr_reader :settings + + # @return [Array] List of found issues + attr_reader :issues_list + + # Constructor + # + # @param initial_settings [Agama::Storage::Config] Agama storage settings + # @param devicegraph [Devicegraph] starting point. If nil, then probed devicegraph + # will be used + # @param disk_analyzer [DiskAnalyzer] by default, the method will create a new one + # based on the initial devicegraph or will use the one from the StorageManager if + # starting from probed (i.e. 'devicegraph' argument is also missing) + # @param issues_list [Array e + raise NotBootableError, e.message + end + + # Removes partition tables from candidate devices with empty partition table + # + # @param devicegraph [Devicegraph] the graph gets modified + # @return [Array] sid of devices where partition table was deleted from + def remove_empty_partition_tables(devicegraph) + devices = drives_with_empty_partition_table(devicegraph) + devices.each(&:delete_partition_table) + devices.map(&:sid) + end + + # All candidate devices with an empty partition table + # + # @param devicegraph [Y2Storage::Devicegraph] + # @return [Array] + def drives_with_empty_partition_table(devicegraph) + devices = settings.drives.map { |d| device_for(d, devicegraph) }.compact + devices.select { |d| d.partition_table && d.partitions.empty? } + end + + # Planned partitions that will hold the given planned devices + # + # @return [Array] + def partitions_for_clean + # The current logic is quite trivial, but this is implemented as a separate method because + # some extra logic is expected in the future (eg. considering partitions on pre-existing + # RAIDs and more stuff). See the equivalent method at DevicegraphGenerator. + planned_devices.partitions + end + + # Configures SpaceMaker#protected_sids according to the given list of planned devices + def protect_sids + space_maker.protected_sids = planned_devices.all.select(&:reuse?).map(&:reuse_sid) + end + + # Creates the planned devices on a given devicegraph + # + # @param devicegraph [Devicegraph] the graph gets modified + def create_devices(devicegraph) + devices_creator = Proposal::AgamaDevicesCreator.new(devicegraph, issues_list) + names = settings.drives.map(&:found_device).compact.map(&:name) + protect_sids + result = devices_creator.populated_devicegraph(planned_devices, names, space_maker) + end + + # Equivalent device at the given devicegraph for the given configuration setting (eg. drive) + # + # @param drive [Agama::Storage::Configs::Drive] + # @param devicegraph [Devicegraph] + # @return [Device] + def device_for(drive, devicegraph) + return unless drive.found_device + + devicegraph.find_device(drive.found_device.sid) + end + end +end diff --git a/service/lib/y2storage/proposal/agama_device_planner.rb b/service/lib/y2storage/proposal/agama_device_planner.rb new file mode 100644 index 0000000000..e696a7d968 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_device_planner.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/planned" +require "agama/issue" + +module Y2Storage + module Proposal + # Base class used by Agama planners. + class AgamaDevicePlanner + include Yast::I18n + + # @!attribute [r] devicegraph + # Devicegraph to be used as starting point. + # @return [Devicegraph] + attr_reader :devicegraph + + # @!attribute [r] issues_list + # List of issues to register any found problem + # @return [Array] + attr_reader :issues_list + + # Constructor + # + # @param devicegraph [Devicegraph] see {#devicegraph} + # @param issues_list [Array] see {#issues_list} + def initialize(devicegraph, issues_list) + textdomain "agama" + + @devicegraph = devicegraph + @issues_list = issues_list + end + + # Planned devices according to the given settings. + # + # @return [Array] Array of planned devices. + def planned_devices(_setting) + raise NotImplementedError + end + + private + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [#encryption, #filesystem] + def configure_device(planned, settings) + configure_encryption(planned, settings.encryption) if settings.encryption + configure_filesystem(planned, settings.filesystem) if settings.filesystem + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::Filesystem] + def configure_filesystem(planned, settings) + planned.mount_point = settings.path + planned.mount_by = settings.mount_by + planned.fstab_options = settings.mount_options + planned.mkfs_options = settings.mkfs_options.join(",") + planned.label = settings.label + configure_filesystem_type(planned, settings.type) if settings.type + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::FilesystemType] + def configure_filesystem_type(planned, settings) + planned.filesystem_type = settings.fs_type + configure_btrfs(planned, settings.btrfs) if settings.btrfs + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::Btrfs] + def configure_btrfs(planned, settings) + # TODO: we need to discuss what to do with transactional systems and the read_only + # property. We are not sure whether those things should be configurable by the user. + # planned.read_only = settings.read_only? + planned.snapshots = settings.snapshots? + planned.default_subvolume = settings.default_subvolume + planned.subvolumes = settings.subvolumes + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::Encryption] + def configure_encryption(planned, settings) + planned.encryption_password = settings.password + planned.encryption_method = settings.method + planned.encryption_pbkdf = settings.pbkd_function + planned.encryption_label = settings.label + planned.encryption_cipher = settings.cipher + planned.encryption_key_size = settings.key_size + + check_encryption(planned) + end + + # @see #configure_encryption + def check_encryption(dev) + issues_list << issue_missing_enc_password(dev) if missing_enc_password?(dev) + issues_list << issue_available_enc_method(dev) unless dev.encryption_method.available? + issues_list << issue_wrong_enc_method(dev) unless supported_enc_method?(dev) + end + + # @see #check_encryption + def missing_enc_password?(planned) + return false unless planned.encryption_method&.password_required? + + planned.encryption_password.nil? || planned.encryption_password.empty? + end + + # @see #check_encryption + def supported_enc_method?(planned) + planned.supported_encryption_method?(planned.encryption_method) + end + + # @see #check_encryption + def issue_missing_enc_password(planned) + msg = format( + # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like + # 'luks1' or 'random_swap'). + _("No passphrase provided (required for using the method '%{crypt_method}')."), + crypt_method: planned.encryption_method.id.to_s + ) + encryption_issue(msg) + end + + # @see #check_encryption + def issue_available_enc_method(planned) + msg = format( + # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like + # 'luks1' or 'random_swap'). + _("Encryption method '%{crypt_method}' is not available in this system."), + crypt_method: planned.encryption_method.id.to_s + ) + encryption_issue(msg) + end + + # @see #check_encryption + def issue_wrong_enc_method(planned) + msg = format( + # TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like + # 'luks1' or 'random_swap'). + _("'%{crypt_method}' is not a suitable method to encrypt the device."), + crypt_method: planned.encryption_method.id.to_s + ) + encryption_issue(msg) + end + + # @see #check_encryption + def encryption_issue(message) + Agama::Issue.new( + message, + source: Agama::Issue::Source::CONFIG, + severity: Agama::Issue::Severity::ERROR + ) + end + + # @param planned [Planned::Partition] + # @param settings [Agama::Storage::Configs::Size] + def configure_size(planned, settings) + planned.min_size = settings.min + planned.max_size = settings.max + planned.weight = 100 + end + + # @param planned [Planned::Disk] + # @param settings [Agama::Storage::Configs::Drive] + def configure_partitions(planned, settings) + planned.partitions = settings.partitions.map do |partition_settings| + planned_partition(partition_settings).tap { |p| p.disk = settings.found_device.name } + end + end + + # @param settings [Agama::Storage::Configs::Partition] + # @return [Planned::Partition] + def planned_partition(settings) + Planned::Partition.new(nil, nil).tap do |planned| + planned.partition_id = settings.id + configure_device(planned, settings) + configure_size(planned, settings.size) + end + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_devices_creator.rb b/service/lib/y2storage/proposal/agama_devices_creator.rb new file mode 100644 index 0000000000..1c273535a1 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_devices_creator.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +# Copyright (c) [2017-2020] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/proposal/agama_lvm_helper" +require "y2storage/exceptions" + +module Y2Storage + module Proposal + # Class to create and reuse devices during the Agama proposal + class AgamaDevicesCreator + include Yast::Logger + + # @return [Array] List of found issues + attr_reader :issues_list + + # Constructor + # + # @param original_graph [Devicegraph] Devicegraph to be used as starting point + # @param issues_list [Array] List of issues to register the problems + # found during devices creation + def initialize(original_graph, issues_list) + @original_graph = original_graph + @issues_list = issues_list + end + + # Devicegraph including all the specified planned devices + # + # @param planned_devices [Planned::DevicesCollection] Devices to create/reuse + # @param disk_names [Array] Disks to consider + # @param space_maker [SpaceMaker] + # + # @return [CreatorResult] Result with new devicegraph in which all the + # planned devices have been allocated + def populated_devicegraph(planned_devices, disk_names, space_maker) + # Process planned partitions + log.info "planned devices = #{planned_devices.to_a.inspect}" + log.info "disk names = #{disk_names.inspect}" + + reset + + @planned_devices = planned_devices + @disk_names = disk_names + @space_maker = space_maker + + process_devices + end + + protected + + # @return [Devicegraph] Original devicegraph + attr_reader :original_graph + + # @return [Planned::DevicesCollection] Devices to create/reuse + attr_reader :planned_devices + + # @return [Array] Disks to consider + attr_reader :disk_names + + # @return [SpaceMaker] space maker to use during operation + attr_reader :space_maker + + # @return [Proposal::CreatorResult] Current result containing the devices that have been + # created + attr_reader :creator_result + + # @return [Devicegraph] Current devicegraph + attr_reader :devicegraph + + private + + # Sets the current creator result + # + # The current devicegraph is properly updated. + # + # @param result [Proposal::CreatorResult] + def creator_result=(result) + @creator_result = result + @devicegraph = result.devicegraph + end + + # Resets values before create devices + # + # @see #populated_devicegraph + def reset + @creator_result = nil + @devicegraph = original_graph.duplicate + end + + # Reuses and creates planned devices + # + # @return [CreatorResult] Result with new devicegraph in which all the + # planned devices have been allocated + def process_devices + process_existing_partitionables + creator_result + end + + # @see #process_devices + def process_existing_partitionables + partitions = partitions_for_existing(planned_devices) + + # lvm_lvs = system_lvm_over_existing? ? system_lvs(planned_devices) : [] + lvm_lvs = [] + lvm_helper = AgamaLvmHelper.new(lvm_lvs) + + # Check whether there is any chance of getting an unwanted order for the planned partitions + # within a disk + space_result = provide_space(partitions, original_graph, lvm_helper) + + partition_creator = PartitionCreator.new(space_result[:devicegraph]) + self.creator_result = + partition_creator.create_partitions(space_result[:partitions_distribution]) + + # This may be here or before create_partitions. + # + # What about resizing if needed? + # Likely shrinking is fine and should be always handled at the SpaceMaker. + # But I'm not so sure if growing is so fine (we may need to make some space first). + # I don't think we have the growing case covered by SpaceMaker, the distribution + # calculator, etc. + # + planned_devices.each do |planned| + next unless planned.reuse? + + planned.reuse!(devicegraph) + end + + # This may be unexpected if the storage configuration provided by the user includes + # carefully crafted mount options but may be needed in weird situations for more automated + # proposals. Let's re-evaluate over time. + devicegraph.mount_points.each(&:adjust_mount_options) + end + + # @see #process_existing_partitionables + def provide_space(planned_partitions, devicegraph, lvm_helper) + result = space_maker.provide_space(devicegraph, planned_partitions, lvm_helper) + log.info "Found enough space" + result + end + + # @see #process_existing_partitionables + def partitions_for_existing(planned_devices) + # Maybe in the future this can include partitions on top of existing MDs + # NOTE: simplistic implementation + planned_devices.partitions.reject(&:reuse?) + end + + # Formats and/or mounts the disk-like block devices + # + # XEN partitions (StrayBlkDevice) are intentionally left out for now + # + # Add planned disks to reuse list so they can be considered for lvm and raids later on. + def process_disk_like_devs + # Do we do something about SpaceMaker here? I assume it was already done as mandatory + planned_devs = planned_devices.select { |d| d.is_a?(Planned::Disk) } + planned_devs.each { |d| d.reuse!(devicegraph) } + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_devices_planner.rb b/service/lib/y2storage/proposal/agama_devices_planner.rb new file mode 100644 index 0000000000..4476f66cc1 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_devices_planner.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/proposal/agama_drive_planner" +require "y2storage/planned" + +module Y2Storage + module Proposal + # Devices planner for Agama. + class AgamaDevicesPlanner + include Yast::Logger + + # Settings used to calculate the planned devices. + # + # @return [Agama::Storage::Config] + attr_reader :settings + + # @param settings [Agama::Storage::Config] + # @param issues_list [Array] + def initialize(settings, issues_list) + @settings = settings + @issues_list = issues_list + end + + # List of devices that need to be created to satisfy the settings. Does not include + # devices needed for booting. + # + # For the time being, this implements only stuff coming from partitition elements within + # drive elements. + # + # @param devicegraph [Devicegraph] + # @return [Planned::DevicesCollection] + def initial_planned_devices(devicegraph) + # In the future this will also include planned devices that are equivalent to + # those typically generated by the Guided Proposal. For those, note that: + # - For dedicated VGs it creates a Planned VG containing a Planned LV, but no PVs + # - For LVM volumes it create a Planned LV but associated to no planned VG + # - For partition volumes, it creates a planned partition, of course + + devs = settings.drives.flat_map { |d| planned_for_drive(d, devicegraph) }.compact + Planned::DevicesCollection.new(devs) + end + + protected + + # @return [Array] List to register any found issue + attr_reader :issues_list + + # @see #initial_planned_devices + def planned_for_drive(drive, devicegraph) + planner = AgamaDrivePlanner.new(devicegraph, issues_list) + planner.planned_devices(drive) + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_drive_planner.rb b/service/lib/y2storage/proposal/agama_drive_planner.rb new file mode 100644 index 0000000000..c8f0be0203 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_drive_planner.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/proposal/agama_device_planner" + +module Y2Storage + module Proposal + # Drive planner for Agama. + class AgamaDrivePlanner < AgamaDevicePlanner + # @param settings [Agama::Storage::Configs::Drive] + # @return [Array] + def planned_devices(settings) + [planned_drive(settings)] + end + + private + + # Support for StrayBlkDevice is intentionally left out. As far as we know, the plan + # for SLE/Leap 16 is to drop XEN support + # + # @param settings [Agama::Storage::Configs::Drive] + # @return [Planned::Disk] + def planned_drive(settings) + return planned_full_drive(settings) unless settings.partitions? + + planned_partitioned_drive(settings) + end + + # @param settings [Agama::Storage::Configs::Drive] + # @return [Planned::Disk] + def planned_full_drive(settings) + Planned::Disk.new.tap do |planned| + configure_drive(planned, settings) + configure_device(planned, settings) + end + end + + # @param settings [Agama::Storage::Configs::Drive] + # @return [Planned::Disk] + def planned_partitioned_drive(settings) + Planned::Disk.new.tap do |planned| + configure_drive(planned, settings) + configure_partitions(planned, settings) + end + end + + # @param planned [Planned::Disk] + # @param settings [Agama::Storage::Configs::Drive] + def configure_drive(planned, settings) + planned.assign_reuse(settings.found_device) + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_lvm_helper.rb b/service/lib/y2storage/proposal/agama_lvm_helper.rb new file mode 100644 index 0000000000..80784a13b4 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_lvm_helper.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/proposal/lvm_helper" +require "y2storage/proposal_settings" + +module Y2Storage + module Proposal + # LVM helper for Agama. + class AgamaLvmHelper < LvmHelper + # Constructor + def initialize(lvm_lvs) + super(lvm_lvs, guided_settings) + end + + private + + # Method used by the constructor to somehow simulate a typical Guided Proposal + def guided_settings + # Despite the "current_product" part in the name of the constructor, it only applies + # generic default values that are independent of the product (there is no YaST + # ProductFeatures mechanism in place). + Y2Storage::ProposalSettings.new_for_current_product.tap do |target| + target.lvm_vg_strategy = :use_needed + target.lvm_vg_reuse = false + # TODO: Add encryption options. + target.encryption_password = nil + # target.encryption_pbkdf + # target.encryption_method + end + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_searcher.rb b/service/lib/y2storage/proposal/agama_searcher.rb new file mode 100644 index 0000000000..614f57d38e --- /dev/null +++ b/service/lib/y2storage/proposal/agama_searcher.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] 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/issue" + +module Y2Storage + module Proposal + # Auxiliary class to handle the 'search' elements within a storage configuration + class AgamaSearcher + include Yast::Logger + include Yast::I18n + + # Constructor + def initialize + textdomain "agama" + end + + # Resolve all the 'search' elements within a given configuration + # + # The second argument (the storage configuration) gets modified in several ways: + # + # - All its 'search' elements get resolved, associating devices from the devicegraph + # (first argument) if some is found. + # - Some device definitions can get removed if configured to be skipped in absence of a + # corresponding device + # + # The third argument (the list of issues) gets modified by adding any found problem. + # + # @param devicegraph [Devicegraph] used to find the corresponding devices that will get + # associated to each search element + # @param settings [Agama::Storage::Config] storage configuration containing device definitions + # like drives, volume groups, etc. + # @param issues_list [Array] + def search(devicegraph, settings, issues_list) + @sids = [] + settings.drives.each do |drive| + drive.search_device(devicegraph, @sids) + process_element(drive, settings.drives, issues_list) + + next unless drive.found_device && drive.partitions? + + drive.partitions.each do |part| + next unless part.search + + part.search_device(drive.found_device, @sids) + process_element(part, drive.partitions, issues_list) + end + end + end + + private + + # @see #search + def process_element(element, collection, issues_list) + found = element.found_device + if found + @sids << found.sid + else + issues_list << not_found_issue(element) + collection.delete(element) if element.search.skip_device? + end + end + + # Issue generated if a corresponding device is not found for the given element + # + # @param element [Agama::Storage::Configs::Drive, Agama::Storage::Configs::Partition] + # @return [Agama::Issue] + def not_found_issue(element) + Agama::Issue.new( + issue_message(element), + source: Agama::Issue::Source::CONFIG, + severity: issue_severity(element.search) + ) + end + + # @see #not_found_issue + def issue_message(element) + if element.is_a?(Agama::Storage::Configs::Drive) + if element.search.skip_device? + _("No device found for an optional drive") + else + _("No device found for a mandatory drive") + end + elsif element.search.skip_device? + _("No device found for an optional partition") + else + _("No device found for a mandatory partition") + end + end + + # @see #not_found_issue + def issue_severity(search) + return Agama::Issue::Severity::WARN if search.skip_device? + + Agama::Issue::Severity::ERROR + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_space_maker.rb b/service/lib/y2storage/proposal/agama_space_maker.rb new file mode 100644 index 0000000000..bb14a399d3 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_space_maker.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2storage/proposal/space_maker" +require "y2storage/proposal_settings" + +module Y2Storage + module Proposal + # Space maker for Agama. + class AgamaSpaceMaker < SpaceMaker + # Constructor + def initialize(disk_analyzer, settings) + super(disk_analyzer, guided_settings(settings)) + end + + private + + # Method used by the constructor to somehow simulate a typical Guided Proposal + def guided_settings(settings) + # Despite the "current_product" part in the name of the constructor, it only applies + # generic default values that are independent of the product (there is no YaST + # ProductFeatures mechanism in place). + Y2Storage::ProposalSettings.new_for_current_product.tap do |target| + target.space_settings.strategy = :bigger_resize + target.space_settings.actions = [] + + boot_device = settings.boot_device + + target.root_device = boot_device + target.candidate_devices = [boot_device].compact + end + end + end + end +end diff --git a/service/package/gem2rpm.yml b/service/package/gem2rpm.yml index aab4071193..6acbef4133 100644 --- a/service/package/gem2rpm.yml +++ b/service/package/gem2rpm.yml @@ -38,7 +38,7 @@ Requires: yast2-iscsi-client >= 4.5.7 Requires: yast2-network Requires: yast2-proxy - Requires: yast2-storage-ng >= 5.0.13 + Requires: yast2-storage-ng >= 5.0.16 Requires: yast2-users %ifarch s390 s390x Requires: yast2-s390 >= 4.6.4 diff --git a/service/test/agama/storage/config_conversions/from_json_test.rb b/service/test/agama/storage/config_conversions/from_json_test.rb new file mode 100644 index 0000000000..c648874d6d --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_test.rb @@ -0,0 +1,660 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../test_helper" +require "agama/storage/config_conversions/from_json" +require "agama/config" +require "y2storage/encryption_method" +require "y2storage/pbkd_function" + +describe Agama::Storage::ConfigConversions::FromJSON do + subject { described_class.new(config_json, product_config: product_config) } + + let(:product_config) { Agama::Config.new(product_data) } + + let(:product_data) do + { + "storage" => { + "lvm" => false, + "space_policy" => "delete", + "encryption" => { + "method" => "luks2", + "pbkd_function" => "argon2id" + }, + "volumes" => ["/", "swap"], + "volume_templates" => [ + { + "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, + "btrfs" => { + "snapshots" => true, "default_subvolume" => "@", + "subvolumes" => ["home", "opt", "root", "srv"] + }, + "outline" => { + "required" => true, "snapshots_configurable" => true, + "auto_size" => { + "base_min" => "5 GiB", "base_max" => "10 GiB", + "min_fallback_for" => ["/home"], "max_fallback_for" => ["/home"], + "snapshots_increment" => "300%" + } + } + }, + { + "mount_path" => "/home", "size" => { "auto" => false, "min" => "5 GiB" }, + "filesystem" => "xfs", "outline" => { "required" => false } + }, + { + "mount_path" => "swap", "filesystem" => "swap", + "outline" => { "required" => false } + }, + { "mount_path" => "", "filesystem" => "ext4", + "size" => { "min" => "100 MiB" } } + ] + } + } + end + + before do + # Speed up tests by avoding real check of TPM presence. + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + end + + describe "#convert" do + using Y2Storage::Refinements::SizeCasts + + context "with an empty JSON configuration" do + let(:config_json) { {} } + + it "generates a storage configuration" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Config) + end + + it "calculates default boot settings" 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 eq nil + end + + # @todo Generate default drive/LVM from product descripton. + it "does not include any device in the configuration" do + config = subject.convert + expect(config.drives).to be_empty + end + end + + context "with some drives and boot configuration at JSON" do + let(:config_json) do + { + boot: { configure: true, device: "/dev/sdb" }, + drives: [ + { + ptableType: "gpt", + partitions: [{ filesystem: { path: "/" } }] + } + ] + } + end + + it "generates a storage configuration" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Config) + end + + it "calculates the corresponding boot settings" 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 eq "/dev/sdb" + end + + it "includes the corresponding drives" do + config = subject.convert + expect(config.drives.size).to eq 1 + drive = config.drives.first + expect(drive).to be_a(Agama::Storage::Configs::Drive) + expect(drive.ptable_type).to eq Y2Storage::PartitionTables::Type::GPT + expect(drive.partitions.size).to eq 1 + partition = drive.partitions.first + expect(partition.filesystem.path).to eq "/" + end + end + + context "omitting sizes for the partitions" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { filesystem: { path: "/", type: { btrfs: { snapshots: false } } } }, + { filesystem: { path: "/home" } }, + { filesystem: { path: "/opt" } }, + { filesystem: { path: "swap" } } + ] + } + ] + } + end + + it "uses default sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 5.GiB, max: 10.GiB) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + size: have_attributes(default: true, min: 5.GiB, + max: Y2Storage::DiskSize.unlimited) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: true, min: 100.MiB, + max: Y2Storage::DiskSize.unlimited) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + size: have_attributes( + default: true, min: Y2Storage::DiskSize.zero, max: Y2Storage::DiskSize.unlimited + ) + ) + ) + end + end + + context "setting fixed sizes for the partitions" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { filesystem: { path: "/" }, size: "10 GiB" }, + { filesystem: { path: "/home" }, size: "6Gb" }, + { filesystem: { path: "/opt" }, size: 3221225472 }, + { filesystem: { path: "swap" }, size: "6 Gib" } + ] + } + ] + } + end + + it "sets both min and max to the same value if a string is used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: false, min: 10.GiB, max: 10.GiB) + ) + ) + end + + it "sets both min and max to the same value if an integer is used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: false, min: 3.GiB, max: 3.GiB) + ) + ) + end + + it "makes a difference between SI units and binary units" do + config = subject.convert + partitions = config.drives.first.partitions + home_size = partitions.find { |p| p.filesystem.path == "/home" }.size + swap_size = partitions.find { |p| p.filesystem.path == "swap" }.size + expect(swap_size.min.to_i).to eq 6 * 1024 * 1024 * 1024 + expect(home_size.max.to_i).to eq 6 * 1000 * 1000 * 1000 + end + end + + # Note the min is mandatory + context "specifying size limits for the partitions" do + RSpec.shared_examples "size limits" do + it "sets both min and max limits as requested if strings are used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + size: have_attributes(default: false, min: 6.GiB, max: 9.GiB) + ) + ) + end + + it "makes a difference between SI units and binary units" do + config = subject.convert + partitions = config.drives.first.partitions + home_size = partitions.find { |p| p.filesystem.path == "/home" }.size + swap_size = partitions.find { |p| p.filesystem.path == "swap" }.size + expect(home_size.min.to_i).to eq 6 * 1024 * 1024 * 1024 + expect(swap_size.max.to_i).to eq 6 * 1000 * 1000 * 1000 + end + + it "sets both min and max limits as requested if numbers are used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + size: have_attributes(default: false, min: 1.GiB) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: false, min: 1.GiB, max: 3.GiB) + ) + ) + end + + it "uses unlimited for the omitted max sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: false, min: 3.GiB, + max: Y2Storage::DiskSize.unlimited) + ) + ) + end + end + + context "using a hash" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/", type: { btrfs: { snapshots: false } } }, + size: { min: "3 GiB" } + }, + { + filesystem: { path: "/home" }, + size: { min: "6 GiB", max: "9 GiB" } + }, + { + filesystem: { path: "swap" }, + size: { min: 1073741824, max: "6 GB" } + }, + { + filesystem: { path: "/opt" }, + size: { min: "1073741824", max: 3221225472 } + } + ] + } + ] + } + end + + include_examples "size limits" + end + + context "using an array" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/", type: { btrfs: { snapshots: false } } }, + size: ["3 GiB"] + }, + { + filesystem: { path: "/home" }, + size: ["6 GiB", "9 GiB"] + }, + { + filesystem: { path: "swap" }, + size: [1073741824, "6 GB"] + }, + { + filesystem: { path: "/opt" }, + size: ["1073741824", 3221225472] + } + ] + } + ] + } + end + + include_examples "size limits" + end + end + + context "using 'default' as size for some partitions and size limit for others" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/", size: "default" } + }, + { + filesystem: { path: "/opt" }, + size: { min: "6 GiB", max: "22 GiB" } + } + ] + } + ] + } + end + + it "uses the appropriate sizes for each partition" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 40.GiB, + max: Y2Storage::DiskSize.unlimited) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: false, min: 6.GiB, max: 22.GiB) + ) + ) + end + end + + context "using 'default' as size for some partitions and size limit for others" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/", size: "default" } + }, + { + filesystem: { path: "/opt" }, + size: { min: "6 GiB", max: "22 GiB" } + } + ] + } + ] + } + end + + it "uses the appropriate sizes for each partition" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 40.GiB, + max: Y2Storage::DiskSize.unlimited) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: false, min: 6.GiB, max: 22.GiB) + ) + ) + end + end + + context "using 'default' for a partition that is fallback for others" do + let(:config_json) { { drives: [{ partitions: partitions }] } } + let(:root) do + { filesystem: { path: "/", type: { btrfs: { snapshots: false } } }, size: "default" } + end + let(:partitions) { [root] + other } + + context "if the other partitions are ommitted" do + let(:other) { [] } + + it "sums all the fallback sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 10.GiB, + max: Y2Storage::DiskSize.unlimited) + ) + ) + end + end + + context "if the other partitions are included (even with non-exact name)" do + let(:other) { [{ filesystem: { path: "/home/" } }] } + + it "ignores the fallback sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 5.GiB, max: 10.GiB) + ) + ) + end + end + end + + context "specifying a filesystem for a drive" do + let(:config_json) do + { + drives: [{ filesystem: filesystem }] + } + end + + context "if the filesystem specification only contains a path" do + let(:filesystem) { { path: "/" } } + + it "uses the default type and btrfs attributes for that path" do + config = subject.convert + filesystem = config.drives.first.filesystem + expect(filesystem.type.fs_type).to eq Y2Storage::Filesystems::Type::BTRFS + expect(filesystem.type.btrfs.snapshots).to eq true + expect(filesystem.type.btrfs.default_subvolume).to eq "@" + expect(filesystem.type.btrfs.subvolumes.map(&:path)).to eq ["home", "opt", "root", "srv"] + end + end + + context "if the filesystem specification contains some btrfs settings" do + let(:filesystem) do + { path: "/", + type: { btrfs: { snapshots: false, default_subvolume: "", subvolumes: ["tmp"] } } } + end + + it "uses the specified btrfs attributes" do + config = subject.convert + filesystem = config.drives.first.filesystem + expect(filesystem.type.fs_type).to eq Y2Storage::Filesystems::Type::BTRFS + expect(filesystem.type.btrfs.snapshots).to eq false + # TODO: none of the following attributes are specified at the schema. Intentional? + # expect(filesystem.type.btrfs.default_subvolume).to eq "" + # expect(filesystem.type.btrfs.subvolumes.map(&:path)).to eq ["tmp"] + end + end + end + + context "configuring partial information for several mount points" do + let(:config_json) { { drives: [{ partitions: partitions }] } } + let(:partitions) do + [ + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } }, + { filesystem: { path: "/opt" } } + ] + end + + it "configures the filesystem types according to the product configuration" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes( + path: "/", type: have_attributes(fs_type: Y2Storage::Filesystems::Type::BTRFS) + ) + ), + an_object_having_attributes( + filesystem: have_attributes( + path: "swap", type: have_attributes(fs_type: Y2Storage::Filesystems::Type::SWAP) + ) + ), + an_object_having_attributes( + filesystem: have_attributes( + path: "/opt", type: have_attributes(fs_type: Y2Storage::Filesystems::Type::EXT4) + ) + ) + ) + end + end + + context "when some partition is configured to be encrypted" do + let(:config_json) do + { + drives: [{ partitions: partitions }] + } + end + + let(:partitions) do + [ + { + id: "linux", size: { min: "10 GiB" }, + filesystem: { type: "xfs", path: "/home" }, + encryption: encryption_home + }, + { + size: { min: "2 GiB" }, + filesystem: { type: "swap", path: "swap" }, + encryption: encryption_swap + } + ] + end + + let(:encryption_home) do + { luks2: { password: "notsecret", keySize: 256 } } + end + + let(:encryption_swap) { nil } + + it "sets the encryption settings for the corresponding partition" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + encryption: have_attributes( + password: "notsecret", method: Y2Storage::EncryptionMethod::LUKS2, key_size: 256 + ) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + encryption: nil + ) + ) + end + + context "if only the password is provided" do + let(:encryption_home) { { luks2: { password: "notsecret" } } } + let(:encryption_swap) { nil } + + it "uses the default derivation function" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + encryption: have_attributes( + password: "notsecret", + method: Y2Storage::EncryptionMethod::LUKS2, + pbkd_function: Y2Storage::PbkdFunction::ARGON2ID + ) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + encryption: nil + ) + ) + end + end + + context "if random encryption is configured for swap" do + let(:encryption_home) { nil } + let(:encryption_swap) { "random_swap" } + + it "sets the corresponding configuration" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + encryption: nil + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + encryption: have_attributes( + password: nil, + label: nil, + cipher: nil, + method: Y2Storage::EncryptionMethod::RANDOM_SWAP + ) + ) + ) + end + end + end + + context "when the id of some partition is specified" do + let(:config_json) do + { + drives: [{ partitions: partitions }] + } + end + + let(:partitions) do + [ + { + id: "Esp", size: { min: "10 GiB" }, + filesystem: { type: "xfs", path: "/home" } + }, + { + size: { min: "2 GiB" }, + filesystem: { type: "swap", path: "swap" } + } + ] + end + + it "configures the corresponding id" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + id: Y2Storage::PartitionId::ESP + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + id: nil + ) + ) + end + end + end +end diff --git a/service/test/y2storage/agama_proposal_test.rb b/service/test/y2storage/agama_proposal_test.rb new file mode 100644 index 0000000000..5af850cdac --- /dev/null +++ b/service/test/y2storage/agama_proposal_test.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../agama/storage/storage_helpers" +require "agama/config" +require "agama/storage/config" +require "y2storage/agama_proposal" + +describe Y2Storage::AgamaProposal do + include Agama::RSpec::StorageHelpers + + before do + mock_storage(devicegraph: "empty-hd-50GiB.yaml") + end + + subject(:proposal) do + described_class.new(initial_settings, issues_list: issues_list) + end + let(:initial_settings) do + Agama::Storage::Config.new.tap do |settings| + settings.drives = drives + end + end + let(:issues_list) { [] } + let(:drives) { [drive0] } + let(:drive0) { Agama::Storage::Configs::Drive.new.tap { |d| d.partitions = partitions0 } } + let(:partitions0) { [root_partition] } + let(:root_partition) do + Agama::Storage::Configs::Partition.new.tap do |part| + part.filesystem = Agama::Storage::Configs::Filesystem.new.tap do |fs| + fs.path = "/" + fs.type = Agama::Storage::Configs::FilesystemType.new.tap do |type| + type.fs_type = Y2Storage::Filesystems::Type::BTRFS + end + end + part.size = Agama::Storage::Configs::Size.new.tap do |size| + size.min = Y2Storage::DiskSize.GiB(8.5) + size.max = Y2Storage::DiskSize.unlimited + end + end + end + + describe "#propose" do + context "when only the root partition is specified" do + context "if no configuration about boot devices is specified" do + it "proposes to create the root device and the boot-related partition" do + proposal.propose + partitions = proposal.devices.partitions + expect(partitions.size).to eq 2 + expect(partitions.first.id).to eq Y2Storage::PartitionId::BIOS_BOOT + root_part = partitions.last + expect(root_part.size).to be > Y2Storage::DiskSize.GiB(49) + root_fs = root_part.filesystem + expect(root_fs.root?).to eq true + expect(root_fs.type.is?(:btrfs)).to eq true + end + end + + context "if no boot devices should be created" do + before do + initial_settings.boot = Agama::Storage::Configs::Boot.new.tap { |b| b.configure = false } + end + + it "proposes to create only the root device" do + proposal.propose + partitions = proposal.devices.partitions + expect(partitions.size).to eq 1 + root_part = partitions.first + expect(root_part.id).to eq Y2Storage::PartitionId::LINUX + expect(root_part.size).to be > Y2Storage::DiskSize.GiB(49) + root_fs = root_part.filesystem + expect(root_fs.root?).to eq true + expect(root_fs.type.is?(:btrfs)).to eq true + end + end + end + + context "when a partition table type is specified for a drive" do + let(:drive0) do + Agama::Storage::Configs::Drive.new.tap do |drive| + drive.partitions = partitions0 + drive.ptable_type = Y2Storage::PartitionTables::Type::MSDOS + end + end + + it "tries to propose a partition table of the requested type" do + proposal.propose + ptable = proposal.devices.disks.first.partition_table + expect(ptable.type).to eq Y2Storage::PartitionTables::Type::MSDOS + end + + it "honors the partition table type if possible when calculating the boot partitions" do + proposal.propose + partitions = proposal.devices.partitions + expect(partitions.map(&:id)).to_not include Y2Storage::PartitionId::BIOS_BOOT + end + end + + context "when encrypting some devices" do + let(:partitions0) { [root_partition, home_partition] } + + let(:home_partition) do + Agama::Storage::Configs::Partition.new.tap do |part| + part.filesystem = Agama::Storage::Configs::Filesystem.new.tap do |fs| + fs.path = "/home" + fs.type = Agama::Storage::Configs::FilesystemType.new.tap do |type| + type.fs_type = Y2Storage::Filesystems::Type::EXT4 + end + end + part.size = Agama::Storage::Configs::Size.new.tap do |size| + size.min = Y2Storage::DiskSize.GiB(10) + size.max = Y2Storage::DiskSize.unlimited + end + part.encryption = home_encryption + end + end + + let(:home_encryption) do + Agama::Storage::Configs::Encryption.new.tap do |enc| + enc.password = "notSecreT" + enc.method = encryption_method + end + end + + let(:encryption_method) { Y2Storage::EncryptionMethod::LUKS2 } + let(:available?) { true } + + before do + allow(encryption_method).to receive(:available?).and_return(available?) if encryption_method + end + + context "if the encryption settings contain all the detailed information" do + let(:home_encryption) do + Agama::Storage::Configs::Encryption.new.tap do |enc| + enc.password = "notSecreT" + enc.method = encryption_method + enc.pbkd_function = Y2Storage::PbkdFunction::ARGON2I + enc.label = "luks_label" + enc.cipher = "aes-xts-plain64" + enc.key_size = 512 + end + end + + it "proposes the right encryption layer" do + proposal.propose + partition = proposal.devices.partitions.find do |part| + part.blk_filesystem&.mount_path == "/home" + end + expect(partition.encrypted?).to eq true + expect(partition.encryption).to have_attributes( + method: Y2Storage::EncryptionMethod::LUKS2, + password: "notSecreT", + pbkdf: Y2Storage::PbkdFunction::ARGON2I, + label: "luks_label", + cipher: "aes-xts-plain64", + # libstorage-ng uses bytes instead of bits to represent the key size, contrary to + # all LUKS documentation and to cryptsetup + key_size: 64 + ) + end + end + + context "if the encryption method is not available for this system" do + let(:available?) { false } + + it "aborts the proposal process" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "reports the corresponding error" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /method 'luks2' is not available/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + + context "if the encryption method is not available for this system" do + let(:encryption_method) { Y2Storage::EncryptionMethod::RANDOM_SWAP } + + it "aborts the proposal process" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "reports the corresponding error" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /'random_swap' is not a suitable method/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + + context "if the method requires a password but none is provided" do + let(:home_encryption) do + Agama::Storage::Configs::Encryption.new.tap do |enc| + enc.method = encryption_method + end + end + + it "aborts the proposal process" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "reports the corresponding error" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /No passphrase provided/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + + context "when there are more drives than disks in the system" do + let(:drives) { [drive0, drive1] } + let(:drive1) do + Agama::Storage::Configs::Drive.new.tap do |drive| + drive.search = Agama::Storage::Configs::Search.new.tap do |search| + search.if_not_found = if_not_found + end + end + end + + context "if if_not_found is set to :skip for the surplus drive" do + let(:if_not_found) { :skip } + + it "calculates a proposal if possible" do + proposal.propose + expect(proposal.failed?).to eq false + end + + it "registers a non-critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /optional drive/, + severity: Agama::Issue::Severity::WARN + ) + end + end + + context "if if_not_found is set to :error for the surplus drive" do + let(:if_not_found) { :error } + + it "aborts the proposal" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "registers a critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /mandatory drive/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + + context "when searching for a non-existent partition" do + let(:partitions0) { [root_partition, existing_partition] } + let(:existing_partition) do + Agama::Storage::Configs::Partition.new.tap do |part| + part.search = Agama::Storage::Configs::Search.new.tap do |search| + search.if_not_found = if_not_found + end + end + end + + context "if if_not_found is set to :skip" do + let(:if_not_found) { :skip } + + it "calculates a proposal if possible" do + proposal.propose + expect(proposal.failed?).to eq false + end + + it "registers a non-critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /optional partition/, + severity: Agama::Issue::Severity::WARN + ) + end + end + + context "if if_not_found is set to :error" do + let(:if_not_found) { :error } + + it "aborts the proposal" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "registers a critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /mandatory partition/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + end +end