From 7931b68f8fe60bb315091653ac31b806200c5258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 7 May 2025 14:07:50 +0100 Subject: [PATCH 01/12] schema: add search conditions --- .../share/examples/storage/drive_search.json | 42 ++++ .../examples/storage/md_raid_search.json | 43 ++++ .../examples/storage/partition_search.json | 120 +++++++++++ rust/agama-lib/share/storage.schema.json | 201 ++++++++++++++---- 4 files changed, 367 insertions(+), 39 deletions(-) create mode 100644 rust/agama-lib/share/examples/storage/drive_search.json create mode 100644 rust/agama-lib/share/examples/storage/md_raid_search.json create mode 100644 rust/agama-lib/share/examples/storage/partition_search.json diff --git a/rust/agama-lib/share/examples/storage/drive_search.json b/rust/agama-lib/share/examples/storage/drive_search.json new file mode 100644 index 0000000000..65ff5a6d5e --- /dev/null +++ b/rust/agama-lib/share/examples/storage/drive_search.json @@ -0,0 +1,42 @@ +{ + "storage": { + "drives": [ + { "search": "*" }, + { "search": "/dev/vda" }, + { + "search": { + "condition": { "name": "/dev/vdb" }, + "ifNotFound": "skip" + } + }, + { + "search": { + "condition": { "size": "10 GiB" }, + "ifNotFound": "error", + "max": 2 + } + }, + { + "search": { + "condition": { + "size": { "equal": "10 GiB" } + } + } + }, + { + "search": { + "condition": { + "size": { "greater": "10 GiB" } + } + } + }, + { + "search": { + "condition": { + "size": { "less": "10 GiB" } + } + } + } + ] + } +} diff --git a/rust/agama-lib/share/examples/storage/md_raid_search.json b/rust/agama-lib/share/examples/storage/md_raid_search.json new file mode 100644 index 0000000000..4198e90a81 --- /dev/null +++ b/rust/agama-lib/share/examples/storage/md_raid_search.json @@ -0,0 +1,43 @@ +{ + "storage": { + "mdRaids": [ + { "search": "*" }, + { "search": "/dev/md1" }, + { + "search": { + "condition": { "name": "/dev/md2" }, + "ifNotFound": "skip" + } + }, + { + "search": { + "condition": { "size": "10 GiB" }, + "ifNotFound": "error", + "max": 2 + } + }, + { + "search": { + "condition": { + "size": { "equal": "10 GiB" } + }, + "ifNotFound": "create" + } + }, + { + "search": { + "condition": { + "size": { "greater": "10 GiB" } + } + } + }, + { + "search": { + "condition": { + "size": { "less": "10 GiB" } + } + } + } + ] + } +} diff --git a/rust/agama-lib/share/examples/storage/partition_search.json b/rust/agama-lib/share/examples/storage/partition_search.json new file mode 100644 index 0000000000..bc27ed8f75 --- /dev/null +++ b/rust/agama-lib/share/examples/storage/partition_search.json @@ -0,0 +1,120 @@ +{ + "storage": { + "drives": [ + { + "search": "/dev/vda", + "partitions": [ + { "search": "*" }, + { "search": "/dev/vda1" }, + { + "search": { + "condition": { "name": "/dev/vda2" }, + "ifNotFound": "skip" + } + }, + { + "search": { + "condition": { "size": "10 GiB" }, + "ifNotFound": "error", + "max": 2 + } + }, + { + "search": { + "condition": { + "size": { "equal": "10 GiB" } + }, + "ifNotFound": "create" + } + }, + { + "search": { + "condition": { + "size": { "greater": "10 GiB" } + } + } + }, + { + "search": { + "condition": { + "size": { "less": "10 GiB" } + } + } + }, + { + "search": { + "max": 1, + "ifNotFound": "error" + }, + "delete": true + }, + { + "search": { + "max": 1, + "ifNotFound": "skip" + }, + "deleteIfNeeded": true + } + ] + } + ], + "mdRaids": [ + { + "search": "/dev/md1", + "partitions": [ + { "search": "*" }, + { "search": "/dev/md1-p1" }, + { + "search": { + "condition": { "name": "/dev/md1-p2" }, + "ifNotFound": "skip" + } + }, + { + "search": { + "condition": { "size": "10 GiB" }, + "ifNotFound": "error", + "max": 2 + } + }, + { + "search": { + "condition": { + "size": { "equal": "10 GiB" } + }, + "ifNotFound": "create" + } + }, + { + "search": { + "condition": { + "size": { "greater": "10 GiB" } + } + } + }, + { + "search": { + "condition": { + "size": { "less": "10 GiB" } + } + } + }, + { + "search": { + "max": 1, + "ifNotFound": "error" + }, + "delete": true + }, + { + "search": { + "max": 1, + "ifNotFound": "skip" + }, + "deleteIfNeeded": true + } + ] + } + ] + } +} diff --git a/rust/agama-lib/share/storage.schema.json b/rust/agama-lib/share/storage.schema.json index 9e454da6c1..4e63aac538 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -48,7 +48,7 @@ "type": "object", "additionalProperties": false, "properties": { - "search": { "$ref": "#/$defs/searchElement" }, + "search": { "$ref": "#/$defs/driveSearch" }, "alias": { "$ref": "#/$defs/alias" }, "encryption": { "$ref": "#/$defs/encryption" }, "filesystem": { "$ref": "#/$defs/filesystem" } @@ -59,7 +59,7 @@ "additionalProperties": false, "required": ["partitions"], "properties": { - "search": { "$ref": "#/$defs/searchElement" }, + "search": { "$ref": "#/$defs/driveSearch" }, "alias": { "$ref": "#/$defs/alias" }, "ptableType": { "$ref": "#/$defs/ptableType" }, "partitions": { @@ -79,7 +79,7 @@ "type": "object", "additionalProperties": false, "properties": { - "search": { "$ref": "#/$defs/searchElement" }, + "search": { "$ref": "#/$defs/mdRaidSearch" }, "alias": { "$ref": "#/$defs/alias" }, "name": { "$ref": "#/$defs/mdRaidName" }, "level": { "$ref": "#/$defs/mdRaidLevel" }, @@ -95,7 +95,7 @@ "additionalProperties": false, "required": ["partitions"], "properties": { - "search": { "$ref": "#/$defs/searchElement" }, + "search": { "$ref": "#/$defs/mdRaidSearch" }, "alias": { "$ref": "#/$defs/alias" }, "name": { "$ref": "#/$defs/mdRaidName" }, "level": { "$ref": "#/$defs/mdRaidLevel" }, @@ -158,7 +158,7 @@ { "$ref": "#/$defs/advancedPartitionsGenerator" }, { "$ref": "#/$defs/regularPartition" }, { "$ref": "#/$defs/partitionToDelete" }, - { "$ref": "#/$defs/PartitionToDeleteIfNeeded" } + { "$ref": "#/$defs/partitionToDeleteIfNeeded" } ] }, "simpleVolumesGenerator": { @@ -195,7 +195,7 @@ "type": "object", "additionalProperties": false, "properties": { - "search": { "$ref": "#/$defs/searchElement" }, + "search": { "$ref": "#/$defs/partitionSearch" }, "alias": { "$ref": "#/$defs/alias" }, "id": { "title": "Partition id", @@ -219,19 +219,19 @@ "additionalProperties": false, "required": ["delete", "search"], "properties": { - "search": { "$ref": "#/$defs/searchElement" }, + "search": { "$ref": "#/$defs/deletePartitionSearch" }, "delete": { "description": "Delete the partition.", "const": true } } }, - "PartitionToDeleteIfNeeded": { + "partitionToDeleteIfNeeded": { "type": "object", "additionalProperties": false, "required": ["deleteIfNeeded", "search"], "properties": { - "search": { "$ref": "#/$defs/searchElement" }, + "search": { "$ref": "#/$defs/deletePartitionSearch" }, "deleteIfNeeded": { "description": "Delete the partition if needed to make space.", "const": true @@ -390,49 +390,172 @@ "minimum": 1, "maximum": 128 }, - "searchElement": { + "driveSearch": { "anyOf": [ - { "$ref": "#/$defs/simpleSearchAll" }, - { "$ref": "#/$defs/simpleSearchByName" }, - { "$ref": "#/$defs/advancedSearch" } + { "$ref": "#/$defs/searchAll" }, + { "$ref": "#/$defs/searchName" }, + { "$ref": "#/$defs/driveAdvancedSearch" } ] }, - "simpleSearchAll": { - "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": "*" + "driveAdvancedSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { "$ref": "#/$defs/driveSearchCondition" }, + "max": { "$ref": "#/$defs/searchMax" }, + "ifNotFound": { "$ref": "#/$defs/searchActions" } + } }, - "simpleSearchByName": { - "descrition": "Search by device name", - "type": "string", - "examples": ["/dev/vda", "/dev/disk/by-id/ata-WDC_WD3200AAKS-75L9"] + "driveSearchCondition": { + "anyOf": [ + { "$ref": "#/$defs/searchConditionName" }, + { "$ref": "#/$defs/searchConditionSize" } + ] }, - "advancedSearch": { - "description": "Advanced options for searching devices.", + "mdRaidSearch": { + "anyOf": [ + { "$ref": "#/$defs/searchAll" }, + { "$ref": "#/$defs/searchName" }, + { "$ref": "#/$defs/mdRaidAdvancedSearch" } + ] + }, + "mdRaidAdvancedSearch": { "type": "object", "additionalProperties": false, "properties": { - "condition": { - "title": "Search condition", - "type": "object", - "additionalProperties": false, - "required": ["name"], - "properties": { - "name": { "$ref": "#/$defs/simpleSearchByName" } - } - }, - "max": { - "description": "Maximum devices to match.", + "condition": { "$ref": "#/$defs/mdRaidSearchCondition" }, + "max": { "$ref": "#/$defs/searchMax" }, + "ifNotFound": { "$ref": "#/$defs/searchCreatableActions" } + } + }, + "mdRaidSearchCondition": { + "anyOf": [ + { "$ref": "#/$defs/searchConditionName" }, + { "$ref": "#/$defs/searchConditionSize" } + ] + }, + "partitionSearch": { + "anyOf": [ + { "$ref": "#/$defs/searchAll" }, + { "$ref": "#/$defs/searchName" }, + { "$ref": "#/$defs/partitionAdvancedSearch" } + ] + }, + "partitionAdvancedSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { "$ref": "#/$defs/partitionSearchCondition" }, + "max": { "$ref": "#/$defs/searchMax" }, + "ifNotFound": { "$ref": "#/$defs/searchCreatableActions" } + } + }, + "deletePartitionSearch": { + "anyOf": [ + { "$ref": "#/$defs/searchAll" }, + { "$ref": "#/$defs/searchName" }, + { "$ref": "#/$defs/deletePartitionAdvancedSearch" } + ] + }, + "deletePartitionAdvancedSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { "$ref": "#/$defs/partitionSearchCondition" }, + "max": { "$ref": "#/$defs/searchMax" }, + "ifNotFound": { "$ref": "#/$defs/searchActions" } + } + }, + "partitionSearchCondition": { + "anyOf": [ + { "$ref": "#/$defs/searchConditionName" }, + { "$ref": "#/$defs/searchConditionSize" }, + { "$ref": "#/$defs/searchConditionPartitionNumber" } + ] + }, + "searchConditionName": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "$ref": "#/$defs/searchName" } + } + }, + "searchConditionSize": { + "type": "object", + "additionalProperties": false, + "required": ["size"], + "properties": { + "size": { + "anyOf": [ + { "$ref": "#/$defs/sizeValue" }, + { "$ref": "#/$defs/searchConditionSizeEqual" }, + { "$ref": "#/$defs/searchConditionSizeGreater" }, + { "$ref": "#/$defs/searchConditionSizeLess" } + ] + } + } + }, + "searchConditionSizeEqual": { + "type": "object", + "additionalProperties": false, + "required": ["equal"], + "properties": { + "equal": { "$ref": "#/$defs/sizeValue" } + } + }, + "searchConditionSizeGreater": { + "type": "object", + "additionalProperties": false, + "required": ["greater"], + "properties": { + "greater": { "$ref": "#/$defs/sizeValue" } + } + }, + "searchConditionSizeLess": { + "type": "object", + "additionalProperties": false, + "required": ["less"], + "properties": { + "less": { "$ref": "#/$defs/sizeValue" } + } + }, + "searchConditionPartitionNumber": { + "type": "object", + "additionalProperties": false, + "required": ["number"], + "properties": { + "number": { + "description": "Partition number (e.g., 1 for vda1).", "type": "integer", "minimum": 1 - }, - "ifNotFound": { - "title": "Search action", - "description": "How to handle the section if the device is not found.", - "enum": ["skip", "error"], - "default": "error" } } }, + "searchMax": { + "description": "Maximum devices to match.", + "type": "integer", + "minimum": 1 + }, + "searchActions": { + "description": "How to handle the section if the device is not found.", + "enum": ["skip", "error"], + "default": "error" + }, + "searchCreatableActions": { + "description": "How to handle the section if the device is not found.", + "enum": ["skip", "error", "create"], + "default": "error" + }, + "searchAll": { + "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": "*" + }, + "searchName": { + "descrition": "Search by device name", + "type": "string", + "examples": ["/dev/vda", "/dev/disk/by-id/ata-WDC_WD3200AAKS-75L9"] + }, "alias": { "description": "Alias used to reference a device.", "type": "string" From 0e69e0603d5e24f4d060421cd9f1f7ed48906eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 8 May 2025 15:36:39 +0100 Subject: [PATCH 02/12] storage: generate search conditions from json --- .../from_json_conversions/search.rb | 19 +- .../search_conditions.rb | 34 +++ .../search_conditions/size.rb | 70 ++++++ service/lib/agama/storage/configs/search.rb | 22 +- .../storage/configs/search_conditions.rb | 32 +++ .../storage/configs/search_conditions/size.rb | 41 +++ .../lib/agama/storage/configs/with_search.rb | 2 +- .../from_json_conversions/examples.rb | 60 +---- .../from_json_conversions/search_test.rb | 234 ++++++++++++++++++ 9 files changed, 450 insertions(+), 64 deletions(-) create mode 100644 service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions.rb create mode 100644 service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions/size.rb create mode 100644 service/lib/agama/storage/configs/search_conditions.rb create mode 100644 service/lib/agama/storage/configs/search_conditions/size.rb create mode 100644 service/test/agama/storage/config_conversions/from_json_conversions/search_test.rb 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 c221fd8f77..b7bae22a56 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/from_json_conversions/base" +require "agama/storage/config_conversions/from_json_conversions/search_conditions" require "agama/storage/configs/search" module Agama @@ -50,9 +51,11 @@ def conversions return convert_string if search_json.is_a?(String) { - name: search_json.dig(:condition, :name), - max: search_json[:max], - if_not_found: search_json[:ifNotFound]&.to_sym + name: search_json.dig(:condition, :name), + size: convert_size, + partition_number: search_json.dig(:condition, :number), + max: search_json[:max], + if_not_found: search_json[:ifNotFound]&.to_sym } end @@ -62,6 +65,14 @@ def convert_string { name: search_json } end + + # @return [Configs::SearchConditions::Size, nil] + def convert_size + size_json = search_json.dig(:condition, :size) + return unless size_json + + FromJSONConversions::SearchConditions::Size.new(size_json).convert + end end end end diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions.rb new file mode 100644 index 0000000000..41b763dc00 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module ConfigConversions + module FromJSONConversions + # Namespace for conversions of search conditions. + module SearchConditions + end + end + end + end +end + +require "agama/storage/config_conversions/from_json_conversions/search_conditions/size" diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions/size.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions/size.rb new file mode 100644 index 0000000000..4bd8ed9841 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/search_conditions/size.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/from_json_conversions/base" +require "agama/storage/configs/search_conditions/size" +require "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module FromJSONConversions + module SearchConditions + # Size condition conversion from JSON according to schema. + class Size < Base + private + + # @see Base + # @return [Configs::SearchConditions::Size] + def default_config + Configs::SearchConditions::Size.new + end + + alias_method :size_json, :config_json + + # @see Base#conversions + # @return [Hash] + def conversions + { + value: convert_value, + operator: convert_operator + } + end + + # @return [Y2Storage::DiskSize] + def convert_value + value = size_json.is_a?(Hash) ? size_json.values.first : size_json + + Y2Storage::DiskSize.new(value) + end + + # @return [Symbol, nil] + def convert_operator + return unless size_json.is_a?(Hash) + + size_json.keys.first.to_sym + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/search.rb b/service/lib/agama/storage/configs/search.rb index b0af036151..23d78b6f3d 100644 --- a/service/lib/agama/storage/configs/search.rb +++ b/service/lib/agama/storage/configs/search.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -32,14 +32,26 @@ def self.new_for_search_all new.tap { |c| c.if_not_found = :skip } end + # Search by name. + # + # @return [String, nil] + attr_accessor :name + + # Search by size. + # + # @return [SearchConditions::Size, nil] + attr_accessor :size + + # Search by partition number (only applies if searching partitions). + # + # @return [Integer, nil] e.g., 2 for "/dev/vda2". + attr_accessor :partition_number + # Found device, if any + # # @return [Y2Storage::Device, nil] attr_reader :device - # Name of the device to find. - # @return [String, nil] - attr_accessor :name - # What to do if the search does not match with the expected number of devices # @return [:create, :skip, :error] attr_accessor :if_not_found diff --git a/service/lib/agama/storage/configs/search_conditions.rb b/service/lib/agama/storage/configs/search_conditions.rb new file mode 100644 index 0000000000..7fea6de3f4 --- /dev/null +++ b/service/lib/agama/storage/configs/search_conditions.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + # Namespace for conditions used for searching devices. + module SearchConditions + end + end + end +end + +require "agama/storage/configs/search_conditions/size" diff --git a/service/lib/agama/storage/configs/search_conditions/size.rb b/service/lib/agama/storage/configs/search_conditions/size.rb new file mode 100644 index 0000000000..0492401fc2 --- /dev/null +++ b/service/lib/agama/storage/configs/search_conditions/size.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + module SearchConditions + # Condition for searching by size. + class Size + # @return [Y2Storage::DiskSize, nil] + attr_accessor :value + + # @return [:equal, :greater, :less] + attr_accessor :operator + + def initialize + @operator = :equal + end + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/with_search.rb b/service/lib/agama/storage/configs/with_search.rb index d4c814462c..ccf45360c4 100644 --- a/service/lib/agama/storage/configs/with_search.rb +++ b/service/lib/agama/storage/configs/with_search.rb @@ -35,7 +35,7 @@ module WithSearch # Assigned device according to the search. # - # @see Y2Storage::Proposal::AgamaSearcher + # @see ConfigSolvers::Search # # @return [Y2Storage::Device, nil] def found_device diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/examples.rb b/service/test/agama/storage/config_conversions/from_json_conversions/examples.rb index fb039ddfa1..aac5cffcca 100644 --- a/service/test/agama/storage/config_conversions/from_json_conversions/examples.rb +++ b/service/test/agama/storage/config_conversions/from_json_conversions/examples.rb @@ -135,61 +135,13 @@ shared_examples "with search" do context "if 'search' is specified" do - context "with a device name" do - let(:search) { "/dev/vda1" } + let(:search) { "/dev/vda1" } - it "sets #search to the expected value" do - config = subject.convert - expect(config.search).to be_a(Agama::Storage::Configs::Search) - expect(config.search.name).to eq("/dev/vda1") - expect(config.search.if_not_found).to eq(:error) - end - end - - context "with an asterisk" do - let(:search) { "*" } - - it "sets #search to the expected value" do - config = subject.convert - expect(config.search).to be_a(Agama::Storage::Configs::Search) - expect(config.search.name).to be_nil - expect(config.search.if_not_found).to eq(:skip) - expect(config.search.max).to be_nil - end - end - - context "with a search section" do - let(:search) do - { - condition: { name: "/dev/vda1" }, - ifNotFound: "skip" - } - end - - it "sets #search to the expected value" do - config = subject.convert - expect(config.search).to be_a(Agama::Storage::Configs::Search) - expect(config.search.name).to eq("/dev/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 = 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 + it "sets #search to the expected value" do + config = subject.convert + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to eq("/dev/vda1") + expect(config.search.if_not_found).to eq(:error) end end end diff --git a/service/test/agama/storage/config_conversions/from_json_conversions/search_test.rb b/service/test/agama/storage/config_conversions/from_json_conversions/search_test.rb new file mode 100644 index 0000000000..f9e3997ff3 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_conversions/search_test.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions/from_json_conversions/search" +require "agama/storage/configs/search" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::FromJSONConversions::Search do + subject do + described_class.new(config_json) + end + + let(:config_json) do + { + condition: condition, + max: max, + ifNotFound: if_not_found + } + end + + let(:condition) { nil } + let(:max) { nil } + let(:if_not_found) { nil } + + describe "#convert" do + it "returns a search config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Configs::Search) + end + + context "with a device name" do + let(:config_json) { "/dev/vda1" } + + it "sets #name to the expected value" do + config = subject.convert + expect(config.name).to eq("/dev/vda1") + end + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_nil + end + + it "sets #partition_number to the expected value" do + config = subject.convert + expect(config.partition_number).to be_nil + end + + it "sets #max to the expected value" do + config = subject.convert + expect(config.max).to be_nil + end + + it "sets #if_not_found to the expected value" do + config = subject.convert + expect(config.if_not_found).to eq(:error) + end + end + + context "with an asterisk" do + let(:config_json) { "*" } + + it "sets #name to the expected value" do + config = subject.convert + expect(config.name).to be_nil + end + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_nil + end + + it "sets #partition_number to the expected value" do + config = subject.convert + expect(config.partition_number).to be_nil + end + + it "sets #max to the expected value" do + config = subject.convert + expect(config.max).to be_nil + end + + it "sets #if_not_found to the expected value" do + config = subject.convert + expect(config.if_not_found).to eq(:skip) + end + end + + context "with a search section" do + context "if 'condition' is not specefied" do + let(:condition) { nil } + + it "sets #name to the expected value" do + config = subject.convert + expect(config.name).to be_nil + end + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_nil + end + + it "sets #partition_number to the expected value" do + config = subject.convert + expect(config.partition_number).to be_nil + end + end + + context "if 'max' is not specified" do + let(:max) { nil } + + it "sets #max to the expected value" do + config = subject.convert + expect(config.max).to be_nil + end + end + + context "if 'ifNotFound' is not specified" do + let(:if_not_found) { nil } + + it "sets #if_not_found to the expected value" do + config = subject.convert + expect(config.if_not_found).to eq(:error) + end + end + + context "if 'condition' is specefied" do + context "and 'name' is specified" do + let(:condition) { { name: "/dev/vda" } } + + it "sets #name to the expected value" do + config = subject.convert + expect(config.name).to eq("/dev/vda") + end + end + + context "and 'size' is specified" do + let(:condition) { { size: size } } + + context "without operator" do + let(:size) { "2 GiB" } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::SearchConditions::Size) + expect(config.size.value).to eq(2.GiB) + expect(config.size.operator).to eq(:equal) + end + end + + context "with 'equal' operator" do + let(:size) { { equal: "2 GiB" } } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::SearchConditions::Size) + expect(config.size.value).to eq(2.GiB) + expect(config.size.operator).to eq(:equal) + end + end + + context "with 'greater' operator" do + let(:size) { { greater: "2 GiB" } } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::SearchConditions::Size) + expect(config.size.value).to eq(2.GiB) + expect(config.size.operator).to eq(:greater) + end + end + + context "with 'less' operator" do + let(:size) { { less: "2 GiB" } } + + it "sets #size to the expected value" do + config = subject.convert + expect(config.size).to be_a(Agama::Storage::Configs::SearchConditions::Size) + expect(config.size.value).to eq(2.GiB) + expect(config.size.operator).to eq(:less) + end + end + end + + context "and 'number' is specified" do + let(:condition) { { number: 2 } } + it "sets #partition_number to the expected value" do + config = subject.convert + expect(config.partition_number).to eq(2) + end + end + end + + context "if 'max' is specified" do + let(:max) { 3 } + + it "sets #max to the expected value" do + config = subject.convert + expect(config.max).to eq(3) + end + end + + context "if 'ifNotFound' is specified" do + let(:if_not_found) { "skip" } + + it "sets #if_not_found to the expected value" do + config = subject.convert + expect(config.if_not_found).to eq(:skip) + end + end + end + end +end From 84abe81fed7794ebe65a63283f7e3dc76db0d63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 9 May 2025 11:51:14 +0100 Subject: [PATCH 03/12] refactor(storage): prepare search solvers for adding conditions --- service/lib/agama/storage/config_solver.rb | 13 +- service/lib/agama/storage/config_solvers.rb | 6 +- .../storage/config_solvers/devices_search.rb | 118 +++++ .../storage/config_solvers/drives_search.rb | 80 ++++ .../storage/config_solvers/md_raids_search.rb | 75 ++++ .../config_solvers/partitions_search.rb | 57 +++ .../agama/storage/config_solvers/search.rb | 225 ---------- .../storage/config_solvers/search_matchers.rb | 54 +++ .../config_solvers/with_partitions_search.rb | 38 ++ service/lib/agama/storage/proposal.rb | 2 +- service/lib/y2storage/agama_proposal.rb | 2 +- .../test/agama/storage/config_solver_test.rb | 417 +----------------- .../config_solvers/drives_search_test.rb | 277 ++++++++++++ .../config_solvers/md_raids_search_test.rb | 250 +++++++++++ .../config_solvers/partitions_search_test.rb | 246 +++++++++++ service/test/fixtures/md_raids.yaml | 61 +++ 16 files changed, 1289 insertions(+), 632 deletions(-) create mode 100644 service/lib/agama/storage/config_solvers/devices_search.rb create mode 100644 service/lib/agama/storage/config_solvers/drives_search.rb create mode 100644 service/lib/agama/storage/config_solvers/md_raids_search.rb create mode 100644 service/lib/agama/storage/config_solvers/partitions_search.rb delete mode 100644 service/lib/agama/storage/config_solvers/search.rb create mode 100644 service/lib/agama/storage/config_solvers/search_matchers.rb create mode 100644 service/lib/agama/storage/config_solvers/with_partitions_search.rb create mode 100644 service/test/agama/storage/config_solvers/drives_search_test.rb create mode 100644 service/test/agama/storage/config_solvers/md_raids_search_test.rb create mode 100644 service/test/agama/storage/config_solvers/partitions_search_test.rb create mode 100644 service/test/fixtures/md_raids.yaml diff --git a/service/lib/agama/storage/config_solver.rb b/service/lib/agama/storage/config_solver.rb index 2f4c5d6016..c4db39dbfd 100644 --- a/service/lib/agama/storage/config_solver.rb +++ b/service/lib/agama/storage/config_solver.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -34,12 +34,9 @@ module Storage class ConfigSolver # @param product_config [Agama::Config] configuration of the product to install # @param devicegraph [Y2Storage::Devicegraph] initial layout of the system - # @param disk_analyzer [Y2Storage::DiskAnalyzer, nil] optional extra information about the - # initial layout of the system - def initialize(product_config, devicegraph, disk_analyzer: nil) + def initialize(product_config, devicegraph) @product_config = product_config @devicegraph = devicegraph - @disk_analyzer = disk_analyzer end # Solves the config according to the product and the system. @@ -51,7 +48,8 @@ def solve(config) ConfigSolvers::Boot.new(product_config).solve(config) ConfigSolvers::Encryption.new(product_config).solve(config) ConfigSolvers::Filesystem.new(product_config).solve(config) - ConfigSolvers::Search.new(product_config, devicegraph, disk_analyzer).solve(config) + ConfigSolvers::DrivesSearch.new(devicegraph).solve(config) + ConfigSolvers::MdRaidsSearch.new(devicegraph).solve(config) # Sizes must be solved once the searches are solved. ConfigSolvers::Size.new(product_config, devicegraph).solve(config) end @@ -63,9 +61,6 @@ def solve(config) # @return [Y2Storage::Devicegraph] attr_reader :devicegraph - - # @return [Y2Storage::DiskAnalyzer, nil] - attr_reader :disk_analyzer end end end diff --git a/service/lib/agama/storage/config_solvers.rb b/service/lib/agama/storage/config_solvers.rb index a8e89d7c47..5c19a76f80 100644 --- a/service/lib/agama/storage/config_solvers.rb +++ b/service/lib/agama/storage/config_solvers.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,9 +20,11 @@ # find current contact information at www.suse.com. require "agama/storage/config_solvers/boot" +require "agama/storage/config_solvers/drives_search" require "agama/storage/config_solvers/encryption" require "agama/storage/config_solvers/filesystem" -require "agama/storage/config_solvers/search" +require "agama/storage/config_solvers/md_raids_search" +require "agama/storage/config_solvers/partitions_search" require "agama/storage/config_solvers/size" module Agama diff --git a/service/lib/agama/storage/config_solvers/devices_search.rb b/service/lib/agama/storage/config_solvers/devices_search.rb new file mode 100644 index 0000000000..33eed804a8 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/devices_search.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module ConfigSolvers + # Base class for solving search configs. + class DevicesSearch + # Solves the search configs by assigning devices from the candidate devices according to the + # search condition. + # + # @param device_configs [Array<#search>] + # @param candidate_devices [Array] + # + # @return [Array<#search>] Device configs with solved search. + def solve(device_configs, candidate_devices) + @candidate_devices = candidate_devices + @assigned_sids = [] + device_configs.flat_map { |d| solve_search(d) } + end + + private + + # @return [Array] + attr_reader :candidate_devices + + # SIDs of the assigned candidate devices. + # + # @return [Array] + attr_reader :assigned_sids + + # Whether the given device matches the search condition. + # + # @note Derived classes must define this method. + # + # @param _device_config [#search] + # @param _device [Y2Storage#device] + # + # @return [Boolean] + def match_condition?(_device_config, _device) + raise "#match_condition? is not defined" + end + + # Solves the search of given device config. + # + # As result, one or several configs can be generated. For example, if the search condition + # matches 3 unassigned candidate devices, then 3 configs are generated, one per device. + # + # @param device_config [#search] + # @return [#search, Array<#search>] Device configs with solved search. + def solve_search(device_config) + return device_config unless device_config.search + + devices = find_devices(device_config) + return solve_without_device(device_config) if devices.none? + + devices.map { |d| solve_with_device(device_config, d) } + end + + # Finds unassigned candidate devices that matches the search condition. + # + # @param device_config [#search] + # @return [Array] + def find_devices(device_config) + devices = unassigned_candidate_devices + .select { |d| match_condition?(device_config, d) } + .sort_by(&:name) + + devices.first(device_config.search.max || devices.size) + end + + # Candidate devices that are not assigned yet. + # + # @return [Array] + def unassigned_candidate_devices + candidate_devices.reject { |d| assigned_sids.include?(d.sid) } + end + + # Solves the search of the given device config without assigning a device. + # + # @param device_config [#search] + # @return [#search] A device config + def solve_without_device(device_config) + device_config.copy.tap { |d| d.search.solve } + end + + # Solves the search of the given device config by assigning a device. + # + # @param device_config [#search] + # @param device [Y2Storage::Device] + # + # @return [#search] A device config + def solve_with_device(device_config, device) + @assigned_sids << device.sid + device_config.copy.tap { |d| d.search.solve(device) } + end + end + end + end +end diff --git a/service/lib/agama/storage/config_solvers/drives_search.rb b/service/lib/agama/storage/config_solvers/drives_search.rb new file mode 100644 index 0000000000..96f8cfa194 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/drives_search.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_solvers/devices_search" +require "agama/storage/config_solvers/search_matchers" +require "agama/storage/config_solvers/with_partitions_search" +require "y2storage/disk_analyzer" + +module Agama + module Storage + module ConfigSolvers + # Solver for the search of the drive configs. + class DrivesSearch < DevicesSearch + include SearchMatchers + include WithPartitionsSearch + + # @param devicegraph [Y2Storage::Devicegraph] + def initialize(devicegraph) + super() + @devicegraph = devicegraph + end + + # Solves the search of the drive configs and solves the searches of their partitions. + # + # @note The config object is modified. + # + # @param config [Storage::Config] + # @return [Storage::Config] + def solve(config) + config.drives = super(config.drives, candidate_drives) + solve_partitions_search(config.drives) + config + end + + private + + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + + # @see DevicesSearch#match_condition? + # @param drive_config [Configs::Drive] + # @param drive [Y2Storage::Disk, Y2Storage::StrayBlkDevice] + # + # @return [Boolean] + def match_condition?(drive_config, drive) + match_name?(drive_config, drive) && match_size?(drive_config, drive) + end + + # Candidate drives for solving the search of drive configs. + # + # @return [Array] + def candidate_drives + disk_analyzer = Y2Storage::DiskAnalyzer.new(devicegraph) + candidate_sids = disk_analyzer.candidate_disks.map(&:sid) + + drives = devicegraph.disk_devices + devicegraph.stray_blk_devices + drives.select { |d| candidate_sids.include?(d.sid) } + end + end + end + end +end diff --git a/service/lib/agama/storage/config_solvers/md_raids_search.rb b/service/lib/agama/storage/config_solvers/md_raids_search.rb new file mode 100644 index 0000000000..c9ba53144c --- /dev/null +++ b/service/lib/agama/storage/config_solvers/md_raids_search.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_solvers/devices_search" +require "agama/storage/config_solvers/search_matchers" +require "agama/storage/config_solvers/with_partitions_search" + +module Agama + module Storage + module ConfigSolvers + # Solver for the search of the MD RAID configs. + class MdRaidsSearch < DevicesSearch + include SearchMatchers + include WithPartitionsSearch + + # @param devicegraph [Y2Storage::Devicegraph] + def initialize(devicegraph) + super() + @devicegraph = devicegraph + end + + # Solves the search of the MD RAID configs and solves the searches of their partitions. + # + # @note The config object is modified. + # + # @param config [Storage::Config] + # @return [Storage::Config] + def solve(config) + config.md_raids = super(config.md_raids, candidate_md_raids) + solve_partitions_search(config.md_raids) + config + end + + private + + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + + # @see DevicesSearch#match_condition? + # @param md_raid_config [Configs::MdRaid] + # @param md_raid [Y2Storage::Md] + # + # @return [Boolean] + def match_condition?(md_raid_config, md_raid) + match_name?(md_raid_config, md_raid) && match_size?(md_raid_config, md_raid) + end + + # Candidate MD RAIDs for solving the search of the configs. + # + # @return [Array] SIDs of the devices that are already associated to another - # search. - attr_reader :sids - - # @see #solve - # - # @note The given drive object can be modified - # - # @param original_drive [Configs::Drive] - # @return [Configs::Drive, Array] - def solve_drive(original_drive) - solve_partitionable(original_drive, :find_drives) - end - - # @see #solve - # - # @note The given mdRaid object can be modified - # - # @param original_raid [Configs::MdRaid] - # @return [Configs::MdRaid, Array] - def solve_raid(original_raid) - solve_partitionable(original_raid, :find_raids) - end - - # @see #solve_drive - # @see #solve_raid - def solve_partitionable(original_config, find_method) - devices = send(find_method, original_config.search) - return without_device(original_config) if devices.empty? - - devices.map do |device| - partitionable_copy(original_config, device) - end - end - - # Marks the search of the given config object as solved - # - # @note The config object is modified. - # - # @param config [Configs::Drive, Configs::MdRaid, Configs::Partition] - # @return [Configs::Drive, Configs::MdRaid, Configs::Partition] - def without_device(config) - config.search.solve - config - end - - # see #solve_partitionable - def partitionable_copy(original_config, device) - config = original_config.copy - config.search.solve(device) - add_found(config) - - return config unless config.partitions? - - config.partitions = config.partitions.flat_map do |partition_config| - solve_partition(partition_config, device) - end - - config - end - - # see #solve_partitionable - # - # @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_drives(search_config) - candidates = candidate_devices(search_config, default: devicegraph.blk_devices) - candidates.select! { |d| d.is?(:disk_device, :stray_blk_device) } - filter_by_disk_analyzer(candidates) - next_unassigned_devices(candidates, search_config) - end - - # @see #find_drives - # @param devices [Array] this argument is modified - def filter_by_disk_analyzer(devices) - return unless disk_analyzer - - candidate_sids = disk_analyzer.candidate_disks.map(&:sid) - devices.select! { |d| candidate_sids.include?(d.sid) } - end - - # Finds the partitions matching the given search config, if any - # - # @param search_config [Agama::Storage::Configs::Search] - # @param device [#partitions] - # - # @return [Array] - def find_partitions(search_config, device) - candidates = candidate_devices(search_config, default: device.partitions) - candidates.select! { |d| d.is?(:partition) } - next_unassigned_devices(candidates, search_config) - end - - # Finds the MD Raids matching the given search config. - # - # @param search_config [Agama::Storage::Configs::Search] - # @return [Y2Storage::Device, nil] - def find_raids(search_config) - candidates = candidate_devices(search_config, default: devicegraph.software_raids) - next_unassigned_devices(candidates, search_config) - end - - # Candidate devices for the given search config. - # - # @param search_config [Agama::Storage::Configs::Search] - # @param default [Array] Candidates if the search does not indicate - # conditions. - # @return [Array] - def candidate_devices(search_config, default: []) - return default if search_config.always_match? - - [find_device(search_config)].compact - end - - # Performs a search in the devicegraph to find a device matching the given search config. - # - # @param search_config [Agama::Storage::Configs::Search] - # @return [Y2Storage::Device] - def find_device(search_config) - devicegraph.find_by_any_name(search_config.name) - end - - # Next unassigned devices from the given list. - # - # @param devices [Array] - # @param search [Config::Search] - # @return [Array] - def next_unassigned_devices(devices, search) - devices - .reject { |d| sids.include?(d.sid) } - .sort_by(&:name) - .first(search.max || devices.size) - end - - # @see #search - # @param config [#found_device] - def add_found(config) - found = config.found_device - @sids << found.sid if found - end - end - end - end -end diff --git a/service/lib/agama/storage/config_solvers/search_matchers.rb b/service/lib/agama/storage/config_solvers/search_matchers.rb new file mode 100644 index 0000000000..66417ba3f1 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/search_matchers.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module ConfigSolvers + # Matchers for searching devices. + module SearchMatchers + # Whether the name of the given device matches the search condition. + # + # @param config [#search] + # @param device [Y2Storage::Device] + # + # @return [Boolean] + def match_name?(config, device) + search = config.search + return true unless search&.name + + found_device = device.devicegraph.find_by_any_name(search.name) + found_device&.sid == device.sid + end + + # Whether the size of the given device matches the search condition. + # + # @param _config [#search] + # @param _device [Y2Storage::Device] + # + # @return [Boolean] + def match_size?(_config, _device) + # TODO + true + end + end + end + end +end diff --git a/service/lib/agama/storage/config_solvers/with_partitions_search.rb b/service/lib/agama/storage/config_solvers/with_partitions_search.rb new file mode 100644 index 0000000000..4aa3a16c05 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/with_partitions_search.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_solvers/partitions_search" + +module Agama + module Storage + module ConfigSolvers + # Mixin for solving the search of the partitions. + module WithPartitionsSearch + # Solves the search of the partition configs from the given configs. + # + # @param configs [Array<#partitions>] + def solve_partitions_search(configs) + configs.each { |c| ConfigSolvers::PartitionsSearch.new.solve(c) } + end + end + end + end +end diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 96b4433d64..82258d3fc0 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -117,7 +117,7 @@ def solve_model(model_json) .convert ConfigSolver - .new(product_config, storage_manager.probed, disk_analyzer: disk_analyzer) + .new(product_config, storage_manager.probed) .solve(config) ConfigConversions::ToModel.new(config).convert diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index c5aed0a8d1..02a63d28e9 100644 --- a/service/lib/y2storage/agama_proposal.rb +++ b/service/lib/y2storage/agama_proposal.rb @@ -93,7 +93,7 @@ def fatal_error? # @raise [NoDiskSpaceError] if there is no enough space to perform the installation def calculate_proposal Agama::Storage::ConfigSolver - .new(product_config, initial_devicegraph, disk_analyzer: disk_analyzer) + .new(product_config, initial_devicegraph) .solve(config) issues = Agama::Storage::ConfigChecker diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index 04a1efff69..a2a8fe53e2 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -100,7 +100,6 @@ end let(:devicegraph) { Y2Storage::StorageManager.instance.probed } - let(:disk_analyzer) { nil } before do mock_storage(devicegraph: scenario) @@ -110,7 +109,7 @@ .and_return(true) end - subject { described_class.new(product_config, devicegraph, disk_analyzer: disk_analyzer) } + subject { described_class.new(product_config, devicegraph) } describe "#solve" do let(:scenario) { "empty-hd-50GiB.yaml" } @@ -467,7 +466,9 @@ end end - context "for a drive" do + context "for a drive config" do + let(:scenario) { "disks.yaml" } + let(:config_json) { { drives: drives } } let(:drives) do @@ -484,6 +485,13 @@ let(:filesystem) { nil } let(:partitions) { [] } + it "solves the search" do + subject.solve(config) + drive = config.drives.first + expect(drive.search.solved?).to eq(true) + expect(drive.search.device.name).to eq("/dev/vda") + end + encryption_proc = proc { |c| c.drives.first.encryption } include_examples "encryption", encryption_proc @@ -494,222 +502,16 @@ include_examples "partition", partitions_proc include_examples "new partition size", partitions_proc include_examples "reused partition size", partitions_proc - - context "if a drive omits the search" do - let(:drives) do - [ - {}, - {}, - {} - ] - end - - let(:scenario) { "disks.yaml" } - - it "sets the first unassigned device to the drive" do - subject.solve(config) - 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 - - context "and any of the devices are excluded from the list of candidate devices" do - let(:disk_analyzer) { instance_double(Y2Storage::DiskAnalyzer) } - before do - allow(disk_analyzer).to receive(:candidate_disks).and_return [ - devicegraph.find_by_name("/dev/vdb"), devicegraph.find_by_name("/dev/vdc") - ] - end - - it "sets the first unassigned candidate devices to the drive" do - subject.solve(config) - searches = config.drives.map(&:search) - expect(searches[0].solved?).to eq(true) - expect(searches[0].device.name).to eq("/dev/vdb") - expect(searches[1].solved?).to eq(true) - expect(searches[1].device.name).to eq("/dev/vdc") - end - - it "does not set devices that are not installation candidates" do - subject.solve(config) - searches = config.drives.map(&:search) - expect(searches[2].solved?).to eq(true) - expect(searches[2].device).to be_nil - end - end - - context "and there is not unassigned device" do - let(:drives) do - [ - {}, - {}, - {}, - {} - ] - end - - it "does not set a device to the drive" do - subject.solve(config) - search = config.drives[3].search - expect(search.solved?).to eq(true) - expect(search.device).to be_nil - end - end - end - - context "if a drive contains an empty search" do - 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(: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(: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(:drives) do - [ - { search: search } - ] - end - - let(:scenario) { "disks.yaml" } - - context "and the device is found" do - let(:search) { "/dev/vdb" } - - it "sets the device to the drive" do - subject.solve(config) - search = config.drives.first.search - expect(search.solved?).to eq(true) - expect(search.device.name).to eq("/dev/vdb") - end - end - - 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 - expect(search.solved?).to eq(true) - expect(search.device).to be_nil - end - end - - context "and the device was already assigned" do - let(:drives) do - [ - {}, - { search: "/dev/vda" } - ] - end - - it "does not set a device to the drive" do - subject.solve(config) - search = config.drives[1].search - expect(search.solved?).to eq(true) - expect(search.device).to be_nil - end - end - - context "and there is other drive with the same device" do - let(:drives) do - [ - { search: "/dev/vdb" }, - { search: "/dev/vdb" } - ] - end - - it "only sets the device to the first drive" do - subject.solve(config) - search1, search2 = config.drives.map(&:search) - expect(search1.solved?).to eq(true) - expect(search1.device.name).to eq("/dev/vdb") - expect(search2.solved?).to eq(true) - expect(search2.device).to be_nil - end - end - end end context "for a MD RAID" do + let(:scenario) { "md_raids.yaml" } + let(:config_json) do { mdRaids: [ { + search: { max: 1 }, encryption: encryption, filesystem: filesystem, partitions: partitions @@ -722,6 +524,13 @@ let(:filesystem) { nil } let(:partitions) { [] } + it "solves the search" do + subject.solve(config) + md = config.md_raids.first + expect(md.search.solved?).to eq(true) + expect(md.search.device.name).to eq("/dev/md0") + end + encryption_proc = proc { |c| c.md_raids.first.encryption } include_examples "encryption", encryption_proc @@ -733,7 +542,7 @@ include_examples "new partition size", partitions_proc end - context "for a volume group" do + context "for a volume group config" do let(:config_json) do { volumeGroups: [ @@ -763,7 +572,7 @@ include_examples "encryption", encryption_proc end - context "for a logical volume" do + context "for a logical volume config" do let(:logical_volumes) do [ { @@ -788,184 +597,4 @@ end end end - - context "if a partition has an empty search" do - let(:config_json) do - { - drives: [{ partitions: partitions }] - } - end - - let(:partitions) do - [ - { 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 - 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) - expect(search2.device.name).to eq("/dev/vda2") - expect(search3.solved?).to eq(true) - expect(search3.device.name).to eq("/dev/vda3") - end - - context "and there are more partition searches without name" do - let(:partitions) do - [ - { search: {} }, - { search: {} }, - { search: "*" } - ] - end - - it "does not set a device to the surpluss configs" do - subject.solve(config) - 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 }] - } - end - - let(:partitions) do - [ - { search: search } - ] - end - - let(:scenario) { "disks.yaml" } - - search_proc = proc { |c| c.drives.first.partitions.first.search } - - context "and the partition is found" do - let(:search) { "/dev/vda2" } - - it "sets the partition to the config" do - subject.solve(config) - search = search_proc.call(config) - expect(search.solved?).to eq(true) - expect(search.device.name).to eq("/dev/vda2") - end - end - - 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) - expect(search.solved?).to eq(true) - expect(search.device).to be_nil - end - end - - context "and the device was already assigned" do - let(:partitions) do - [ - { search: {} }, - { search: "/dev/vda1" } - ] - end - - it "does not set a partition to the config" do - subject.solve(config) - search = config.drives.first.partitions.last.search - expect(search.solved?).to eq(true) - expect(search.device).to be_nil - end - end - - context "and there is other partition with the same device" do - let(:partitions) do - [ - { search: "/dev/vda2" }, - { search: "/dev/vda2" } - ] - end - - it "only sets the partition to the first config" do - subject.solve(config) - search1, search2 = config.drives.first.partitions.map(&:search) - expect(search1.solved?).to eq(true) - expect(search1.device.name).to eq("/dev/vda2") - expect(search2.solved?).to eq(true) - expect(search2.device).to be_nil - 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/agama/storage/config_solvers/drives_search_test.rb b/service/test/agama/storage/config_solvers/drives_search_test.rb new file mode 100644 index 0000000000..bb9f60ea1c --- /dev/null +++ b/service/test/agama/storage/config_solvers/drives_search_test.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_solvers/drives_search" +require "y2storage" + +describe Agama::Storage::ConfigSolvers::DrivesSearch do + include Agama::RSpec::StorageHelpers + + subject { described_class.new(devicegraph) } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + before do + mock_storage(devicegraph: scenario) + end + + describe "#solve" do + let(:scenario) { "disks.yaml" } + + let(:config_json) { { drives: drives } } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + context "if a drive config has the default search" do + let(:drives) do + [ + {}, + {}, + {} + ] + end + + it "sets the first unassigned device to the drive config" do + subject.solve(config) + expect(config.drives.size).to eq(3) + + drive1, drive2, drive3 = config.drives + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vda") + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device.name).to eq("/dev/vdb") + expect(drive3.search.solved?).to eq(true) + expect(drive3.search.device.name).to eq("/dev/vdc") + end + + context "and any of the devices are excluded from the list of candidate devices" do + before do + allow_any_instance_of(Y2Storage::DiskAnalyzer) + .to(receive(:candidate_disks)) + .and_return( + [devicegraph.find_by_name("/dev/vdb"), devicegraph.find_by_name("/dev/vdc")] + ) + end + + it "sets the first unassigned candidate device to the drive config" do + subject.solve(config) + expect(config.drives.size).to eq(3) + + drive1, drive2, drive3 = config.drives + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vdb") + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device.name).to eq("/dev/vdc") + expect(drive3.search.solved?).to eq(true) + expect(drive3.search.device).to be_nil + end + end + + context "and there is not unassigned device" do + let(:drives) do + [ + {}, + {}, + {}, + {} + ] + end + + it "does not set a device to the drive config" do + subject.solve(config) + expect(config.drives.size).to eq(4) + + drive4 = config.drives.last + expect(drive4.search.solved?).to eq(true) + expect(drive4.search.device).to be_nil + end + end + end + + context "if a drive config contains a search without condition and without max" do + let(:drives) do + [ + { search: {} } + ] + end + + it "expands the number of drive configs to match all the existing disks" do + subject.solve(config) + expect(config.drives.size).to eq(3) + + drive1, drive2, drive3 = config.drives + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vda") + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device.name).to eq("/dev/vdb") + expect(drive3.search.solved?).to eq(true) + expect(drive3.search.device.name).to eq("/dev/vdc") + end + end + + context "if a drive config contains a search without conditions but with a max" do + let(:drives) do + [ + { search: { max: max } } + ] + end + + context "and the max is equal or smaller than the number of disks" do + let(:max) { 2 } + + it "expands the number of drive configs to match the max" do + subject.solve(config) + expect(config.drives.size).to eq(2) + + drive1, drive2 = config.drives + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vda") + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device.name).to eq("/dev/vdb") + end + end + + context "and the max is bigger than the number of disks" do + let(:max) { 20 } + + it "expands the number of drive configs to match all the existing disks" do + subject.solve(config) + expect(config.drives.size).to eq(3) + + drive1, drive2, drive3 = config.drives + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vda") + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device.name).to eq("/dev/vdb") + expect(drive3.search.solved?).to eq(true) + expect(drive3.search.device.name).to eq("/dev/vdc") + end + end + end + + context "if a drive config has a search with a device name" do + let(:drives) do + [ + { search: search } + ] + end + + context "and the device is found" do + let(:search) { "/dev/vdb" } + + it "sets the device to the drive config" do + subject.solve(config) + expect(config.drives.size).to eq(1) + + drive1 = config.drives.first + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vdb") + end + end + + 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 config" do + subject.solve(config) + expect(config.drives.size).to eq(1) + + drive1 = config.drives.first + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device).to be_nil + end + end + + context "and the device was already assigned" do + let(:drives) do + [ + {}, + { search: "/dev/vda" } + ] + end + + it "does not set a device to the drive config" do + subject.solve(config) + expect(config.drives.size).to eq(2) + + _, drive2 = config.drives + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device).to be_nil + end + end + + context "and there is other drive config with the same device" do + let(:drives) do + [ + { search: "/dev/vdb" }, + { search: "/dev/vdb" } + ] + end + + it "only sets the device to the first drive config" do + subject.solve(config) + expect(config.drives.size).to eq(2) + + drive1, drive2 = config.drives + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vdb") + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device).to be_nil + end + end + end + + context "if a drive config has partitions with search" do + let(:drives) do + [ + { + partitions: [ + { search: {} } + ] + } + ] + end + + it "solves the search of the partitions" do + subject.solve(config) + partitions = config.drives.first.partitions + expect(partitions.size).to eq(3) + + p1, p2, p3 = partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/vda1") + expect(p2.search.solved?).to eq(true) + expect(p2.search.device.name).to eq("/dev/vda2") + expect(p3.search.solved?).to eq(true) + expect(p3.search.device.name).to eq("/dev/vda3") + end + end + end +end diff --git a/service/test/agama/storage/config_solvers/md_raids_search_test.rb b/service/test/agama/storage/config_solvers/md_raids_search_test.rb new file mode 100644 index 0000000000..524a57487d --- /dev/null +++ b/service/test/agama/storage/config_solvers/md_raids_search_test.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_solvers/md_raids_search" +require "y2storage" + +describe Agama::Storage::ConfigSolvers::MdRaidsSearch do + include Agama::RSpec::StorageHelpers + + subject { described_class.new(devicegraph) } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + before do + mock_storage(devicegraph: scenario) + end + + describe "#solve" do + let(:scenario) { "md_raids.yaml" } + + let(:config_json) { { mdRaids: md_raids } } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + context "if a MD RAID config has a search for any device" do + let(:md_raids) do + [ + { search: { max: 1 } }, + { search: { max: 1 } } + ] + end + + it "sets the first unassigned device to the MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(2) + + md1, md2 = config.md_raids + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md0") + expect(md2.search.solved?).to eq(true) + expect(md2.search.device.name).to eq("/dev/md1") + end + + context "and there is not unassigned device" do + let(:md_raids) do + [ + { search: { max: 1 } }, + { search: { max: 1 } }, + { search: { max: 1 } }, + { search: { max: 1 } } + ] + end + + it "does not set a device to the MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(4) + + md4 = config.md_raids.last + expect(md4.search.solved?).to eq(true) + expect(md4.search.device).to be_nil + end + end + end + + context "if a MD RAID config contains a search without condition and without max" do + let(:md_raids) do + [ + { search: {} } + ] + end + + it "expands the number of MD RAID configs to match all the existing MD RAIDs" do + subject.solve(config) + expect(config.md_raids.size).to eq(3) + + md1, md2, md3 = config.md_raids + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md0") + expect(md2.search.solved?).to eq(true) + expect(md2.search.device.name).to eq("/dev/md1") + expect(md3.search.solved?).to eq(true) + expect(md3.search.device.name).to eq("/dev/md2") + end + end + + context "if a MD RAID config contains a search without conditions but with a max" do + let(:md_raids) do + [ + { search: { max: max } } + ] + end + + context "and the max is equal or smaller than the number of MD RAIDs" do + let(:max) { 2 } + + it "expands the number of MD RAID configs to match the max" do + subject.solve(config) + expect(config.md_raids.size).to eq(2) + + md1, md2 = config.md_raids + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md0") + expect(md2.search.solved?).to eq(true) + expect(md2.search.device.name).to eq("/dev/md1") + end + end + + context "and the max is bigger than the number of MD RAIDs" do + let(:max) { 20 } + + it "expands the number of MD RAID configs to match all the existing MD RAIDs" do + subject.solve(config) + expect(config.md_raids.size).to eq(3) + + md1, md2, md3 = config.md_raids + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md0") + expect(md2.search.solved?).to eq(true) + expect(md2.search.device.name).to eq("/dev/md1") + expect(md3.search.solved?).to eq(true) + expect(md3.search.device.name).to eq("/dev/md2") + end + end + end + + context "if a MD RAID config has a search with a device name" do + let(:md_raids) do + [ + { search: search } + ] + end + + context "and the device is found" do + let(:search) { "/dev/md1" } + + it "sets the device to the MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(1) + + md1 = config.md_raids.first + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md1") + end + end + + context "and the device is not found" do + let(:search) { "/dev/md3" } + + # 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 MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(1) + + md1 = config.md_raids.first + expect(md1.search.solved?).to eq(true) + expect(md1.search.device).to be_nil + end + end + + context "and the device was already assigned" do + let(:md_raids) do + [ + { search: { max: 1 } }, + { search: "/dev/md0" } + ] + end + + it "does not set a device to the MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(2) + + _, md2 = config.md_raids + expect(md2.search.solved?).to eq(true) + expect(md2.search.device).to be_nil + end + end + + context "and there is other MD RAID config with the same device" do + let(:md_raids) do + [ + { search: "/dev/md1" }, + { search: "/dev/md1" } + ] + end + + it "only sets the device to the first MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(2) + + md1, md2 = config.md_raids + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md1") + expect(md2.search.solved?).to eq(true) + expect(md2.search.device).to be_nil + end + end + end + + context "if a MD RAID config has partitions with search" do + let(:md_raids) do + [ + { + search: "/dev/md0", + partitions: [ + { search: {} } + ] + } + ] + end + + it "solves the search of the partitions" do + subject.solve(config) + partitions = config.md_raids.first.partitions + expect(partitions.size).to eq(2) + + p1, p2 = partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/md0p1") + expect(p2.search.solved?).to eq(true) + expect(p2.search.device.name).to eq("/dev/md0p2") + end + end + end +end diff --git a/service/test/agama/storage/config_solvers/partitions_search_test.rb b/service/test/agama/storage/config_solvers/partitions_search_test.rb new file mode 100644 index 0000000000..cd269bfa0e --- /dev/null +++ b/service/test/agama/storage/config_solvers/partitions_search_test.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_solvers/partitions_search" +require "y2storage" + +describe Agama::Storage::ConfigSolvers::PartitionsSearch do + include Agama::RSpec::StorageHelpers + + subject { described_class.new } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + before do + mock_storage(devicegraph: scenario) + end + + describe "#solve" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + partitions: partitions + } + ] + } + end + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + let(:drive) { config.drives.first } + + context "if the drive config is not solved yet" do + let(:partitions) do + [ + { search: {} }, + { search: {} } + ] + end + + it "does not set a partition to the partition configs" do + subject.solve(drive) + p1, p2 = drive.partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device).to be_nil + expect(p2.search.solved?).to eq(true) + expect(p2.search.device).to be_nil + end + end + + context "if the drive config is solved" do + before do + drive.search.solve(disk) + end + + let(:disk) { devicegraph.find_by_name("/dev/vda") } + + context "if a partition config has a search without condition and without max" do + let(:partitions) do + [ + { search: {} } + ] + end + + it "expands the number of partition configs to match all the existing partitions" do + subject.solve(drive) + partitions = drive.partitions + expect(partitions.size).to eq(3) + + p1, p2, p3 = partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/vda1") + expect(p2.search.solved?).to eq(true) + expect(p2.search.device.name).to eq("/dev/vda2") + expect(p3.search.solved?).to eq(true) + expect(p3.search.device.name).to eq("/dev/vda3") + end + + context "and there are more partition searches without name" do + let(:partitions) do + [ + { search: {} }, + { search: {} }, + { search: "*" } + ] + end + + it "does not set a device to the surpluss configs" do + subject.solve(drive) + partitions = drive.partitions + expect(partitions.size).to eq(5) + + _, _, _, p4, p5 = partitions + expect(p4.search.solved?).to eq(true) + expect(p4.search.device).to be_nil + expect(p5.search.solved?).to eq(true) + expect(p5.search.device).to be_nil + end + end + end + + context "if a partition config contains a search without condition but with a max" do + let(:partitions) do + [ + { search: { max: max } } + ] + end + + 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(drive) + partitions = drive.partitions + expect(partitions.size).to eq(2) + + p1, p2 = partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/vda1") + expect(p2.search.solved?).to eq(true) + expect(p2.search.device.name).to eq("/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(drive) + partitions = drive.partitions + expect(partitions.size).to eq(3) + + p1, p2, p3 = partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/vda1") + expect(p2.search.solved?).to eq(true) + expect(p2.search.device.name).to eq("/dev/vda2") + expect(p3.search.solved?).to eq(true) + expect(p3.search.device.name).to eq("/dev/vda3") + end + end + end + + context "if a partition config has a search with a device name" do + let(:partitions) do + [ + { search: search } + ] + end + + context "and the partition is found" do + let(:search) { "/dev/vda2" } + + it "sets the partition to the partition config" do + subject.solve(drive) + partitions = drive.partitions + expect(partitions.size).to eq(1) + + p1 = partitions.first + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/vda2") + end + end + + 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 partition config" do + subject.solve(drive) + partitions = drive.partitions + expect(partitions.size).to eq(1) + + p1 = partitions.first + expect(p1.search.solved?).to eq(true) + expect(p1.search.device).to be_nil + end + end + + context "and the device was already assigned" do + let(:partitions) do + [ + { search: {} }, + { search: "/dev/vda1" } + ] + end + + it "does not set a partition to the config" do + subject.solve(drive) + p = drive.partitions.last + expect(p.search.solved?).to eq(true) + expect(p.search.device).to be_nil + end + end + + context "and there is other partition with the same device" do + let(:partitions) do + [ + { search: "/dev/vda2" }, + { search: "/dev/vda2" } + ] + end + + it "only sets the partition to the first partition config" do + subject.solve(drive) + expect(partitions.size).to eq(2) + + p1, p2 = drive.partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/vda2") + expect(p2.search.solved?).to eq(true) + expect(p2.search.device).to be_nil + end + end + end + end + end +end diff --git a/service/test/fixtures/md_raids.yaml b/service/test/fixtures/md_raids.yaml new file mode 100644 index 0000000000..2ba337aefc --- /dev/null +++ b/service/test/fixtures/md_raids.yaml @@ -0,0 +1,61 @@ +--- +- disk: + name: /dev/vda + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vda1 + - partition: + size: 10 GiB + name: /dev/vda2 + - partition: + size: 10 GiB + name: /dev/vda3 +- disk: + name: /dev/vdb + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdb1 + - partition: + size: 10 GiB + name: /dev/vdb2 + - partition: + size: 10 GiB + name: /dev/vdb3 +- md: + name: "/dev/md0" + chunk_size: 16 KiB + partition_table: gpt + partitions: + - partition: + size: 1 GiB + name: /dev/md0p1 + - partition: + size: 1 GiB + name: /dev/md0p2 + md_devices: + - md_device: + blk_device: /dev/vda1 + - md_device: + blk_device: /dev/vdb1 +- md: + name: "/dev/md1" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vda2 + - md_device: + blk_device: /dev/vdb2 +- md: + name: "/dev/md2" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vda3 + - md_device: + blk_device: /dev/vdb3 From b52aabd149f5b29f83f7d37b26fd6273c237b887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 13 May 2025 12:43:10 +0100 Subject: [PATCH 04/12] storage: add search by size --- .../storage/config_solvers/search_matchers.rb | 21 ++- .../config_solvers/drives_search_test.rb | 134 +++++++++++++---- .../config_solvers/md_raids_search_test.rb | 134 +++++++++++++---- .../config_solvers/partitions_search_test.rb | 136 ++++++++++++++---- service/test/fixtures/sizes.yaml | 61 ++++++++ 5 files changed, 409 insertions(+), 77 deletions(-) create mode 100644 service/test/fixtures/sizes.yaml diff --git a/service/lib/agama/storage/config_solvers/search_matchers.rb b/service/lib/agama/storage/config_solvers/search_matchers.rb index 66417ba3f1..2b8b203bec 100644 --- a/service/lib/agama/storage/config_solvers/search_matchers.rb +++ b/service/lib/agama/storage/config_solvers/search_matchers.rb @@ -40,13 +40,24 @@ def match_name?(config, device) # Whether the size of the given device matches the search condition. # - # @param _config [#search] - # @param _device [Y2Storage::Device] + # @param config [#search] + # @param device [Y2Storage::Device] # # @return [Boolean] - def match_size?(_config, _device) - # TODO - true + def match_size?(config, device) + size = config.search&.size + return true unless size&.value + + case size.operator + when :equal + device.size == size.value + when :greater + device.size > size.value + when :less + device.size < size.value + else + false + end end end end diff --git a/service/test/agama/storage/config_solvers/drives_search_test.rb b/service/test/agama/storage/config_solvers/drives_search_test.rb index bb9f60ea1c..066fdfed25 100644 --- a/service/test/agama/storage/config_solvers/drives_search_test.rb +++ b/service/test/agama/storage/config_solvers/drives_search_test.rb @@ -173,6 +173,52 @@ end end + context "if a drive config has a search with condition" do + let(:drives) do + [ + { search: search } + ] + end + + context "and the device was already assigned" do + let(:drives) do + [ + {}, + { search: "/dev/vda" } + ] + end + + it "does not set a device to the drive config" do + subject.solve(config) + expect(config.drives.size).to eq(2) + + _, drive2 = config.drives + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device).to be_nil + end + end + + context "and there is other drive config with the same condition" do + let(:drives) do + [ + { search: "/dev/vdb" }, + { search: "/dev/vdb" } + ] + end + + it "only sets the device to the first drive config" do + subject.solve(config) + expect(config.drives.size).to eq(2) + + drive1, drive2 = config.drives + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq("/dev/vdb") + expect(drive2.search.solved?).to eq(true) + expect(drive2.search.device).to be_nil + end + end + end + context "if a drive config has a search with a device name" do let(:drives) do [ @@ -208,42 +254,82 @@ expect(drive1.search.device).to be_nil end end + end - context "and the device was already assigned" do - let(:drives) do - [ - {}, - { search: "/dev/vda" } - ] + context "if a drive config has a search with a size" do + let(:scenario) { "sizes.yaml" } + + let(:drives) do + [ + { + search: { + condition: { size: size } + } + } + ] + end + + shared_examples "find device" do |device| + it "sets the device to the drive config" do + subject.solve(config) + expect(config.drives.size).to eq(1) + + drive1 = config.drives.first + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device.name).to eq(device) end + end + shared_examples "do not find device" do it "does not set a device to the drive config" do subject.solve(config) - expect(config.drives.size).to eq(2) + expect(config.drives.size).to eq(1) - _, drive2 = config.drives - expect(drive2.search.solved?).to eq(true) - expect(drive2.search.device).to be_nil + drive1 = config.drives.first + expect(drive1.search.solved?).to eq(true) + expect(drive1.search.device).to be_nil end end - context "and there is other drive config with the same device" do - let(:drives) do - [ - { search: "/dev/vdb" }, - { search: "/dev/vdb" } - ] + context "and the operator is :equal" do + let(:size) { { equal: value } } + + context "and there is a disk with equal size" do + let(:value) { "200 GiB" } + include_examples "find device", "/dev/vdb" end - it "only sets the device to the first drive config" do - subject.solve(config) - expect(config.drives.size).to eq(2) + context "and there is no disk with equal size" do + let(:size) { "20 GiB" } + include_examples "do not find device" + end + end - drive1, drive2 = config.drives - expect(drive1.search.solved?).to eq(true) - expect(drive1.search.device.name).to eq("/dev/vdb") - expect(drive2.search.solved?).to eq(true) - expect(drive2.search.device).to be_nil + context "and the operator is :greater" do + let(:size) { { greater: value } } + + context "and there is a disk with greater size" do + let(:value) { "100 GiB" } + include_examples "find device", "/dev/vdb" + end + + context "and there is no disk with greater size" do + let(:value) { "200 GiB" } + include_examples "do not find device" + end + end + + context "and the operator is :less" do + let(:size) { { less: value } } + + context "and there is a disk with less size" do + let(:value) { "200 GiB" } + include_examples "find device", "/dev/vda" + end + + context "and there is no disk with less size" do + let(:value) { "100 GiB" } + include_examples "do not find device" end end end diff --git a/service/test/agama/storage/config_solvers/md_raids_search_test.rb b/service/test/agama/storage/config_solvers/md_raids_search_test.rb index 524a57487d..5851b71a1a 100644 --- a/service/test/agama/storage/config_solvers/md_raids_search_test.rb +++ b/service/test/agama/storage/config_solvers/md_raids_search_test.rb @@ -147,6 +147,52 @@ end end + context "if a MD RAID config has a search with condition" do + let(:md_raids) do + [ + { search: search } + ] + end + + context "and the device was already assigned" do + let(:md_raids) do + [ + { search: { max: 1 } }, + { search: "/dev/md0" } + ] + end + + it "does not set a device to the MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(2) + + _, md2 = config.md_raids + expect(md2.search.solved?).to eq(true) + expect(md2.search.device).to be_nil + end + end + + context "and there is other MD RAID config with the same device" do + let(:md_raids) do + [ + { search: "/dev/md1" }, + { search: "/dev/md1" } + ] + end + + it "only sets the device to the first MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(2) + + md1, md2 = config.md_raids + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md1") + expect(md2.search.solved?).to eq(true) + expect(md2.search.device).to be_nil + end + end + end + context "if a MD RAID config has a search with a device name" do let(:md_raids) do [ @@ -182,42 +228,82 @@ expect(md1.search.device).to be_nil end end + end - context "and the device was already assigned" do - let(:md_raids) do - [ - { search: { max: 1 } }, - { search: "/dev/md0" } - ] + context "if a MD RAID config has a search with a size" do + let(:scenario) { "sizes.yaml" } + + let(:md_raids) do + [ + { + search: { + condition: { size: size } + } + } + ] + end + + shared_examples "find device" do |device| + it "sets the device to the MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(1) + + md1 = config.md_raids.first + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq(device) end + end + shared_examples "do not find device" do it "does not set a device to the MD RAID config" do subject.solve(config) - expect(config.md_raids.size).to eq(2) + expect(config.md_raids.size).to eq(1) - _, md2 = config.md_raids - expect(md2.search.solved?).to eq(true) - expect(md2.search.device).to be_nil + md1 = config.md_raids.first + expect(md1.search.solved?).to eq(true) + expect(md1.search.device).to be_nil end end - context "and there is other MD RAID config with the same device" do - let(:md_raids) do - [ - { search: "/dev/md1" }, - { search: "/dev/md1" } - ] + context "and the operator is :equal" do + let(:size) { { equal: value } } + + context "and there is a MD RAID with equal size" do + let(:value) { devicegraph.find_by_name("/dev/md2").size } + include_examples "find device", "/dev/md2" end - it "only sets the device to the first MD RAID config" do - subject.solve(config) - expect(config.md_raids.size).to eq(2) + context "and there is no MD RAID with equal size" do + let(:size) { "20 GiB" } + include_examples "do not find device" + end + end - md1, md2 = config.md_raids - expect(md1.search.solved?).to eq(true) - expect(md1.search.device.name).to eq("/dev/md1") - expect(md2.search.solved?).to eq(true) - expect(md2.search.device).to be_nil + context "and the operator is :greater" do + let(:size) { { greater: value } } + + context "and there is a MD RAID with greater size" do + let(:value) { "50 GiB" } + include_examples "find device", "/dev/md2" + end + + context "and there is no MD RAID with greater size" do + let(:value) { "200 GiB" } + include_examples "do not find device" + end + end + + context "and the operator is :less" do + let(:size) { { less: value } } + + context "and there is a MD RAID with less size" do + let(:value) { "20 GiB" } + include_examples "find device", "/dev/md0" + end + + context "and there is no MD RAID with less size" do + let(:value) { "10 GiB" } + include_examples "do not find device" end end end diff --git a/service/test/agama/storage/config_solvers/partitions_search_test.rb b/service/test/agama/storage/config_solvers/partitions_search_test.rb index cd269bfa0e..681ea80ac1 100644 --- a/service/test/agama/storage/config_solvers/partitions_search_test.rb +++ b/service/test/agama/storage/config_solvers/partitions_search_test.rb @@ -167,6 +167,50 @@ end end + context "if a partition config has a search with condition" do + let(:partitions) do + [ + { search: search } + ] + end + + context "and the device was already assigned" do + let(:partitions) do + [ + { search: {} }, + { search: "/dev/vda1" } + ] + end + + it "does not set a partition to the config" do + subject.solve(drive) + p = drive.partitions.last + expect(p.search.solved?).to eq(true) + expect(p.search.device).to be_nil + end + end + + context "and there is other partition with the same device" do + let(:partitions) do + [ + { search: "/dev/vda2" }, + { search: "/dev/vda2" } + ] + end + + it "only sets the partition to the first partition config" do + subject.solve(drive) + expect(partitions.size).to eq(2) + + p1, p2 = drive.partitions + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq("/dev/vda2") + expect(p2.search.solved?).to eq(true) + expect(p2.search.device).to be_nil + end + end + end + context "if a partition config has a search with a device name" do let(:partitions) do [ @@ -204,40 +248,84 @@ expect(p1.search.device).to be_nil end end + end - context "and the device was already assigned" do - let(:partitions) do - [ - { search: {} }, - { search: "/dev/vda1" } - ] + context "if a partition config has a search with a size" do + let(:scenario) { "sizes.yaml" } + + let(:partitions) do + [ + { + search: { + condition: { size: size } + } + } + ] + end + + shared_examples "find device" do |device| + it "sets the device to the partition config" do + subject.solve(drive) + partitions = drive.partitions + expect(partitions.size).to eq(1) + + p1 = partitions.first + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq(device) end + end - it "does not set a partition to the config" do + shared_examples "do not find device" do + it "does not set a device to the partition config" do subject.solve(drive) - p = drive.partitions.last - expect(p.search.solved?).to eq(true) - expect(p.search.device).to be_nil + partitions = drive.partitions + expect(partitions.size).to eq(1) + + p1 = partitions.first + expect(p1.search.solved?).to eq(true) + expect(p1.search.device).to be_nil end end - context "and there is other partition with the same device" do - let(:partitions) do - [ - { search: "/dev/vda2" }, - { search: "/dev/vda2" } - ] + context "and the operator is :equal" do + let(:size) { { equal: value } } + + context "and there is a partition with equal size" do + let(:value) { "20 GiB" } + include_examples "find device", "/dev/vda2" end - it "only sets the partition to the first partition config" do - subject.solve(drive) - expect(partitions.size).to eq(2) + context "and there is no partition with equal size" do + let(:size) { "21 GiB" } + include_examples "do not find device" + end + end - p1, p2 = drive.partitions - expect(p1.search.solved?).to eq(true) - expect(p1.search.device.name).to eq("/dev/vda2") - expect(p2.search.solved?).to eq(true) - expect(p2.search.device).to be_nil + context "and the operator is :greater" do + let(:size) { { greater: value } } + + context "and there is a partition with greater size" do + let(:value) { "20 GiB" } + include_examples "find device", "/dev/vda3" + end + + context "and there is no partition with greater size" do + let(:value) { "200 GiB" } + include_examples "do not find device" + end + end + + context "and the operator is :less" do + let(:size) { { less: value } } + + context "and there is a partition with less size" do + let(:value) { "20 GiB" } + include_examples "find device", "/dev/vda1" + end + + context "and there is no partition with less size" do + let(:value) { "1 GiB" } + include_examples "do not find device" end end end diff --git a/service/test/fixtures/sizes.yaml b/service/test/fixtures/sizes.yaml new file mode 100644 index 0000000000..b770580e65 --- /dev/null +++ b/service/test/fixtures/sizes.yaml @@ -0,0 +1,61 @@ +--- +- disk: + name: /dev/vda + size: 100 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vda1 + - partition: + size: 20 GiB + name: /dev/vda2 + - partition: + size: 30 GiB + name: /dev/vda3 +- disk: + name: /dev/vdb + size: 200 GiB + partition_table: gpt + partitions: + - partition: + size: 10 GiB + name: /dev/vdb1 + - partition: + size: 30 GiB + name: /dev/vdb2 + - partition: + size: 30 GiB + name: /dev/vdb3 +- md: + name: "/dev/md0" + chunk_size: 16 KiB + partition_table: gpt + partitions: + - partition: + size: 1 GiB + name: /dev/md0p1 + - partition: + size: 2 GiB + name: /dev/md0p2 + md_devices: + - md_device: + blk_device: /dev/vda1 + - md_device: + blk_device: /dev/vdb1 +- md: + name: "/dev/md1" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vda2 + - md_device: + blk_device: /dev/vdb2 +- md: + name: "/dev/md2" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vda3 + - md_device: + blk_device: /dev/vdb3 From 440a10dd5dc4be80f576d9a7b13a5eb39be9554c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 13 May 2025 13:03:15 +0100 Subject: [PATCH 05/12] storage: add search by partition number --- .../config_solvers/partitions_search.rb | 17 +++- .../config_solvers/partitions_search_test.rb | 96 ++++++++++--------- 2 files changed, 67 insertions(+), 46 deletions(-) diff --git a/service/lib/agama/storage/config_solvers/partitions_search.rb b/service/lib/agama/storage/config_solvers/partitions_search.rb index b1624ee837..33788ac422 100644 --- a/service/lib/agama/storage/config_solvers/partitions_search.rb +++ b/service/lib/agama/storage/config_solvers/partitions_search.rb @@ -49,7 +49,22 @@ def solve(config) # # @return [Boolean] def match_condition?(partition_config, partition) - match_name?(partition_config, partition) && match_size?(partition_config, partition) + match_name?(partition_config, partition) && + match_size?(partition_config, partition) && + match_number?(partition_config, partition) + end + + # Whether the number of the given partition matches the search condition. + # + # @param partition_config [Configs::Partition] + # @param partition [Y2Storage::Partition] + # + # @return [Boolean] + def match_number?(partition_config, partition) + search = partition_config.search + return true unless search&.partition_number + + partition.number == search.partition_number end end end diff --git a/service/test/agama/storage/config_solvers/partitions_search_test.rb b/service/test/agama/storage/config_solvers/partitions_search_test.rb index 681ea80ac1..3e7ab8c369 100644 --- a/service/test/agama/storage/config_solvers/partitions_search_test.rb +++ b/service/test/agama/storage/config_solvers/partitions_search_test.rb @@ -211,6 +211,30 @@ end end + shared_examples "find device" do |device| + it "sets the device to the partition config" do + subject.solve(drive) + partitions = drive.partitions + expect(partitions.size).to eq(1) + + p1 = partitions.first + expect(p1.search.solved?).to eq(true) + expect(p1.search.device.name).to eq(device) + end + end + + shared_examples "do not find device" do + it "does not set a device to the partition config" do + subject.solve(drive) + partitions = drive.partitions + expect(partitions.size).to eq(1) + + p1 = partitions.first + expect(p1.search.solved?).to eq(true) + expect(p1.search.device).to be_nil + end + end + context "if a partition config has a search with a device name" do let(:partitions) do [ @@ -220,33 +244,15 @@ context "and the partition is found" do let(:search) { "/dev/vda2" } - - it "sets the partition to the partition config" do - subject.solve(drive) - partitions = drive.partitions - expect(partitions.size).to eq(1) - - p1 = partitions.first - expect(p1.search.solved?).to eq(true) - expect(p1.search.device.name).to eq("/dev/vda2") - end + include_examples "find device", "/dev/vda2" end 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 partition config" do - subject.solve(drive) - partitions = drive.partitions - expect(partitions.size).to eq(1) - - p1 = partitions.first - expect(p1.search.solved?).to eq(true) - expect(p1.search.device).to be_nil - end + let(:search) { "/dev/vdb1" } + include_examples "do not find device" end end @@ -263,30 +269,6 @@ ] end - shared_examples "find device" do |device| - it "sets the device to the partition config" do - subject.solve(drive) - partitions = drive.partitions - expect(partitions.size).to eq(1) - - p1 = partitions.first - expect(p1.search.solved?).to eq(true) - expect(p1.search.device.name).to eq(device) - end - end - - shared_examples "do not find device" do - it "does not set a device to the partition config" do - subject.solve(drive) - partitions = drive.partitions - expect(partitions.size).to eq(1) - - p1 = partitions.first - expect(p1.search.solved?).to eq(true) - expect(p1.search.device).to be_nil - end - end - context "and the operator is :equal" do let(:size) { { equal: value } } @@ -329,6 +311,30 @@ end end end + + context "if a partition config has a search with a partition number" do + let(:scenario) { "sizes.yaml" } + + let(:partitions) do + [ + { + search: { + condition: { number: number } + } + } + ] + end + + context "and the partition is found" do + let(:number) { 2 } + include_examples "find device", "/dev/vda2" + end + + context "and the device is not found" do + let(:number) { 20 } + include_examples "do not find device" + end + end end end end From c3efd9dd96d0f1461178a7085d16ac2405bc7c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 13 May 2025 14:53:08 +0100 Subject: [PATCH 06/12] storage: filter candidate devices --- .../storage/config_solvers/drives_search.rb | 4 +--- .../storage/config_solvers/md_raids_search.rb | 3 ++- service/package/gem2rpm.yml | 2 +- .../config_solvers/drives_search_test.rb | 11 +++++----- .../config_solvers/md_raids_search_test.rb | 20 +++++++++++++++++++ 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/service/lib/agama/storage/config_solvers/drives_search.rb b/service/lib/agama/storage/config_solvers/drives_search.rb index 96f8cfa194..b1bac7d103 100644 --- a/service/lib/agama/storage/config_solvers/drives_search.rb +++ b/service/lib/agama/storage/config_solvers/drives_search.rb @@ -69,10 +69,8 @@ def match_condition?(drive_config, drive) # @return [Array] def candidate_drives disk_analyzer = Y2Storage::DiskAnalyzer.new(devicegraph) - candidate_sids = disk_analyzer.candidate_disks.map(&:sid) - drives = devicegraph.disk_devices + devicegraph.stray_blk_devices - drives.select { |d| candidate_sids.include?(d.sid) } + drives.select { |d| disk_analyzer.candidate_device?(d) } end end end diff --git a/service/lib/agama/storage/config_solvers/md_raids_search.rb b/service/lib/agama/storage/config_solvers/md_raids_search.rb index c9ba53144c..f93404c5aa 100644 --- a/service/lib/agama/storage/config_solvers/md_raids_search.rb +++ b/service/lib/agama/storage/config_solvers/md_raids_search.rb @@ -67,7 +67,8 @@ def match_condition?(md_raid_config, md_raid) # # @return [Array= 4.5.7 Requires: yast2-network Requires: yast2-proxy - Requires: yast2-storage-ng >= 5.0.20 + Requires: yast2-storage-ng >= 5.0.30 Requires: yast2-users %ifarch s390 s390x Requires: yast2-s390 >= 4.6.4 diff --git a/service/test/agama/storage/config_solvers/drives_search_test.rb b/service/test/agama/storage/config_solvers/drives_search_test.rb index 066fdfed25..fa9cf61bf1 100644 --- a/service/test/agama/storage/config_solvers/drives_search_test.rb +++ b/service/test/agama/storage/config_solvers/drives_search_test.rb @@ -68,13 +68,12 @@ expect(drive3.search.device.name).to eq("/dev/vdc") end - context "and any of the devices are excluded from the list of candidate devices" do + context "and any of the devices is not a candidate device" do + let(:disk_analyzer) { instance_double(Y2Storage::DiskAnalyzer) } + before do - allow_any_instance_of(Y2Storage::DiskAnalyzer) - .to(receive(:candidate_disks)) - .and_return( - [devicegraph.find_by_name("/dev/vdb"), devicegraph.find_by_name("/dev/vdc")] - ) + allow(Y2Storage::DiskAnalyzer).to receive(:new).and_return(disk_analyzer) + allow(disk_analyzer).to receive(:candidate_device?) { |d| d.name != "/dev/vda" } end it "sets the first unassigned candidate device to the drive config" do diff --git a/service/test/agama/storage/config_solvers/md_raids_search_test.rb b/service/test/agama/storage/config_solvers/md_raids_search_test.rb index 5851b71a1a..5d9a5542a7 100644 --- a/service/test/agama/storage/config_solvers/md_raids_search_test.rb +++ b/service/test/agama/storage/config_solvers/md_raids_search_test.rb @@ -65,6 +65,26 @@ expect(md2.search.device.name).to eq("/dev/md1") end + context "and any of the devices is not a candidate device" do + let(:disk_analyzer) { instance_double(Y2Storage::DiskAnalyzer) } + + before do + allow(Y2Storage::DiskAnalyzer).to receive(:new).and_return(disk_analyzer) + allow(disk_analyzer).to receive(:candidate_device?) { |d| d.name != "/dev/md0" } + end + + it "sets the first unassigned candidate device to the MD RAID config" do + subject.solve(config) + expect(config.md_raids.size).to eq(2) + + md1, md2 = config.md_raids + expect(md1.search.solved?).to eq(true) + expect(md1.search.device.name).to eq("/dev/md1") + expect(md2.search.solved?).to eq(true) + expect(md2.search.device.name).to eq("/dev/md2") + end + end + context "and there is not unassigned device" do let(:md_raids) do [ From fda022ec747dbe382352511ea0e450b4771865ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 13 May 2025 15:37:58 +0100 Subject: [PATCH 07/12] storage: check members of reused md raid --- service/lib/agama/storage/config.rb | 7 ++ .../agama/storage/config_checkers/alias.rb | 2 +- .../agama/storage/config_checkers/md_raid.rb | 112 +++++++++++++++++- .../storage/config_checkers/md_raid_test.rb | 79 +++++++++++- service/test/agama/storage/config_test.rb | 47 ++++++++ service/test/fixtures/md_disks.yaml | 15 +++ 6 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 service/test/fixtures/md_disks.yaml diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index c0232996b8..32490e2e5b 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -119,6 +119,13 @@ def filesystems supporting_filesystem.map(&:filesystem).compact end + # Configs with configurable search. + # + # @return [Array<#search>] + def supporting_search + drives + md_raids + partitions + end + # Configs with configurable encryption. # # @return [Array<#encryption>] diff --git a/service/lib/agama/storage/config_checkers/alias.rb b/service/lib/agama/storage/config_checkers/alias.rb index 7ce591e284..a1dfcc7580 100644 --- a/service/lib/agama/storage/config_checkers/alias.rb +++ b/service/lib/agama/storage/config_checkers/alias.rb @@ -103,7 +103,7 @@ def formatted_issue def partitioned_issue return unless config.alias && storage_config.supporting_partitions.include?(config) - return unless config.partitions.any? + return unless config.partitions? users = storage_config.users(config.alias) return unless users.any? diff --git a/service/lib/agama/storage/config_checkers/md_raid.rb b/service/lib/agama/storage/config_checkers/md_raid.rb index 220321c39b..3ed600810c 100644 --- a/service/lib/agama/storage/config_checkers/md_raid.rb +++ b/service/lib/agama/storage/config_checkers/md_raid.rb @@ -63,7 +63,8 @@ def issues partitions_issues, devices_issues, level_issue, - devices_size_issue + devices_size_issue, + reused_member_issues ].flatten.compact end @@ -129,6 +130,115 @@ def used_devices storage_config.potential_for_md_device .select { |d| config.devices.include?(d.alias) } end + + # Issues from the member devices of a reused MD RAID. + # + # @return [Array] + def reused_member_issues + return [] unless config.found_device + + config.found_device.devices.map { |d| reused_member_issue(d) } + end + + # Issue from the member devices of a reused MD RAID. + # + # @param device [Y2Storage::BlkDevice] + # @return [Issue, nil] + def reused_member_issue(device) + member_config = find_config(device) + return unless member_config + + formatted_reused_member_issue(member_config) || + partitioned_reused_member_issue(member_config) || + target_reused_member_issue(member_config) + end + + # Finds the config assigned to the given device. + # + # @param device [Y2Storage::BlkDevice] + # @return [#search] + def find_config(device) + storage_config.supporting_search.find { |c| c.found_device == device } + end + + # Issue if the device member is formatted. + # + # @param member_config [#search] + # @return [Issue, nil] + def formatted_reused_member_issue(member_config) + return unless storage_config.supporting_filesystem.include?(member_config) + return unless member_config.filesystem + + error( + format( + _( + # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and + # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). + "The device '%{member}' cannot be formatted because it is a device member of the " \ + "reused MD RAID %{md_raid}" + ), + member: member_config.found_device.name, + md_raid: config.found_device.name + ), + kind: :reused_md_member + ) + end + + # Issue if the device member is partitioned. + # + # @param member_config [#search] + # @return [Issue, nil] + def partitioned_reused_member_issue(member_config) + return unless storage_config.supporting_partitions.include?(member_config) + return unless member_config.partitions? + + error( + format( + _( + # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and + # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). + "The device '%{member}' cannot be partitioned because it is a device member of " \ + "the reused MD RAID %{md_raid}" + ), + member: member_config.found_device.name, + md_raid: config.found_device.name + ), + kind: :reused_md_member + ) + end + + # Issue if the device member is used by other device (e.g., as target for physical volumes). + # + # @param member_config [#search] + # @return [Issue, nil] + def target_reused_member_issue(member_config) + return unless users?(member_config) + + error( + format( + _( + # TRANSLATORS: %{member} is replaced by a device name (e.g., "/dev/vda") and + # %{md_raid} is replaced by a MD RAID name (e.g., "/dev/md0"). + "The device '%{member}' cannot be used because it is a device member of the " \ + "reused MD RAID %{md_raid}" + ), + member: member_config.found_device.name, + md_raid: config.found_device.name + ), + kind: :reused_md_member + ) + end + + # Whether the given config has any user (direct user or as target). + # + # @param config [#search] + # @return [Boolean] + def users?(config) + return false unless config.alias + + storage_config.users(config.alias).any? || + storage_config.target_users(config.alias).any? + end end end end diff --git a/service/test/agama/storage/config_checkers/md_raid_test.rb b/service/test/agama/storage/config_checkers/md_raid_test.rb index 4173bf2b5e..96fe9f79fc 100644 --- a/service/test/agama/storage/config_checkers/md_raid_test.rb +++ b/service/test/agama/storage/config_checkers/md_raid_test.rb @@ -31,9 +31,7 @@ let(:config_json) do { - drives: [ - { alias: "disk1" } - ], + drives: drives, mdRaids: [ { alias: device_alias, @@ -49,6 +47,7 @@ } end + let(:drives) { [{ alias: "disk1" }] } let(:device_alias) { nil } let(:search) { nil } let(:level) { nil } @@ -129,6 +128,80 @@ end end + context "if the MD RAID is reused" do + let(:scenario) { "md_disks.yaml" } + let(:search) { "/dev/md0" } + + before { solve_config } + + context "and there is a config reusing a device member" do + let(:drives) do + [ + { + alias: "vda", + search: "/dev/vda", + filesystem: member_filesystem, + partitions: member_partitions + } + ] + end + + let(:member_filesystem) { nil } + let(:member_partitions) { nil } + + context "and the member config has filesystem" do + let(:member_filesystem) { { path: "/" } } + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :reused_md_member, + description: /cannot be formatted.*member of.*md0/ + ) + end + end + + context "and the member config has partitions" do + let(:member_partitions) do + [ + { + filesystem: { path: "/" } + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :reused_md_member, + description: /cannot be partitioned.*member of.*md0/ + ) + end + end + + context "and the member config is used by other device" do + let(:volume_groups) do + [ + { + physicalVolumes: ["vda"] + } + ] + end + + it "includes the expected issue" do + issues = subject.issues + expect(issues).to include an_object_having_attributes( + error?: true, + kind: :reused_md_member, + description: /cannot be used.*member of.*md0/ + ) + end + end + end + end + context "if the MD RAID is valid" do let(:config_json) do { diff --git a/service/test/agama/storage/config_test.rb b/service/test/agama/storage/config_test.rb index c29301b031..8f33a83c17 100644 --- a/service/test/agama/storage/config_test.rb +++ b/service/test/agama/storage/config_test.rb @@ -468,6 +468,53 @@ end end + describe "#supporting_search" do + let(:config_json) do + { + drives: [ + {}, + { + partitions: [ + {} + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + {} + ] + } + ], + mdRaids: [ + {}, + { + partitions: [ + {} + ] + } + ] + } + end + + it "returns all configs with configurable search" do + configs = subject.supporting_search + expect(configs.size).to eq(6) + end + + it "includes all drives" do + expect(subject.supporting_search).to include(*subject.drives) + end + + it "includes all MD RAIDs" do + expect(subject.supporting_search).to include(*subject.md_raids) + end + + it "includes all partitions" do + expect(subject.supporting_search).to include(*subject.partitions) + end + end + describe "#supporting_encryption" do let(:config_json) do { diff --git a/service/test/fixtures/md_disks.yaml b/service/test/fixtures/md_disks.yaml new file mode 100644 index 0000000000..bfe51066c1 --- /dev/null +++ b/service/test/fixtures/md_disks.yaml @@ -0,0 +1,15 @@ +--- +- disk: + name: /dev/vda + size: 500 GiB +- disk: + name: /dev/vdb + size: 500 GiB +- md: + name: "/dev/md0" + chunk_size: 16 KiB + md_devices: + - md_device: + blk_device: /dev/vda + - md_device: + blk_device: /dev/vdb From 455d4cdc4db22ec4a4694c2831c5fa85e39b91fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 14 May 2025 09:52:04 +0100 Subject: [PATCH 08/12] storage: convert search conditions to json --- .../to_json_conversions/search.rb | 25 +++ .../to_json_conversions/examples.rb | 52 ------- .../to_json_conversions/search_test.rb | 145 ++++++++++++++++++ 3 files changed, 170 insertions(+), 52 deletions(-) create mode 100644 service/test/agama/storage/config_conversions/to_json_conversions/search_test.rb 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 a6eda81759..e6dca17fb2 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 @@ -46,11 +46,36 @@ def conversions # @return [Hash, nil] def convert_condition + convert_condition_name || + convert_condition_number || + convert_condition_size + end + + # @return [Hash, nil] + def convert_condition_name name = config.name || config.device&.name return unless name { name: name } end + + # @return [Hash, nil] + def convert_condition_number + number = config.partition_number + return unless number + + { number: number } + end + + # @return [Hash, nil] + def convert_condition_size + size = config.size + return unless size&.value + + { + size: { size.operator => size.value.to_i } + } + end end end end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb b/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb index eb4d966c74..152ff45f2f 100644 --- a/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb +++ b/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb @@ -124,58 +124,6 @@ } ) end - - context "if the device name is not provided" do - let(:search) { {} } - - it "generates the expected JSON" do - config_json = subject.convert - search_json = config_json[:search] - - expect(search_json).to eq( - { - ifNotFound: "error" - } - ) - end - - context "and a device was assigned" do - before do - allow_any_instance_of(Agama::Storage::Configs::Search) - .to(receive(:device)) - .and_return(device) - end - - let(:device) { instance_double(Y2Storage::BlkDevice, name: "/dev/vda") } - - it "generates the expected JSON" do - config_json = subject.convert - search_json = config_json[:search] - - expect(search_json).to eq( - { - condition: { name: "/dev/vda" }, - ifNotFound: "error" - } - ) - end - end - end - - context "if there are no conditions or limits and errors should be skipped" do - let(:search) { { ifNotFound: "skip" } } - - it "generates the expected JSON" do - config_json = subject.convert - search_json = config_json[:search] - - expect(search_json).to eq( - { - ifNotFound: "skip" - } - ) - end - end end end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/search_test.rb b/service/test/agama/storage/config_conversions/to_json_conversions/search_test.rb new file mode 100644 index 0000000000..c6e0ca410d --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_json_conversions/search_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../test_helper" +require "agama/storage/config_conversions/from_json_conversions/search" +require "agama/storage/config_conversions/to_json_conversions/search" +require "y2storage/blk_device" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ConfigConversions::ToJSONConversions::Search do + subject { described_class.new(config) } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSONConversions::Search + .new(config_json) + .convert + end + + let(:config_json) do + { + condition: condition, + ifNotFound: if_not_found, + max: max + } + end + + let(:condition) { nil } + let(:if_not_found) { nil } + let(:max) { nil } + + shared_examples "with device" do + context "and there is an assigned device" do + before do + device = instance_double(Y2Storage::BlkDevice, name: "/dev/vda") + config.solve(device) + end + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:condition]).to eq({ name: "/dev/vda" }) + end + end + end + + describe "#convert" do + it "returns a Hash" do + expect(subject.convert).to be_a(Hash) + end + + context "if #max is not configured" do + let(:max) { nil } + + it "generates the expected JSON" do + expect(subject.convert.keys).to_not include(:max) + end + end + + context "if #condition is not configured" do + let(:condition) { nil } + + context "and there is no assigned device" do + it "generates the expected JSON" do + expect(subject.convert.keys).to_not include(:condition) + end + end + + include_examples "with device" + end + + context "if #max is configured" do + let(:max) { 2 } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:max]).to eq(2) + end + end + + context "if #condition is configured to search by name" do + let(:condition) { { name: "/dev/vda" } } + + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:condition]).to eq({ name: "/dev/vda" }) + end + + end + + context "if #condition is configured to search by size" do + let(:condition) do + { size: { greater: "2 GiB" } } + end + + context "and there is no assigned device" do + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:condition]).to eq({ size: { greater: 2.GiB.to_i } }) + end + end + + include_examples "with device" + end + + context "if #condition is configured to search by partition number" do + let(:condition) { { number: 2 } } + + context "and there is no assigned device" do + it "generates the expected JSON" do + config_json = subject.convert + expect(config_json[:condition]).to eq({ number: 2 }) + end + end + + include_examples "with device" + end + + context "if #if_not_found is configured" do + let(:if_not_found) { "skip" } + + it "generates the expected JSON" do + expect(subject.convert[:ifNotFound]).to eq("skip") + end + end + end +end From 155b0a0a69a4cf41fb3dbd0a03c426636dc65032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 14 May 2025 12:44:31 +0100 Subject: [PATCH 09/12] storage: raise proper exception --- service/lib/agama/storage/config_solvers/devices_search.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/storage/config_solvers/devices_search.rb b/service/lib/agama/storage/config_solvers/devices_search.rb index 33eed804a8..14517f9c18 100644 --- a/service/lib/agama/storage/config_solvers/devices_search.rb +++ b/service/lib/agama/storage/config_solvers/devices_search.rb @@ -56,7 +56,7 @@ def solve(device_configs, candidate_devices) # # @return [Boolean] def match_condition?(_device_config, _device) - raise "#match_condition? is not defined" + raise NotImplementedError end # Solves the search of given device config. From cc47b87aa3abaf794468c7d5e98642c04db36f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 14 May 2025 12:41:35 +0100 Subject: [PATCH 10/12] storage: do not recreate a disk analyzer --- service/lib/agama/storage/config_solver.rb | 13 ++++++++++--- .../agama/storage/config_solvers/drives_search.rb | 9 ++++++--- .../agama/storage/config_solvers/md_raids_search.rb | 8 ++++++-- service/lib/agama/storage/proposal.rb | 2 +- service/lib/y2storage/agama_proposal.rb | 2 +- .../storage/config_solvers/drives_search_test.rb | 6 ++---- .../storage/config_solvers/md_raids_search_test.rb | 6 ++---- 7 files changed, 28 insertions(+), 18 deletions(-) diff --git a/service/lib/agama/storage/config_solver.rb b/service/lib/agama/storage/config_solver.rb index c4db39dbfd..f2bec9ca13 100644 --- a/service/lib/agama/storage/config_solver.rb +++ b/service/lib/agama/storage/config_solver.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_solvers" +require "y2storage/disk_analyzer" module Agama module Storage @@ -34,9 +35,12 @@ module Storage class ConfigSolver # @param product_config [Agama::Config] configuration of the product to install # @param devicegraph [Y2Storage::Devicegraph] initial layout of the system - def initialize(product_config, devicegraph) + # @param disk_analyzer [Y2Storage::DiskAnalyzer, nil] extra information about the initial + # layout of the system + def initialize(product_config, devicegraph, disk_analyzer: nil) @product_config = product_config @devicegraph = devicegraph + @disk_analyzer = disk_analyzer || Y2Storage::DiskAnalyzer.new(devicegraph) end # Solves the config according to the product and the system. @@ -48,8 +52,8 @@ def solve(config) ConfigSolvers::Boot.new(product_config).solve(config) ConfigSolvers::Encryption.new(product_config).solve(config) ConfigSolvers::Filesystem.new(product_config).solve(config) - ConfigSolvers::DrivesSearch.new(devicegraph).solve(config) - ConfigSolvers::MdRaidsSearch.new(devicegraph).solve(config) + ConfigSolvers::DrivesSearch.new(devicegraph, disk_analyzer).solve(config) + ConfigSolvers::MdRaidsSearch.new(devicegraph, disk_analyzer).solve(config) # Sizes must be solved once the searches are solved. ConfigSolvers::Size.new(product_config, devicegraph).solve(config) end @@ -61,6 +65,9 @@ def solve(config) # @return [Y2Storage::Devicegraph] attr_reader :devicegraph + + # @return [Y2Storage::DiskAnalyzer] + attr_reader :disk_analyzer end end end diff --git a/service/lib/agama/storage/config_solvers/drives_search.rb b/service/lib/agama/storage/config_solvers/drives_search.rb index b1bac7d103..dcd456b209 100644 --- a/service/lib/agama/storage/config_solvers/drives_search.rb +++ b/service/lib/agama/storage/config_solvers/drives_search.rb @@ -22,7 +22,6 @@ require "agama/storage/config_solvers/devices_search" require "agama/storage/config_solvers/search_matchers" require "agama/storage/config_solvers/with_partitions_search" -require "y2storage/disk_analyzer" module Agama module Storage @@ -33,9 +32,11 @@ class DrivesSearch < DevicesSearch include WithPartitionsSearch # @param devicegraph [Y2Storage::Devicegraph] - def initialize(devicegraph) + # @param disk_analyzer [Y2Storage::DiskAnalyzer] + def initialize(devicegraph, disk_analyzer) super() @devicegraph = devicegraph + @disk_analyzer = disk_analyzer end # Solves the search of the drive configs and solves the searches of their partitions. @@ -55,6 +56,9 @@ def solve(config) # @return [Y2Storage::Devicegraph] attr_reader :devicegraph + # @return [Y2Storage::DiskAnalyzer] + attr_reader :disk_analyzer + # @see DevicesSearch#match_condition? # @param drive_config [Configs::Drive] # @param drive [Y2Storage::Disk, Y2Storage::StrayBlkDevice] @@ -68,7 +72,6 @@ def match_condition?(drive_config, drive) # # @return [Array] def candidate_drives - disk_analyzer = Y2Storage::DiskAnalyzer.new(devicegraph) drives = devicegraph.disk_devices + devicegraph.stray_blk_devices drives.select { |d| disk_analyzer.candidate_device?(d) } end diff --git a/service/lib/agama/storage/config_solvers/md_raids_search.rb b/service/lib/agama/storage/config_solvers/md_raids_search.rb index f93404c5aa..3bdc0ab60d 100644 --- a/service/lib/agama/storage/config_solvers/md_raids_search.rb +++ b/service/lib/agama/storage/config_solvers/md_raids_search.rb @@ -32,9 +32,11 @@ class MdRaidsSearch < DevicesSearch include WithPartitionsSearch # @param devicegraph [Y2Storage::Devicegraph] - def initialize(devicegraph) + # @param disk_analyzer [Y2Storage::DiskAnalyzer] + def initialize(devicegraph, disk_analyzer) super() @devicegraph = devicegraph + @disk_analyzer = disk_analyzer end # Solves the search of the MD RAID configs and solves the searches of their partitions. @@ -54,6 +56,9 @@ def solve(config) # @return [Y2Storage::Devicegraph] attr_reader :devicegraph + # @return [Y2Storage::DiskAnalyzer] + attr_reader :disk_analyzer + # @see DevicesSearch#match_condition? # @param md_raid_config [Configs::MdRaid] # @param md_raid [Y2Storage::Md] @@ -67,7 +72,6 @@ def match_condition?(md_raid_config, md_raid) # # @return [Array Date: Wed, 14 May 2025 16:18:33 +0100 Subject: [PATCH 11/12] rust: changelog --- rust/package/agama.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index cdef463894..7807d0ae96 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed May 14 15:17:25 UTC 2025 - José Iván López González + +- Add search conditions to storage schema + (gh#agama-project/agama#2338). + ------------------------------------------------------------------- Wed May 14 12:28:57 UTC 2025 - Imobach Gonzalez Sosa From 55f98562b8da6914bde135b4b43070dd42550372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 14 May 2025 16:18:48 +0100 Subject: [PATCH 12/12] service: changelog --- service/package/rubygem-agama-yast.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 8208714632..de270f3344 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed May 14 15:15:47 UTC 2025 - José Iván López González + +- Add search conditions to storage (gh#agama-project/agama#2338). + ------------------------------------------------------------------- Wed May 14 10:22:24 UTC 2025 - Knut Anderssen