diff --git a/rust/agama-lib/share/examples/storage/drives.json b/rust/agama-lib/share/examples/storage/drives.json index 636cfac366..a462ae4fa1 100644 --- a/rust/agama-lib/share/examples/storage/drives.json +++ b/rust/agama-lib/share/examples/storage/drives.json @@ -67,9 +67,7 @@ "deleteIfNeeded": true }, { - "search": { - "ifNotFound": "skip" - }, + "search": "*", "delete": true }, { diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index d7a0cd13a5..7f24d0ffc0 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -1029,6 +1029,11 @@ "type": "string", "examples": ["/dev/vda", "/dev/disk/by-id/ata-WDC_WD3200AAKS-75L9"] }, + "searchAll": { + "title": "Search all devices", + "description": "Shortcut to match all devices if there is any (equivalent to specify no conditions and to skip the entry if no device is found).", + "const": "*" + }, "searchByName": { "title": "Search by name condition", "type": "object", @@ -1042,6 +1047,9 @@ }, "search": { "anyOf": [ + { + "$ref": "#/$defs/searchAll" + }, { "$ref": "#/$defs/searchName" }, diff --git a/service/lib/agama/storage/config_conversions.rb b/service/lib/agama/storage/config_conversions.rb index e8f49d38f5..17ff47ee9b 100644 --- a/service/lib/agama/storage/config_conversions.rb +++ b/service/lib/agama/storage/config_conversions.rb @@ -26,6 +26,9 @@ module Agama module Storage # Conversions for the storage config. module ConfigConversions + # Reserved string for Configs::Search meaning 'match all devices if there is any' + SEARCH_ANYTHING_STRING = "*" + private_constant :SEARCH_ANYTHING_STRING end end end diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb index 5df1bfb673..7b372f55d8 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb @@ -41,27 +41,20 @@ def convert # @see Base#conversions # @return [Hash] def conversions + return convert_string if search_json.is_a?(String) + { - name: convert_name, - if_not_found: convert_not_found + name: search_json.dig(:condition, :name), + max: search_json[:max], + if_not_found: search_json[:ifNotFound]&.to_sym } end - # @return [String, nil] - def convert_name - return search_json if search_json.is_a?(String) - - search_json.dig(:condition, :name) - end - - # @return [Symbol, nil] - def convert_not_found - return if search_json.is_a?(String) - - value = search_json[:ifNotFound] - return unless value + # @return [String] + def convert_string + return { if_not_found: :skip } if search_json == SEARCH_ANYTHING_STRING - value.to_sym + { name: search_json } end end end diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb index 48108837f7..9cc21417f9 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb @@ -33,13 +33,21 @@ def self.config_type Configs::Search end + # @see Base#convert + def convert + return SEARCH_ANYTHING_STRING if config.all_if_any? + + super + end + private # @see Base#conversions def conversions { condition: convert_condition, - ifNotFound: config.if_not_found.to_s + ifNotFound: config.if_not_found.to_s, + max: config.max } end diff --git a/service/lib/agama/storage/config_search_solver.rb b/service/lib/agama/storage/config_search_solver.rb index 72a772b13a..c65d41fe0c 100644 --- a/service/lib/agama/storage/config_search_solver.rb +++ b/service/lib/agama/storage/config_search_solver.rb @@ -36,22 +36,7 @@ def initialize(devicegraph) # @param config [Agama::Storage::Config] def solve(config) @sids = [] - config.drives.each do |drive_config| - device = find_drive(drive_config.search) - drive_config.search.solve(device) - - add_found(drive_config) - - next unless drive_config.found_device && drive_config.partitions? - - drive_config.partitions.each do |partition_config| - next unless partition_config.search - - partition = find_partition(partition_config.search, drive_config.found_device) - partition_config.search.solve(partition) - add_found(partition_config) - end - end + config.drives = config.drives.flat_map { |d| solve_drive(d) } end private @@ -62,24 +47,87 @@ def solve(config) # @return [Array] SIDs of the devices that are already associated to another search. attr_reader :sids - # Finds a drive matching the given search config. + # @see #solve + # + # @note The given drive object can be modified + # + # @param original_drive [Configs::Drive] + # @return [Configs::Drive, Array] + def solve_drive(original_drive) + devices = find_drives(original_drive.search) + return without_device(original_drive) if devices.empty? + + devices.map do |device| + drive_copy(original_drive, device) + end + end + + # Marks the search of the given config object as solved + # + # @note The config object is modified. + # + # @param config [Configs::Drive, Configs::Partition] + # @return [Configs::Drive, Configs::Partition] + def without_device(config) + config.search.solve + config + end + + # see #solve_drive + def drive_copy(original_drive, device) + drive_config = original_drive.copy + drive_config.search.solve(device) + add_found(drive_config) + + return drive_config unless drive_config.partitions? + + drive_config.partitions = drive_config.partitions.flat_map do |partition_config| + solve_partition(partition_config, device) + end + + drive_config + end + + # see #solve_drive + # + # @note The given partition object can be modified + # + # @param original_partition [Configs::Partition] + # @param drive_device [Y2Storage::Partitionable] + # @return [Configs::Partition, Array] + def solve_partition(original_partition, drive_device) + return original_partition unless original_partition.search + + partitions = find_partitions(original_partition.search, drive_device) + return without_device(original_partition) if partitions.empty? + + partitions.map do |partition| + partition_config = original_partition.copy + partition_config.search.solve(partition) + add_found(partition_config) + + partition_config + end + end + + # Finds the drives matching the given search config. # # @param search_config [Agama::Storage::Configs::Search] # @return [Y2Storage::Device, nil] - def find_drive(search_config) + def find_drives(search_config) candidates = candidate_devices(search_config, default: devicegraph.blk_devices) candidates.select! { |d| d.is?(:disk_device, :stray_blk_device) } - next_unassigned_device(candidates) + next_unassigned_devices(candidates, search_config) end - # Finds a partitions matching the given search config. + # Finds the partitions matching the given search config, if any # # @param search_config [Agama::Storage::Configs::Search] # @return [Y2Storage::Device, nil] - def find_partition(search_config, device) + def find_partitions(search_config, device) candidates = candidate_devices(search_config, default: device.partitions) candidates.select! { |d| d.is?(:partition) } - next_unassigned_device(candidates) + next_unassigned_devices(candidates, search_config) end # Candidate devices for the given search config. @@ -89,7 +137,7 @@ def find_partition(search_config, device) # conditions. # @return [Array] def candidate_devices(search_config, default: []) - return default if search_config.any_device? + return default if search_config.always_match? [find_device(search_config)].compact end @@ -102,14 +150,16 @@ def find_device(search_config) devicegraph.find_by_any_name(search_config.name) end - # Next unassigned device from the given list. + # Next unassigned devices from the given list. # # @param devices [Array] + # @param search [Config::Search] # @return [Y2Storage::Device, nil] - def next_unassigned_device(devices) + def next_unassigned_devices(devices, search) devices .reject { |d| sids.include?(d.sid) } - .min_by(&:name) + .sort_by(&:name) + .first(search.max || devices.size) end # @see #search diff --git a/service/lib/agama/storage/configs/drive.rb b/service/lib/agama/storage/configs/drive.rb index ee6113f383..8c6c3a7bcf 100644 --- a/service/lib/agama/storage/configs/drive.rb +++ b/service/lib/agama/storage/configs/drive.rb @@ -48,7 +48,7 @@ class Drive def initialize @partitions = [] # All drives are expected to match a real device in the system, so let's ensure a search. - @search = Search.new + @search = Search.new.tap { |s| s.max = 1 } end # Whether the drive definition contains partition definitions diff --git a/service/lib/agama/storage/configs/search.rb b/service/lib/agama/storage/configs/search.rb index ecb39c2b0f..6066ec9bb8 100644 --- a/service/lib/agama/storage/configs/search.rb +++ b/service/lib/agama/storage/configs/search.rb @@ -37,6 +37,12 @@ class Search # @return [:create, :skip, :error] attr_accessor :if_not_found + # Optional max number of devices to match + # + # return [Integer, nil] nil means no limit, ie. all devices that meet the condition are + # matched + attr_accessor :max + # Constructor def initialize @solved = false @@ -61,10 +67,17 @@ def solve(device = nil) # Whether the search does not define any specific condition. # # @return [Boolean] - def any_device? + def always_match? name.nil? end + # Whether the search matches all the available devices, skipping if none is found + # + # @return [Boolean] + def all_if_any? + always_match? && max.nil? && if_not_found == :skip + end + # Whether the section containing the search should be skipped # # @return [Boolean] diff --git a/service/lib/agama/storage/configs/with_search.rb b/service/lib/agama/storage/configs/with_search.rb index f8cca8e743..44dc803f67 100644 --- a/service/lib/agama/storage/configs/with_search.rb +++ b/service/lib/agama/storage/configs/with_search.rb @@ -35,6 +35,14 @@ module WithSearch def found_device search&.device end + + # Creates a deep copy of the config element + # + # Needed when a search returns multiple devices and the configuration needs to be replicated + # for each one. + def copy + Marshal.load(Marshal.dump(self)) + end end end end diff --git a/service/lib/y2storage/proposal/agama_vg_planner.rb b/service/lib/y2storage/proposal/agama_vg_planner.rb index e56ffcdf1d..7e16c8b325 100644 --- a/service/lib/y2storage/proposal/agama_vg_planner.rb +++ b/service/lib/y2storage/proposal/agama_vg_planner.rb @@ -59,8 +59,8 @@ def planned_vg(vg_config, config) # @param config [Agama::Storage::Config] # @return [Array] def devices_for_pvs(vg_config, config) - drives = vg_config.physical_volumes_devices.map do |dev_alias| - config.drives.find { |d| d.alias?(dev_alias) } + drives = vg_config.physical_volumes_devices.flat_map do |dev_alias| + config.drives.select { |d| d.alias?(dev_alias) } end.compact drives.map { |d| d.found_device.name } diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 6adb956ef5..a9924a79d8 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Oct 24 13:07:50 UTC 2024 - Ancor Gonzalez Sosa + +- Storage: support to match several devices with every 'search' + section (gh#agama-project/agama#1691). + ------------------------------------------------------------------- Tue Oct 22 09:48:57 UTC 2024 - José Iván López González diff --git a/service/test/agama/storage/config_checker_test.rb b/service/test/agama/storage/config_checker_test.rb index dc1f8f1822..fa2da3bb52 100644 --- a/service/test/agama/storage/config_checker_test.rb +++ b/service/test/agama/storage/config_checker_test.rb @@ -244,8 +244,8 @@ before do mock_storage(devicegraph: scenario) - # To speed-up the tests - allow(Y2Storage::EncryptionMethod::TPM_FDE) + # To speed-up the tests. Use #allow_any_instance because #allow introduces marshaling problems + allow_any_instance_of(Y2Storage::EncryptionMethod::TpmFde) .to(receive(:possible?)) .and_return(true) end @@ -255,6 +255,8 @@ # Solves the config before checking. devicegraph = Y2Storage::StorageManager.instance.probed + allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) + Agama::Storage::ConfigSolver .new(devicegraph, product_config) .solve(config) diff --git a/service/test/agama/storage/config_conversions/from_json_test.rb b/service/test/agama/storage/config_conversions/from_json_test.rb index 29af56465d..2f46c8e134 100644 --- a/service/test/agama/storage/config_conversions/from_json_test.rb +++ b/service/test/agama/storage/config_conversions/from_json_test.rb @@ -21,7 +21,7 @@ require_relative "../../../test_helper" require "agama/config" -require "agama/storage/config_conversions/from_json" +require "agama/storage/config_conversions" require "y2storage/encryption_method" require "y2storage/filesystems/mount_by_type" require "y2storage/filesystems/type" @@ -107,6 +107,18 @@ end end + context "with an asterisk" do + let(:search) { "*" } + + it "sets #search to the expected value" do + config = config_proc.call(subject.convert) + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to be_nil + expect(config.search.if_not_found).to eq(:skip) + expect(config.search.max).to be_nil + end + end + context "with a search section" do let(:search) do { @@ -120,6 +132,24 @@ expect(config.search).to be_a(Agama::Storage::Configs::Search) expect(config.search.name).to eq("/dev/vda1") expect(config.search.if_not_found).to eq(:skip) + expect(config.search.max).to be_nil + end + end + + context "with a search section including a max" do + let(:search) do + { + ifNotFound: "error", + max: 3 + } + end + + it "sets #search to the expected value" do + config = config_proc.call(subject.convert) + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to be_nil + expect(config.search.if_not_found).to eq(:error) + expect(config.search.max).to eq 3 end end end diff --git a/service/test/agama/storage/config_conversions/to_json_test.rb b/service/test/agama/storage/config_conversions/to_json_test.rb index 86b0b41a09..977c0f5f13 100644 --- a/service/test/agama/storage/config_conversions/to_json_test.rb +++ b/service/test/agama/storage/config_conversions/to_json_test.rb @@ -20,8 +20,7 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" -require "agama/storage/config_conversions/from_json" -require "agama/storage/config_conversions/to_json" +require "agama/storage/config_conversions" require "y2storage/refinements" using Y2Storage::Refinements::SizeCasts @@ -93,7 +92,8 @@ let(:search) do { condition: { name: "/dev/vda1" }, - ifNotFound: "skip" + ifNotFound: "skip", + max: 2 } end @@ -104,7 +104,8 @@ expect(search_json).to eq( { condition: { name: "/dev/vda1" }, - ifNotFound: "skip" + ifNotFound: "skip", + max: 2 } ) end @@ -145,6 +146,17 @@ end end end + + context "if there are no conditions or limits and errors should be skipped" do + let(:search) { { ifNotFound: "skip" } } + + it "generates a wildcard" do + config_json = result_scope.call(subject.convert) + search_json = config_json[:search] + + expect(search_json).to eq "*" + end + end end shared_examples "with alias" do |result_scope| @@ -633,9 +645,7 @@ drives_json = subject.convert[:drives] default_drive_json = { - search: { - ifNotFound: "error" - }, + search: { ifNotFound: "error", max: 1 }, partitions: [] } @@ -658,9 +668,7 @@ search_json = drive_json[:search] expect(search_json).to eq( - { - ifNotFound: "error" - } + { ifNotFound: "error", max: 1 } ) end end diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index 0717922f0a..eb0cc66949 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -21,7 +21,7 @@ require_relative "./storage_helpers" require "agama/config" -require "agama/storage/config_conversions/from_json" +require "agama/storage/config_conversions" require "agama/storage/config_solver" require "y2storage" require "y2storage/refinements" @@ -550,7 +550,7 @@ end end - context "if a drive has a search without a device name" do + context "if a drive omits the search" do let(:config_json) { { drives: drives } } let(:drives) do @@ -593,6 +593,86 @@ end end + context "if a drive contains an empty search" do + let(:config_json) { { drives: drives } } + + let(:drives) do + [ + { search: {} } + ] + end + + let(:scenario) { "disks.yaml" } + + it "expands the number of drives to match all the existing disks" do + subject.solve(config) + expect(config.drives.size).to eq 3 + search1, search2, search3 = config.drives.map(&:search) + expect(search1.solved?).to eq(true) + expect(search1.device.name).to eq("/dev/vda") + expect(search2.solved?).to eq(true) + expect(search2.device.name).to eq("/dev/vdb") + expect(search3.solved?).to eq(true) + expect(search3.device.name).to eq("/dev/vdc") + end + end + + context "if a drive contains a search with '*'" do + let(:config_json) { { drives: drives } } + + let(:drives) do + [ + { search: "*" } + ] + end + + let(:scenario) { "disks.yaml" } + + it "expands the number of drives to match all the existing disks" do + subject.solve(config) + expect(config.drives.size).to eq 3 + expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) + expect(config.drives.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda", "/dev/vdb", "/dev/vdc"] + end + end + + context "if a drive contains a search with no conditions but with a max" do + let(:config_json) { { drives: drives } } + + let(:drives) do + [ + { search: { max: max } } + ] + end + + let(:scenario) { "disks.yaml" } + + context "and the max is equal or smaller than the number of disks" do + let(:max) { 2 } + + it "expands the number of drives to match the max" do + subject.solve(config) + expect(config.drives.size).to eq 2 + expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) + expect(config.drives.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda", "/dev/vdb"] + end + end + + context "and the max is bigger than the number of disks" do + let(:max) { 20 } + + it "expands the number of drives to match all the existing disks" do + subject.solve(config) + expect(config.drives.size).to eq 3 + expect(config.drives.map(&:search).map(&:solved?)).to all(eq(true)) + expect(config.drives.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda", "/dev/vdb", "/dev/vdc"] + end + end + end + context "if a drive has a search with a device name" do let(:config_json) { { drives: drives } } @@ -618,6 +698,9 @@ context "and the device is not found" do let(:search) { "/dev/vdd" } + # Speed-up fallback search (and make sure it fails) + before { allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) } + it "does not set a device to the drive" do subject.solve(config) search = config.drives.first.search @@ -661,28 +744,26 @@ end end - context "if a partition has a search without a device name" do + context "if a partition has an empty search" do let(:config_json) do { - drives: [ - { partitions: partitions } - ] + drives: [{ partitions: partitions }] } end let(:partitions) do [ - { search: {} }, - { search: {} }, { search: {} } ] end let(:scenario) { "disks.yaml" } - it "sets the first unassigned partition to the config" do + it "expands the number of partition configs to match all the existing partitions" do subject.solve(config) - search1, search2, search3 = config.drives.first.partitions.map(&:search) + drive_partitions = config.drives.first.partitions + expect(drive_partitions.size).to eq 3 + search1, search2, search3 = drive_partitions.map(&:search) expect(search1.solved?).to eq(true) expect(search1.device.name).to eq("/dev/vda1") expect(search2.solved?).to eq(true) @@ -691,31 +772,49 @@ expect(search3.device.name).to eq("/dev/vda3") end - context "and there is not unassigned partition" do + context "and there are more partition searches without name" do let(:partitions) do [ { search: {} }, { search: {} }, - { search: {} }, - { search: {} } + { search: "*" } ] end - it "does not set a partition to the config" do + it "does not set a device to the surpluss configs" do subject.solve(config) - search = config.drives.first.partitions[3].search - expect(search.solved?).to eq(true) - expect(search.device).to be_nil + drive_partitions = config.drives.first.partitions + expect(drive_partitions.size).to eq 5 + searches = drive_partitions[3..-1].map(&:search) + expect(searches.map(&:solved?)).to eq [true, true] + expect(searches.map(&:device)).to eq [nil, nil] end end end + context "if a partition has '*' as search" do + let(:config_json) do + { + drives: [{ partitions: [{ search: "*" }] }] + } + end + + let(:scenario) { "disks.yaml" } + + it "expands the number of partition configs to match all the existing partitions" do + subject.solve(config) + drive_partitions = config.drives.first.partitions + expect(drive_partitions.size).to eq 3 + expect(drive_partitions.map(&:search).map(&:solved?)).to all(eq(true)) + expect(drive_partitions.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda1", "/dev/vda2", "/dev/vda3"] + end + end + context "if a partition has a search with a device name" do let(:config_json) do { - drives: [ - { partitions: partitions } - ] + drives: [{ partitions: partitions }] } end @@ -743,6 +842,9 @@ context "and the device is not found" do let(:search) { "/dev/vdb1" } + # Speed-up fallback search (and make sure it fails) + before { allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) } + it "does not set a partition to the config" do subject.solve(config) search = search_proc.call(config) @@ -761,7 +863,7 @@ it "does not set a partition to the config" do subject.solve(config) - search = config.drives.first.partitions[1].search + search = config.drives.first.partitions.last.search expect(search.solved?).to eq(true) expect(search.device).to be_nil end @@ -785,4 +887,40 @@ end end end + + context "if a partition config contains a search with no conditions but with a max" do + let(:config_json) do + { + drives: [{ partitions: [{ search: { max: max } }] }] + } + end + + let(:scenario) { "disks.yaml" } + + context "and the max is equal or smaller than the number of partitions on the device" do + let(:max) { 2 } + + it "expands the number of partition configs to match the max" do + subject.solve(config) + drive_partitions = config.drives.first.partitions + expect(drive_partitions.size).to eq 2 + expect(drive_partitions.map(&:search).map(&:solved?)).to all(eq(true)) + expect(drive_partitions.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda1", "/dev/vda2"] + end + end + + context "and the max is bigger than the number of partitions on the device" do + let(:max) { 20 } + + it "expands the number of configs to match all the existing partitions" do + subject.solve(config) + drive_partitions = config.drives.first.partitions + expect(drive_partitions.size).to eq 3 + expect(drive_partitions.map(&:search).map(&:solved?)).to all(eq(true)) + expect(drive_partitions.map(&:search).map(&:device).map(&:name)) + .to eq ["/dev/vda1", "/dev/vda2", "/dev/vda3"] + end + end + end end diff --git a/service/test/y2storage/agama_proposal_search_test.rb b/service/test/y2storage/agama_proposal_search_test.rb new file mode 100644 index 0000000000..80a4dd670c --- /dev/null +++ b/service/test/y2storage/agama_proposal_search_test.rb @@ -0,0 +1,221 @@ +# 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 "agama/storage/config_conversions" +require "y2storage" +require "y2storage/agama_proposal" + +describe Y2Storage::AgamaProposal do + using Y2Storage::Refinements::SizeCasts + include Agama::RSpec::StorageHelpers + + subject(:proposal) do + described_class.new(config, issues_list: issues_list) + end + + let(:config) { config_from_json } + + let(:config_from_json) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + let(:issues_list) { [] } + + before do + mock_storage(devicegraph: scenario) + # To speed-up the tests + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + end + + let(:scenario) { "disks.yaml" } + + describe "#propose" do + context "when searching several disks at once and using them as LVM targetDevices" do + let(:config_json) do + { + drives: [ + { search: { max: 2 }, alias: "first-two" } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [ + { generate: { targetDevices: ["first-two"] } } + ], + logicalVolumes: [ + { + name: "root", + size: "55 GiB", + filesystem: { path: "/" } + } + ] + } + ] + } + end + + it "extends the LVM over all the chosen disks if needed" do + devicegraph = proposal.propose + + system = devicegraph.find_by_name("/dev/system") + expect(system.lvm_pvs.map { |pv| pv.blk_device.partitionable.name }) + .to contain_exactly("/dev/vda", "/dev/vdb") + end + end + + context "when searching several disks at once and using them as LVM PVs" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { search: "/dev/vda1" }, + { search: "*", alias: "rest" } + ] + } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: ["rest"], + logicalVolumes: [ + { + name: "root", + size: "10 GiB", + filesystem: { path: "/" } + } + ] + } + ] + } + end + + it "uses all the disks as physical volumes" do + probed = Y2Storage::StorageManager.instance.probed + vda2_sid = probed.find_by_name("/dev/vda2").sid + vda3_sid = probed.find_by_name("/dev/vda3").sid + + devicegraph = proposal.propose + + system = devicegraph.find_by_name("/dev/system") + expect(system.lvm_pvs.map(&:blk_device).map(&:sid)).to contain_exactly(vda2_sid, vda3_sid) + end + end + + context "when marking several partitions for resizing" do + let(:config_json) do + { + boot: { configure: false }, + drives: [ + { + search: disk_name, + partitions: [ + { search: search, size: { min: 0, max: "current" } }, + { size: "25 GiB", filesystem: { path: "/" } } + ] + } + ] + } + end + + before do + allow_any_instance_of(Y2Storage::Partition) + .to(receive(:detect_resize_info)) + .and_return(resize_info) + end + + let(:resize_info) do + instance_double( + Y2Storage::ResizeInfo, resize_ok?: true, + min_size: Y2Storage::DiskSize::GiB(4), max_size: Y2Storage::DiskSize::GiB(35) + ) + end + + shared_examples "resize" do + it "resizes several partitions if needed" do + probed = Y2Storage::StorageManager.instance.probed + vda2_size = probed.find_by_name("/dev/vda2").size + vda3_size = probed.find_by_name("/dev/vda3").size + + devicegraph = proposal.propose + + expect(devicegraph.find_by_name("/dev/vda2").size).to be < vda2_size + expect(devicegraph.find_by_name("/dev/vda3").size).to be < vda3_size + end + end + + context "using an empty search to match the partitions" do + let(:search) { {} } + + context "if there are several partitions at the disk" do + let(:disk_name) { "/dev/vda" } + + include_examples "resize" + end + + context "if there are no partitions in the disk" do + let(:disk_name) { "/dev/vdc" } + + it "register an error and returns nil" do + expect(proposal.propose).to be_nil + expect(proposal.issues_list).to include an_object_having_attributes( + description: /mandatory partition/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + + context "using asterisk as the search to match the partitions" do + let(:search) { "*" } + + context "if there are several partitions at the disk" do + let(:disk_name) { "/dev/vda" } + + include_examples "resize" + end + + context "if there are no partitions in the disk" do + let(:disk_name) { "/dev/vdc" } + + it "processes the proposal" do + devicegraph = proposal.propose + disk = devicegraph.find_by_name(disk_name) + expect(disk.partitions.size).to eq 1 + end + + it "register a warning about non-existent partitions" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /optional partition/, + severity: Agama::Issue::Severity::WARN + ) + end + end + end + end + end +end diff --git a/service/test/y2storage/agama_proposal_test.rb b/service/test/y2storage/agama_proposal_test.rb index fbc0924d01..d26db503d4 100644 --- a/service/test/y2storage/agama_proposal_test.rb +++ b/service/test/y2storage/agama_proposal_test.rb @@ -372,8 +372,11 @@ def partition_config(name: nil, filesystem: nil, size: nil) let(:available?) { true } before do - allow(encryption_method).to receive(:available?).and_return(available?) if encryption_method home_partition.encryption = home_encryption + + # Mocking only the object at encryption_method introduces a problem with serialization + allow_any_instance_of(Y2Storage::EncryptionMethod::Luks2) + .to receive(:available?).and_return(available?) end context "if the encryption settings contain all the detailed information" do @@ -627,7 +630,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) let(:partitions0) { [root_partition, home_partition] } before do - home_partition.search = Agama::Storage::Configs::Search.new + home_partition.search = Agama::Storage::Configs::Search.new.tap { |s| s.max = 1 } end # TODO: Is this correct? The first partition (boot partition) is reused for home. @@ -643,7 +646,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) it "does not reuse the same partition twice" do vda1 = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda1") vda2 = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda2") - root_partition.search = Agama::Storage::Configs::Search.new + root_partition.search = Agama::Storage::Configs::Search.new.tap { |s| s.max = 1 } proposal.propose root = proposal.devices.find_by_name("/dev/vda1")