From e57abc2eef7c5c9408e379659edadfb64831ccbf 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, 17 Sep 2024 15:30:10 +0100 Subject: [PATCH 1/5] schema: add size current --- .../share/examples/storage_sizes.json | 62 +++++++++++++++++++ rust/agama-lib/share/profile.schema.json | 23 ++++--- 2 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 rust/agama-lib/share/examples/storage_sizes.json diff --git a/rust/agama-lib/share/examples/storage_sizes.json b/rust/agama-lib/share/examples/storage_sizes.json new file mode 100644 index 000000000..63d52e30f --- /dev/null +++ b/rust/agama-lib/share/examples/storage_sizes.json @@ -0,0 +1,62 @@ +{ + "storage": { + "drives": [ + { + "partitions": [ + { + "size": 2048 + }, + { + "size": "10 GiB" + }, + { + "size": ["1 GiB"] + }, + { + "size": [1024, "50 GiB"] + }, + { + "size": { + "min": "1 GiB" + } + }, + { + "size": { + "min": 1024, + "max": "50 GiB" + } + }, + { + "search": {}, + "size": ["current"] + }, + { + "search": {}, + "size": [0, "current"] + }, + { + "search": {}, + "size": ["current", "10 GiB"] + }, + { + "size": { + "min": "current" + } + }, + { + "size": { + "min": 0, + "max": "current" + } + }, + { + "size": { + "min": "current", + "max": "10 GiB" + } + } + ] + } + ] + } +} diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index ef4dd56d4..b3d75975b 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -920,14 +920,19 @@ { "$ref": "#/$defs/sizeInteger" } ] }, + "sizeValueWithCurrent": { + "anyOf": [ + { "$ref": "#/$defs/sizeValue" }, + { + "title": "Current size", + "description": "The current size of the device.", + "const": "current" + } + ] + }, "size": { "title": "Size options", "anyOf": [ - { - "title": "Automatic size", - "description": "The size is auto calculated according to the product.", - "const": "auto" - }, { "$ref": "#/$defs/sizeValue" }, @@ -936,11 +941,11 @@ "description": "Lower size limit and optionally upper size limit.", "type": "array", "items": { - "$ref": "#/$defs/sizeValue" + "$ref": "#/$defs/sizeValueWithCurrent" }, "minItems": 1, "maxItems": 2, - "examples": [[1024, 2048], ["1 GiB", "5 GiB"], [1024, "2 GiB"], ["2 GiB"]] + "examples": [[1024, "current"], ["1 GiB", "5 GiB"], [1024, "2 GiB"], ["2 GiB"]] }, { "title": "Size range", @@ -950,11 +955,11 @@ "properties": { "min": { "title": "Mandatory lower size limit", - "$ref": "#/$defs/sizeValue" + "$ref": "#/$defs/sizeValueWithCurrent" }, "max": { "title": "Optional upper size limit", - "$ref": "#/$defs/sizeValue" + "$ref": "#/$defs/sizeValueWithCurrent" } } } From be2c56e9f02b209777d1cac10dda25d236a6dcb2 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, 18 Sep 2024 09:54:24 +0100 Subject: [PATCH 2/5] storage: read size current from JSON config --- .../storage/config_conversions/from_json.rb | 6 +-- .../from_json_conversions/size.rb | 40 ++++++++++++++----- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/service/lib/agama/storage/config_conversions/from_json.rb b/service/lib/agama/storage/config_conversions/from_json.rb index 14ba24df1..846a8b715 100644 --- a/service/lib/agama/storage/config_conversions/from_json.rb +++ b/service/lib/agama/storage/config_conversions/from_json.rb @@ -28,21 +28,19 @@ module Storage module ConfigConversions # Config conversion from JSON hash according to schema. class FromJSON - # @todo Replace product_config param by a ProductDefinition. - # # @param config_json [Hash] # @param product_config [Agama::Config] def initialize(config_json, product_config:) + # TODO: Replace product_config param by a ProductDefinition. @config_json = config_json @product_config = product_config end # Performs the conversion from Hash according to the JSON schema. # - # @todo Raise error if config_json does not match the JSON schema. - # # @return [Storage::Config] def convert + # TODO: Raise error if config_json does not match the JSON schema. config = FromJSONConversions::Config .new(config_json, config_builder: config_builder) .convert diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb index 19e4a1850..cb02433aa 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb @@ -55,30 +55,48 @@ def convert(default = nil) def conversions(_default) { default: false, - min: convert_size(:min), - max: convert_size(:max) || Y2Storage::DiskSize.unlimited + min: convert_min_size, + max: convert_max_size } end # @return [Y2Storage::DiskSize, nil] - def convert_size(field) + def convert_min_size value = case size_json when Hash - size_json[field] + size_json[:min] when Array - field == :max ? size_json[1] : size_json[0] + size_json[0] else size_json end - return unless value + disk_size(value) + end - begin - # This parses without legacy_units, ie. "1 GiB" != "1 GB" - Y2Storage::DiskSize.new(value) - rescue TypeError - # JSON schema validations should prevent this from happening + # @return [Y2Storage::DiskSize, nil] + def convert_max_size + value = case size_json + when Hash + size_json[:max] + when Array + size_json[1] + else + size_json end + + return Y2Storage::DiskSize.unlimited unless value + + disk_size(value) + end + + # @param value [String, Integer] e.g., "2 GiB". + # @return [Y2Storage::DiskSize, nil] nil if value is "current". + def disk_size(value) + return if value == "current" + + # This parses without legacy_units, ie. "1 GiB" != "1 GB". + Y2Storage::DiskSize.new(value) end end end From 966eb34b0c75d2c3e6de3fa24ba298c42967c025 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, 18 Sep 2024 15:26:12 +0100 Subject: [PATCH 3/5] refactor(storage): improve agama proposal - Do not remove configs if a search is not found. - Introduce ConfigSolver class. - Extend ConfigChecker to generate search issues. --- service/lib/agama/storage/config_checker.rb | 52 ++++++++++- .../storage/config_search_solver.rb} | 86 ++++--------------- service/lib/agama/storage/config_solver.rb | 48 +++++++++++ service/lib/agama/storage/configs/search.rb | 27 +++--- service/lib/y2storage/agama_proposal.rb | 70 +++++---------- .../proposal/agama_device_planner.rb | 5 +- .../proposal/agama_devices_planner.rb | 4 - .../y2storage/proposal/agama_drive_planner.rb | 2 + 8 files changed, 151 insertions(+), 143 deletions(-) rename service/lib/{y2storage/proposal/agama_searcher.rb => agama/storage/config_search_solver.rb} (57%) create mode 100644 service/lib/agama/storage/config_solver.rb diff --git a/service/lib/agama/storage/config_checker.rb b/service/lib/agama/storage/config_checker.rb index e05d920f0..281fb4447 100644 --- a/service/lib/agama/storage/config_checker.rb +++ b/service/lib/agama/storage/config_checker.rb @@ -53,10 +53,19 @@ def issues # @param config [Configs::Drive] # @return [Array] def drive_issues(config) - issues = encryption_issues(config) - partitions_issues = config.partitions.flat_map { |p| partition_issues(p) } + [ + search_issue(config), + encryption_issues(config), + partitions_issues(config) + ].flatten.compact + end - issues + partitions_issues + # Issues from partitions. + # + # @param config [Configs::Drive] + # @return [Array] + def partitions_issues(config) + config.partitions.flat_map { |p| partition_issues(p) } end # Issues from a partition config. @@ -64,7 +73,10 @@ def drive_issues(config) # @param config [Configs::Partition] # @return [Array] def partition_issues(config) - encryption_issues(config) + [ + search_issue(config), + encryption_issues(config) + ].flatten.compact end # Issues from a volume group config. @@ -91,6 +103,26 @@ def logical_volume_issues(lv_config, vg_config) ].compact.flatten end + # Issue for not found device. + # + # @param config [Configs::Drive, Configs::Partition] + # @return [Agama::Issue] + def search_issue(config) + return if !config.search || config.found_device + + if config.is_a?(Agama::Storage::Configs::Drive) + if config.search.skip_device? + warning(_("No device found for an optional drive")) + else + error(_("No device found for a mandatory drive")) + end + elsif config.search.skip_device? + warning(_("No device found for an optional partition")) + else + error(_("No device found for a mandatory partition")) + end + end + # @see #logical_volume_issues # # @param lv_config [Configs::LogicalVolume] @@ -200,6 +232,18 @@ def wrong_encryption_method_issue(config) ) end + # Creates a warning issue. + # + # @param message [String] + # @return [Issue] + def warning(message) + Agama::Issue.new( + message, + source: Agama::Issue::Source::CONFIG, + severity: Agama::Issue::Severity::WARN + ) + end + # Creates an error issue. # # @param message [String] diff --git a/service/lib/y2storage/proposal/agama_searcher.rb b/service/lib/agama/storage/config_search_solver.rb similarity index 57% rename from service/lib/y2storage/proposal/agama_searcher.rb rename to service/lib/agama/storage/config_search_solver.rb index 06d968cc9..72a772b13 100644 --- a/service/lib/y2storage/proposal/agama_searcher.rb +++ b/service/lib/agama/storage/config_search_solver.rb @@ -19,44 +19,28 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/issue" - -module Y2Storage - module Proposal - # Auxiliary class to handle the 'search' elements within a storage configuration - class AgamaSearcher - include Yast::Logger - include Yast::I18n - +module Agama + module Storage + # Solver for the search configs. + class ConfigSearchSolver # @param devicegraph [Devicegraph] used to find the corresponding devices that will get # associated to each search element. def initialize(devicegraph) - textdomain "agama" - @devicegraph = devicegraph end - # Resolve all the 'search' elements within a given configuration - # - # The first argument (the storage configuration) gets modified in several ways: + # Solves all the search configs within a given config. # - # - All its 'search' elements get resolved, associating devices from the devicegraph - # (first argument) if some is found. - # - Some device definitions can get removed if configured to be skipped in absence of a - # corresponding device + # @note The config object is modified. # - # The second argument (the list of issues) gets modified by adding any found problem. - # - # @param config [Agama::Storage::Config] storage configuration containing device definitions - # like drives, volume groups, etc. - # @param issues_list [Array] - def search(config, issues_list) + # @param config [Agama::Storage::Config] + def solve(config) @sids = [] config.drives.each do |drive_config| device = find_drive(drive_config.search) - drive_config.search.resolve(device) + drive_config.search.solve(device) - process_element(drive_config, config.drives, issues_list) + add_found(drive_config) next unless drive_config.found_device && drive_config.partitions? @@ -64,8 +48,8 @@ def search(config, issues_list) next unless partition_config.search partition = find_partition(partition_config.search, drive_config.found_device) - partition_config.search.resolve(partition) - process_element(partition_config, drive_config.partitions, issues_list) + partition_config.search.solve(partition) + add_found(partition_config) end end end @@ -129,48 +113,10 @@ def next_unassigned_device(devices) end # @see #search - def process_element(element, collection, issues_list) - found = element.found_device - if found - @sids << found.sid - else - issues_list << not_found_issue(element) - collection.delete(element) if element.search.skip_device? - end - end - - # Issue generated if a corresponding device is not found for the given element - # - # @param element [Agama::Storage::Configs::Drive, Agama::Storage::Configs::Partition] - # @return [Agama::Issue] - def not_found_issue(element) - Agama::Issue.new( - issue_message(element), - source: Agama::Issue::Source::CONFIG, - severity: issue_severity(element.search) - ) - end - - # @see #not_found_issue - def issue_message(element) - if element.is_a?(Agama::Storage::Configs::Drive) - if element.search.skip_device? - _("No device found for an optional drive") - else - _("No device found for a mandatory drive") - end - elsif element.search.skip_device? - _("No device found for an optional partition") - else - _("No device found for a mandatory partition") - end - end - - # @see #not_found_issue - def issue_severity(search) - return Agama::Issue::Severity::WARN if search.skip_device? - - Agama::Issue::Severity::ERROR + # @param config [#found_device] + def add_found(config) + found = config.found_device + @sids << found.sid if found end end end diff --git a/service/lib/agama/storage/config_solver.rb b/service/lib/agama/storage/config_solver.rb new file mode 100644 index 000000000..e03989d5a --- /dev/null +++ b/service/lib/agama/storage/config_solver.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_search_solver" + +module Agama + module Storage + # Class for solving a storage config. + class ConfigSolver + # @param devicegraph [Y2Storage::Devicegraph] + def initialize(devicegraph) + @devicegraph = devicegraph + end + + # Solves the given config with information from the devicegrah. + # + # @note The config object is modified. + # + # @param config [Config] + def solve(config) + ConfigSearchSolver.new(devicegraph).solve(config) + end + + private + + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + end + end +end diff --git a/service/lib/agama/storage/configs/search.rb b/service/lib/agama/storage/configs/search.rb index 0f886c4cd..207a48a28 100644 --- a/service/lib/agama/storage/configs/search.rb +++ b/service/lib/agama/storage/configs/search.rb @@ -39,36 +39,37 @@ class Search # Constructor def initialize + @solved = false @if_not_found = :error end - # Whether the search does not define any specific condition. + # Whether the search was already solved. # # @return [Boolean] - def any_device? - name.nil? + def solved? + @solved end - # Whether the search was already resolved. + # Solves the search with the given device. # - # @return [Boolean] - def resolved? - !!@resolved + # @param device [Y2Storage::Device, nil] + def solve(device = nil) + @device = device + @solved = true end - # Resolves the search with the given device. + # Whether the search does not define any specific condition. # - # @param device [Y2Storage::Device, nil] - def resolve(device = nil) - @device = device - @resolved = true + # @return [Boolean] + def any_device? + name.nil? end # Whether the section containing the search should be skipped # # @return [Boolean] def skip_device? - resolved? && device.nil? && if_not_found == :skip + solved? && device.nil? && if_not_found == :skip end end end diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index 0212912bd..5dca7796d 100644 --- a/service/lib/y2storage/agama_proposal.rb +++ b/service/lib/y2storage/agama_proposal.rb @@ -19,46 +19,19 @@ # 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_checker" +require "agama/storage/config_solver" require "yast" +require "y2storage/exceptions" +require "y2storage/planned" require "y2storage/proposal" -require "y2storage/proposal/agama_searcher" -require "y2storage/proposal/agama_space_maker" -require "y2storage/proposal/agama_devices_planner" require "y2storage/proposal/agama_devices_creator" +require "y2storage/proposal/agama_devices_planner" +require "y2storage/proposal/agama_space_maker" require "y2storage/proposal/planned_devices_handler" -require "y2storage/exceptions" -require "y2storage/planned" module Y2Storage - # Class to calculate a storage proposal for auto-installation using Agama. - # - # @note The storage config (initial_settings param in constructor) is modified in several ways: - # * The search configs are resolved. - # * Every config with an unfound search (e.g., a drive config, a partition config) is removed if - # its search has #if_not_found set to skip. - # - # It would be preferable to work over a copy instead of modifying the given config. In some - # cases, the config object is needed to generate its JSON format. The JSON result would not - # be 100% accurate if some elements are removed. - # - # The original config without removing elements is needed if: - # * The current proposal is the initial proposal automatically calculated by Agama. In - # this case, the config is generated from the product definition. The config JSON format is - # obtained by converting the config object to JSON. - # * The current proposal was calculated from a settings following the guided schema. This - # usually happens when a proposal is calculated from the UI. In this case, a config is - # generated from the guided settings. The config JSON format is obtained by converting the - # config object to JSON. - # - # In those two cases (initial proposal and proposal from guided settings) no elements are - # removed from the config because it has no searches with skip: - # * The config from the product definition has a drive that fails with unfound search (i.e., - # there is no candidate device for installing the system). - # * The config from the guided settings has all drives and partitions with search set to - # error. The proposal fails if the selected devices are not found. - # - # In the future there could be any other scenario in which it would be needed to keep all the - # elements from an initial config containing searches with skip. + # Class to calculate a storage proposal for Agama. # # @example Creating a proposal from the current Agama configuration # config = Agama::Storage::Config.new_from_json(config_json) @@ -81,19 +54,19 @@ class AgamaProposal < Proposal::Base # @return [Array] List of found issues attr_reader :issues_list - # Constructor + # @note The storage config (first param) is modified in several ways: + # * The search configs are solved. # - # @param initial_config [Agama::Storage::Config] Agama storage config - # @param devicegraph [Devicegraph] starting point. If nil, then probed devicegraph - # will be used - # @param disk_analyzer [DiskAnalyzer] by default, the method will create a new one - # based on the initial devicegraph or will use the one from the StorageManager if - # starting from probed (i.e. 'devicegraph' argument is also missing) - # @param issues_list [Array] + def planned_devices raise NotImplementedError end @@ -149,6 +149,7 @@ def configure_partitions(planned, device_config, config) partition_configs = device_config.partitions .reject(&:delete?) .reject(&:delete_if_needed?) + .reject { |c| c.search&.skip_device? } planned.partitions = partition_configs.map do |partition_config| planned_partition(partition_config, device_config, config) diff --git a/service/lib/y2storage/proposal/agama_devices_planner.rb b/service/lib/y2storage/proposal/agama_devices_planner.rb index 3624f6f21..a8788e040 100644 --- a/service/lib/y2storage/proposal/agama_devices_planner.rb +++ b/service/lib/y2storage/proposal/agama_devices_planner.rb @@ -19,7 +19,6 @@ # 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_checker" require "y2storage/planned/devices_collection" require "y2storage/proposal/agama_drive_planner" require "y2storage/proposal/agama_vg_planner" @@ -45,9 +44,6 @@ def initialize(devicegraph, issues_list) # @param config [Agama::Storage::Config] # @return [Planned::DevicesCollection] def planned_devices(config) - checker = Agama::Storage::ConfigChecker.new(config) - issues_list.concat(checker.issues) - # In the future this will also include planned devices that are equivalent to # those typically generated by the Guided Proposal. For those, note that: # - For dedicated VGs it creates a Planned VG containing a Planned LV, but no PVs diff --git a/service/lib/y2storage/proposal/agama_drive_planner.rb b/service/lib/y2storage/proposal/agama_drive_planner.rb index a99e573fb..b5b30282b 100644 --- a/service/lib/y2storage/proposal/agama_drive_planner.rb +++ b/service/lib/y2storage/proposal/agama_drive_planner.rb @@ -31,6 +31,8 @@ class AgamaDrivePlanner < AgamaDevicePlanner # # @return [Array] def planned_devices(drive_config, config) + return [] if drive_config.search&.skip_device? + [planned_drive(drive_config, config)] end From 947ed798626d96960299e851543441e1d7324128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 23 Sep 2024 15:39:02 +0100 Subject: [PATCH 4/5] storage: add size solver - New config solver for sizes (current, defaults, etc). - Move logic related to fallback sizes to ConfigBuilder. --- service/lib/agama/storage/config.rb | 98 +--- service/lib/agama/storage/config_builder.rb | 116 ++++ .../storage/config_conversions/from_json.rb | 18 +- .../lib/agama/storage/config_size_solver.rb | 206 +++++++ service/lib/agama/storage/config_solver.rb | 13 +- service/lib/agama/storage/configs/search.rb | 7 + service/lib/agama/storage/configs/size.rb | 2 +- .../storage/proposal_strategies/agama.rb | 7 +- service/lib/y2storage/agama_proposal.rb | 16 +- .../config_conversions/from_json_test.rb | 526 ++++++++++-------- service/test/agama/storage/proposal_test.rb | 5 +- service/test/y2storage/agama_proposal_test.rb | 459 ++++++++++++--- 12 files changed, 1022 insertions(+), 451 deletions(-) create mode 100644 service/lib/agama/storage/config_size_solver.rb diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index 7384c447c..05e532675 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -19,7 +19,8 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/configs" +require "agama/storage/configs/boot" +require "agama/storage/config_conversions/from_json" module Agama module Storage @@ -92,24 +93,6 @@ def implicit_boot_device root_drive&.found_device&.name end - # Sets min and max sizes for all partitions and logical volumes with default size - # - # @param volume_builder [VolumeTemplatesBuilder] used to check the configuration of the - # product volume templates - def calculate_default_sizes(volume_builder) - default_size_devices.each do |dev| - dev.size.min = default_size(dev, :min, volume_builder) - dev.size.max = default_size(dev, :max, volume_builder) - end - end - - private - - # return [Array] - def filesystems - (drives + partitions + logical_volumes).map(&:filesystem).compact - end - # return [Array] def partitions drives.flat_map(&:partitions) @@ -119,83 +102,6 @@ def partitions def logical_volumes volume_groups.flat_map(&:logical_volumes) end - - # return [Array] - def default_size_devices - (partitions + logical_volumes).select { |p| p.size&.default? } - end - - # Min or max size that should be used for the given partition or logical volume - # - # @param device [Configs::Partition] device configured to have a default size - # @param attr [Symbol] :min or :max - # @param builder [VolumeTemplatesBuilder] see {#calculate_default_sizes} - def default_size(device, attr, builder) - path = device.filesystem&.path || "" - vol = builder.for(path) - return fallback_size(attr) unless vol - - # Theoretically, neither Volume#min_size or Volume#max_size can be nil - # At most they will be zero or unlimited, respectively - return vol.send(:"#{attr}_size") unless vol.auto_size? - - outline = vol.outline - size = size_with_fallbacks(outline, attr, builder) - size = size_with_ram(size, outline) - size_with_snapshots(size, device, outline) - end - - # TODO: these are the fallbacks used when constructing volumes, not sure if repeating them - # here is right - def fallback_size(attr) - return Y2Storage::DiskSize.zero if attr == :min - - Y2Storage::DiskSize.unlimited - end - - # @see #default_size - def size_with_fallbacks(outline, attr, builder) - fallback_paths = outline.send(:"#{attr}_size_fallback_for") - missing_paths = fallback_paths.reject { |p| proposed_path?(p) } - - size = outline.send(:"base_#{attr}_size") - missing_paths.inject(size) { |total, p| total + builder.for(p).send(:"#{attr}_size") } - end - - # @see #default_size - def size_with_ram(initial_size, outline) - return initial_size unless outline.adjust_by_ram? - - [initial_size, ram_size].max - end - - # @see #default_size - def size_with_snapshots(initial_size, device, outline) - return initial_size unless device.filesystem.btrfs_snapshots? - return initial_size unless outline.snapshots_affect_sizes? - - if outline.snapshots_size && outline.snapshots_size > DiskSize.zero - initial_size + outline.snapshots_size - else - multiplicator = 1.0 + (outline.snapshots_percentage / 100.0) - initial_size * multiplicator - end - end - - # Whether there is a separate filesystem configured for the given path - # - # @param path [String, Pathname] - # @return [Boolean] - def proposed_path?(path) - filesystems.any? { |fs| fs.path?(path) } - end - - # Return the total amount of RAM as DiskSize - # - # @return [DiskSize] current RAM size - def ram_size - @ram_size ||= Y2Storage::DiskSize.new(Y2Storage::StorageManager.instance.arch.ram_size) - end end end end diff --git a/service/lib/agama/storage/config_builder.rb b/service/lib/agama/storage/config_builder.rb index 7a5738858..edf9a22b9 100644 --- a/service/lib/agama/storage/config_builder.rb +++ b/service/lib/agama/storage/config_builder.rb @@ -22,6 +22,9 @@ require "agama/storage/configs" require "agama/storage/proposal_settings_reader" require "agama/storage/volume_templates_builder" +require "pathname" +require "y2storage/disk_size" +require "y2storage/storage_manager" module Agama module Storage @@ -55,6 +58,37 @@ def default_filesystem(path = nil) end end + # Default size config from the product definition. + # + # The size defined by the product depends on the mount path of the device. That size can be + # increased because some reasons: + # * Fallback sizes: the size of other path is added to the volume. For example, if /home is + # not present, then the root volume increases its min and max limits by adding the min and + # max limits from the missing /home. The having_paths parameter is used to indicate what + # paths are present. The product defines the fallback paths. + # * Snapshots size: a device can be configured to use snapshots. The default size limits + # could be increased if snapshots are used. The with_snapshots parameter indicates whether + # to add the snapshots size. The product defines the snapshots size. + # * RAM size: the product defines whether the volume for a specific path should be as big as + # the RAM size. + # + # @param path [String, nil] Mount path of the device. + # @param having_paths [Array] Paths where other devices are mounted. + # @param with_snapshots [Boolean] Whether to add the Btrfs snapshots size. + # @return [Configs::Size] + def default_size(path = nil, having_paths: [], with_snapshots: true) + volume = volume_builder.for(path || "") + + return unlimited_size unless volume + + return auto_size(volume.outline, having_paths, with_snapshots) if volume.auto_size? + + Configs::Size.new.tap do |config| + config.min = volume.min_size + config.max = volume.max_size + end + end + private # @return [Agama::Config] @@ -73,6 +107,88 @@ def default_fstype(path = nil) end end + # @return [Configs::Size] + def unlimited_size + Configs::Size.new.tap do |config| + config.min = Y2Storage::DiskSize.zero + config.max = Y2Storage::DiskSize.unlimited + end + end + + # @see #default_size + # + # @param outline [VolumeOutline] + # @param paths [Array] + # @param snapshots [Boolean] + # + # @return [Configs::Size] + def auto_size(outline, paths, snapshots) + min_fallbacks = remove_paths(outline.min_size_fallback_for, paths) + min_size_fallbacks = min_fallbacks.map { |p| volume_builder.for(p).min_size } + min = min_size_fallbacks.reduce(outline.base_min_size, &:+) + + max_fallbacks = remove_paths(outline.max_size_fallback_for, paths) + max_size_fallbacks = max_fallbacks.map { |p| volume_builder.for(p).max_size } + max = max_size_fallbacks.reduce(outline.base_max_size, &:+) + + if outline.adjust_by_ram? + min = size_with_ram(min) + max = size_with_ram(max) + end + + if snapshots + min = size_with_snapshots(min, outline) + max = size_with_snapshots(max, outline) + end + + Configs::Size.new.tap do |config| + config.min = min + config.max = max + end + end + + # @see #default_size + # + # @param size [Y2Storage::DiskSize] + # @return [Y2Storage::DiskSize] + def size_with_ram(size) + [size, ram_size].max + end + + # @see #default_size + # + # @param size [Y2Storage::DiskSize] + # @param outline [VolumeOutline] + # + # @return [Y2Storage::DiskSize] + def size_with_snapshots(size, outline) + return size unless outline.snapshots_affect_sizes? + + if outline.snapshots_size && outline.snapshots_size > Y2Storage::DiskSize.zero + size + outline.snapshots_size + else + multiplicator = 1.0 + (outline.snapshots_percentage / 100.0) + size * multiplicator + end + end + + # @param paths [Array] + # @param paths_to_remove [Array] + # + # @return [Array] + def remove_paths(paths, paths_to_remove) + paths.reject do |path| + paths_to_remove.any? { |p| Pathname.new(p).cleanpath == Pathname.new(path).cleanpath } + end + end + + # Total amount of RAM. + # + # @return [DiskSize] + def ram_size + @ram_size ||= Y2Storage::DiskSize.new(Y2Storage::StorageManager.instance.arch.ram_size) + end + # @return [ProposalSettings] def settings @settings ||= ProposalSettingsReader.new(product_config).read diff --git a/service/lib/agama/storage/config_conversions/from_json.rb b/service/lib/agama/storage/config_conversions/from_json.rb index 846a8b715..6dbc12f93 100644 --- a/service/lib/agama/storage/config_conversions/from_json.rb +++ b/service/lib/agama/storage/config_conversions/from_json.rb @@ -19,9 +19,9 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "agama/config" require "agama/storage/config_builder" require "agama/storage/config_conversions/from_json_conversions/config" -require "agama/storage/volume_templates_builder" module Agama module Storage @@ -29,11 +29,11 @@ module ConfigConversions # Config conversion from JSON hash according to schema. class FromJSON # @param config_json [Hash] - # @param product_config [Agama::Config] - def initialize(config_json, product_config:) + # @param product_config [Agama::Config, nil] + def initialize(config_json, product_config: nil) # TODO: Replace product_config param by a ProductDefinition. @config_json = config_json - @product_config = product_config + @product_config = product_config || Agama::Config.new end # Performs the conversion from Hash according to the JSON schema. @@ -41,12 +41,9 @@ def initialize(config_json, product_config:) # @return [Storage::Config] def convert # TODO: Raise error if config_json does not match the JSON schema. - config = FromJSONConversions::Config + FromJSONConversions::Config .new(config_json, config_builder: config_builder) .convert - - config.calculate_default_sizes(volume_builder) - config end private @@ -61,11 +58,6 @@ def convert def config_builder @config_builder ||= ConfigBuilder.new(product_config) end - - # @return [VolumeTemplatesBuilder] - def volume_builder - @volume_builder ||= VolumeTemplatesBuilder.new_from_config(product_config) - end end end end diff --git a/service/lib/agama/storage/config_size_solver.rb b/service/lib/agama/storage/config_size_solver.rb new file mode 100644 index 000000000..1098c0d61 --- /dev/null +++ b/service/lib/agama/storage/config_size_solver.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/configs/size" +require "agama/storage/config_builder" + +module Agama + module Storage + # Solver for the size configs. + # + # It assigns proper size values according to the product and the system. + class ConfigSizeSolver + # @param devicegraph [Y2Storage::Devicegraph] + # @param product_config [Agama::Config] + def initialize(devicegraph, product_config) + @devicegraph = devicegraph + @product_config = product_config + end + + # Solves all the size configs within a given config. + # + # @note The config object is modified. + # + # @param config [Config] + def solve(config) + @config = config + + solve_default_sizes + solve_current_sizes + end + + private + + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + + # @return [Agama::Config] + attr_reader :product_config + + # @return [Config] + attr_reader :config + + def solve_default_sizes + configs_with_default_product_size.each { |c| solve_default_product_size(c) } + configs_with_default_device_size.each { |c| solve_default_device_size(c) } + end + + def solve_current_sizes + configs_with_valid_current_size.each { |c| solve_current_size(c) } + configs_with_invalid_current_size.each { |c| solve_default_product_size(c) } + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + def solve_default_product_size(config) + config.size = size_from_product(config) + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + def solve_default_device_size(config) + config.size = size_from_device(config.found_device) + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + def solve_current_size(config) + min = config.size.min + max = config.size.max + size = size_from_device(config.found_device) + size.min = min if min + size.max = max if max + config.size = size + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + # @return [Configs::Size] + def size_from_product(config) + path = config.filesystem&.path + snapshots = config.filesystem&.btrfs_snapshots? + + paths = configs_with_filesystem + .map(&:filesystem) + .compact + .map(&:path) + .compact + + config_builder.default_size(path, having_paths: paths, with_snapshots: snapshots) + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + # @return [Configs::Size] + def size_from_device(device) + Configs::Size.new.tap do |config| + config.default = false + config.min = device.size + config.max = device.size + end + end + + # @return [Array] + def configs_with_size + configs = config.partitions + config.logical_volumes + configs.select { |c| valid?(c) } + end + + # @return [Array] + def configs_with_filesystem + configs = config.drives + config.partitions + config.logical_volumes + configs.select { |c| valid?(c) } + end + + # @return [Array] + def configs_with_default_product_size + configs_with_size.select { |c| with_default_product_size?(c) } + end + + # @return [Array] + def configs_with_default_device_size + configs_with_size.select { |c| with_default_device_size?(c) } + end + + # @return [Array] + def configs_with_valid_current_size + configs_with_size.select { |c| with_valid_current_size?(c) } + end + + # @return [Array] + def configs_with_invalid_current_size + configs_with_size.select { |c| with_invalid_current_size?(c) } + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + # @return [Boolean] + def with_default_product_size?(config) + config.size.default? && create_device?(config) + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + # @return [Boolean] + def with_default_device_size?(config) + config.size.default? && reuse_device?(config) + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + # @return [Boolean] + def with_valid_current_size?(config) + with_current_size?(config) && reuse_device?(config) + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + # @return [Boolean] + def with_invalid_current_size?(config) + with_current_size?(config) && create_device?(config) + end + + # @param config [Configs::Partition, Configs::LogicalVolume] + # @return [Boolean] + def with_current_size?(config) + !config.size.default? && (config.size.min.nil? || config.size.max.nil?) + end + + # @param config [Object] Any config from {Configs}. + # @return [Boolean] + def valid?(config) + create_device?(config) || reuse_device?(config) + end + + # @param config [Object] Any config from {Configs}. + # @return [Boolean] + def create_device?(config) + return true unless config.respond_to?(:search) + + config.search.nil? || config.search.create_device? + end + + # @param config [Object] Any config from {Configs}. + # @return [Boolean] + def reuse_device?(config) + return false unless config.respond_to?(:found_device) + + !config.found_device.nil? + end + + # @return [ConfigBuilder] + def config_builder + @config_builder ||= ConfigBuilder.new(product_config) + end + end + end +end diff --git a/service/lib/agama/storage/config_solver.rb b/service/lib/agama/storage/config_solver.rb index e03989d5a..505dc06a8 100644 --- a/service/lib/agama/storage/config_solver.rb +++ b/service/lib/agama/storage/config_solver.rb @@ -20,29 +20,38 @@ # find current contact information at www.suse.com. require "agama/storage/config_search_solver" +require "agama/storage/config_size_solver" module Agama module Storage # Class for solving a storage config. + # + # It assigns proper devices and size values according to the product and the system. class ConfigSolver # @param devicegraph [Y2Storage::Devicegraph] - def initialize(devicegraph) + # @param product_config [Agama::Config] + def initialize(devicegraph, product_config) @devicegraph = devicegraph + @product_config = product_config end - # Solves the given config with information from the devicegrah. + # Solves all the search and size configs within a given config. # # @note The config object is modified. # # @param config [Config] def solve(config) ConfigSearchSolver.new(devicegraph).solve(config) + ConfigSizeSolver.new(devicegraph, product_config).solve(config) end private # @return [Y2Storage::Devicegraph] attr_reader :devicegraph + + # @return [Agama::Config] + attr_reader :product_config end end end diff --git a/service/lib/agama/storage/configs/search.rb b/service/lib/agama/storage/configs/search.rb index 207a48a28..ecb39c2b0 100644 --- a/service/lib/agama/storage/configs/search.rb +++ b/service/lib/agama/storage/configs/search.rb @@ -71,6 +71,13 @@ def any_device? def skip_device? solved? && device.nil? && if_not_found == :skip end + + # Whether the device is not found and it has to be created. + # + # @return [Boolean] + def create_device? + solved? && device.nil? && if_not_found == :create + end end end end diff --git a/service/lib/agama/storage/configs/size.rb b/service/lib/agama/storage/configs/size.rb index 5c3d408d8..63353fe46 100644 --- a/service/lib/agama/storage/configs/size.rb +++ b/service/lib/agama/storage/configs/size.rb @@ -40,7 +40,7 @@ def initialize # @return [Boolean] def default? - !!@default + @default end end end diff --git a/service/lib/agama/storage/proposal_strategies/agama.rb b/service/lib/agama/storage/proposal_strategies/agama.rb index c34e134f7..1ae18619c 100644 --- a/service/lib/agama/storage/proposal_strategies/agama.rb +++ b/service/lib/agama/storage/proposal_strategies/agama.rb @@ -68,9 +68,10 @@ def issues # @return [Y2Storage::AgamaProposal] def agama_proposal Y2Storage::AgamaProposal.new(storage_config, - issues_list: [], - devicegraph: probed_devicegraph, - disk_analyzer: disk_analyzer) + product_config: config, + devicegraph: probed_devicegraph, + disk_analyzer: disk_analyzer, + issues_list: []) end end end diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index 5dca7796d..a386da7e4 100644 --- a/service/lib/y2storage/agama_proposal.rb +++ b/service/lib/y2storage/agama_proposal.rb @@ -56,21 +56,28 @@ class AgamaProposal < Proposal::Base # @note The storage config (first param) is modified in several ways: # * The search configs are solved. + # * The sizes are solved (setting the size of the selected device, assigning fallbacks, etc). # # @param config [Agama::Storage::Config] + # @param product_config [Agama::Config] # @param devicegraph [Devicegraph] Starting point. If nil, then probed devicegraph will be used. # @param disk_analyzer [DiskAnalyzer] By default, the method will create a new one based on the # initial devicegraph or will use the one from the StorageManager if starting from probed # (i.e. 'devicegraph' argument is also missing). # @param issues_list [Array { + "lvm" => false, + "space_policy" => "delete", + "encryption" => { + "method" => "luks2" + }, + "volumes" => ["/", "swap"], + "volume_templates" => [ + { + "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, + "btrfs" => { + "snapshots" => true, "default_subvolume" => "@", + "subvolumes" => ["home", "opt", "root", "srv"] + }, + "outline" => { + "required" => true, "snapshots_configurable" => true, + "auto_size" => { + "base_min" => "5 GiB", "base_max" => "10 GiB", + "min_fallback_for" => ["/home"], "max_fallback_for" => ["/home"], + "snapshots_increment" => "300%" + } + } + }, + { + "mount_path" => "/home", "size" => { "auto" => false, "min" => "5 GiB" }, + "filesystem" => "xfs", "outline" => { "required" => false } + }, + { + "mount_path" => "swap", "filesystem" => "swap", + "outline" => { "required" => false } + }, + { "mount_path" => "", "filesystem" => "ext4", + "size" => { "min" => "100 MiB" } } + ] + } + } + end + let(:issues_list) { [] } let(:drives) { [drive0] } @@ -98,6 +154,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end end part.size = Agama::Storage::Configs::Size.new.tap do |size| + size.default = false size.min = 8.5.GiB size.max = Y2Storage::DiskSize.unlimited end @@ -121,10 +178,8 @@ def partition_config(name: nil, filesystem: nil, size: nil) before do mock_storage(devicegraph: scenario) - end - - subject(:proposal) do - described_class.new(initial_config, issues_list: issues_list) + # To speed-up the tests + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) end let(:scenario) { "empty-hd-50GiB.yaml" } @@ -147,7 +202,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) context "if no boot devices should be created" do before do - initial_config.boot = Agama::Storage::Configs::Boot.new.tap { |b| b.configure = false } + config.boot = Agama::Storage::Configs::Boot.new.tap { |b| b.configure = false } end it "proposes to create only the root device" do @@ -650,18 +705,37 @@ def partition_config(name: nil, filesystem: nil, size: nil) context "when reusing a partition" do let(:scenario) { "disks.yaml" } - let(:drives) { [drive] } - - let(:drive) do - drive_config.tap { |c| c.partitions = [partition] } + let(:config_json) do + { + drives: [ + { + partitions: [ + { + search: name, + filesystem: { + reuseIfPossible: reuse, + path: "/", + type: "ext3" + }, + size: size + }, + { + filesystem: { + path: "/home" + } + } + ] + } + ] + } end - let(:partition) { partition_config(name: name, filesystem: "ext3", size: 20.GiB) } + let(:reuse) { nil } + + let(:size) { nil } context "if trying to reuse the file system" do - before do - partition.filesystem.reuse = true - end + let(:reuse) { true } context "and the partition is already formatted" do let(:name) { "/dev/vda2" } @@ -691,9 +765,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "if not trying to reuse the file system" do - before do - partition.filesystem.reuse = false - end + let(:reuse) { false } context "and the partition is already formatted" do let(:name) { "/dev/vda2" } @@ -722,23 +794,54 @@ def partition_config(name: nil, filesystem: nil, size: nil) end end end + + context "if no size is indicated" do + let(:name) { "/dev/vda2" } + + let(:size) { nil } + + it "does not resize the partition" do + devicegraph = proposal.propose + + vda2 = devicegraph.find_by_name("/dev/vda2") + expect(vda2.size).to eq(20.GiB) + end + end end context "when creating a new partition" do let(:scenario) { "disks.yaml" } - let(:drives) { [drive] } - - let(:drive) do - drive_config.tap { |c| c.partitions = [partition] } + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { + reuseIfPossible: reuse, + path: "/", + type: "ext3" + }, + size: size + }, + { + filesystem: { + path: "/home" + } + } + ] + } + ] + } end - let(:partition) { partition_config(filesystem: "ext3", size: 1.GiB) } + let(:reuse) { nil } + + let(:size) { nil } context "if trying to reuse the file system" do - before do - partition.filesystem.reuse = true - end + let(:reuse) { true } it "creates the file system" do devicegraph = proposal.propose @@ -750,9 +853,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "if not trying to reuse the file system" do - before do - partition.filesystem.reuse = false - end + let(:reuse) { false } it "creates the file system" do devicegraph = proposal.propose @@ -762,27 +863,250 @@ def partition_config(name: nil, filesystem: nil, size: nil) expect(filesystem.type).to eq(Y2Storage::Filesystems::Type::EXT3) end end - end - context "resizing an existing partition" do - let(:scenario) { "disks.yaml" } + context "if no size is indicated" do + let(:size) { nil } - let(:partitions0) { [root_partition, vda3] } + it "creates the partition according to the size from the product definition" do + devicegraph = proposal.propose - let(:vda3) do - Agama::Storage::Configs::Partition.new.tap do |config| - block_device_config(config, name: "/dev/vda3") - config.size = Agama::Storage::Configs::Size.new.tap do |size_config| - size_config.min = vda3_min - size_config.max = vda3_max + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "/"), + size: 10.GiB - 1.MiB + ) + ) + end + end + + context "if a size is indicated" do + let(:size) { "5 GiB" } + + it "creates the partition according to the given size" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "/"), + size: 5.GiB + ) + ) + end + end + + context "if 'current' size is indicated" do + let(:size) { { min: "current" } } + + it "creates the partition according to the size from the product definition" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "/"), + size: 10.GiB - 1.MiB + ) + ) + end + end + + context "if the size is not indicated for some partition with fallbacks" do + let(:scenario) { "empty-hd-50GiB.yaml" } + + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { + path: "/", + type: { + btrfs: { snapshots: snapshots } + } + } + }, + { + filesystem: { path: other_path } + } + ] + } + ] + } + end + + context "and the other partitions are omitted" do + let(:other_path) { nil } + let(:snapshots) { false } + + it "creates the partition adding the fallback sizes" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "/"), + size: 29.95.GiB - 2.80.MiB + ) + ) + end + + context "and snapshots are enabled" do + let(:snapshots) { true } + + it "creates the partition adding the fallback and snapshots sizes" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "/"), + size: 44.95.GiB - 2.80.MiB + ) + ) + end + end + end + + context "and the other partitions are present" do + let(:other_path) { "/home" } + let(:snapshots) { false } + + it "creates the partition ignoring the fallback sizes" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "/"), + size: 10.GiB + ) + ) + end + + context "and snapshots are enabled" do + let(:snapshots) { true } + + it "creates the partition adding the snapshots sizes" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "/"), + size: 32.50.GiB - 4.MiB + ) + ) + end end end end - before do - drive0.search.name = "/dev/vda" + context "if the partition has to be enlarged according to RAM size" do + let(:scenario) { "empty-hd-50GiB.yaml" } - allow_any_instance_of(Y2Storage::Partition).to receive(:detect_resize_info) + let(:product_data) do + { + "storage" => { + "volume_templates" => [ + { + "mount_path" => "swap", + "filesystem" => "swap", + "size" => { "auto" => true }, + "outline" => { + "auto_size" => { + "adjust_by_ram" => true, + "base_min" => "2 GiB", + "base_max" => "4 GiB" + } + } + } + ] + } + } + end + + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { + path: "swap" + }, + size: size + } + ] + } + ] + } + end + + before do + allow_any_instance_of(Y2Storage::Arch).to receive(:ram_size).and_return(8.GiB) + end + + context "and the partition size is not indicated" do + let(:size) { nil } + + it "creates the partition as big as the RAM" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "swap"), + size: 8.GiB + ) + ) + end + end + + context "and the partition size is indicated" do + let(:size) { "2 GiB" } + + it "creates the partition with the given size" do + devicegraph = proposal.propose + + expect(devicegraph.partitions).to include( + an_object_having_attributes( + filesystem: an_object_having_attributes(mount_path: "swap"), + size: 2.GiB + ) + ) + end + end + end + end + + context "resizing an existing partition" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + search: "/dev/vda", + partitions: [ + { + filesystem: { + type: "btrfs", + path: "/" + }, + size: root_size + }, + { + search: "/dev/vda3", + size: vda3_size + } + ] + } + ] + } + end + + let(:root_size) { ["8.5 GiB"] } + + let(:vda3_size) { nil } + + before do + allow_any_instance_of(Y2Storage::Partition) + .to(receive(:detect_resize_info)) .and_return(resize_info) end @@ -794,9 +1118,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "when the reused partition is expected to grow with no enforced limit" do - # Initial size, so no shrinking - let(:vda3_min) { 10.GiB } - let(:vda3_max) { Y2Storage::DiskSize.unlimited } + let(:vda3_size) { ["current"] } it "grows the device as much as allowed by the min size of the new partitions" do vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid @@ -815,9 +1137,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "when the reused partition is expected to grow up to a limit" do - # Initial size, so no shrinking - let(:vda3_min) { 10.GiB } - let(:vda3_max) { 15.GiB } + let(:vda3_size) { ["10 GiB", "15 GiB"] } it "grows the device up to the limit so the new partitions can exceed their mins" do vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid @@ -835,9 +1155,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "when the reused partition is expected to shrink as much as needed" do - let(:vda3_min) { 0.KiB } - # Initial size, so no growing - let(:vda3_max) { 10.GiB } + let(:vda3_size) { ["0 KiB", "current"] } context "if there is no need to shrink the partition" do it "does not modify the size of the partition" do @@ -855,9 +1173,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "if the partition needs to be shrunk to allocate the new ones" do - before do - root_partition.size.min = 24.GiB - end + let(:root_size) { "24 GiB" } it "shrinks the partition as needed" do vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid @@ -877,8 +1193,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "when the reused partition is expected to shrink in all cases" do - let(:vda3_min) { 0.KiB } - let(:vda3_max) { 6.GiB } + let(:vda3_size) { ["0 KiB", "6 GiB"] } context "if there is no need to shrink the partition" do it "shrinks the partition to the specified max size" do @@ -896,9 +1211,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "if the partition needs to be shrunk to allocate the new ones" do - before do - root_partition.size.min = 25.GiB - end + let(:root_size) { "25 Gib" } it "shrinks the partition as needed" do vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid @@ -921,14 +1234,6 @@ def partition_config(name: nil, filesystem: nil, size: nil) context "when the config has LVM volume groups" do let(:scenario) { "empty-hd-50GiB.yaml" } - let(:initial_config) do - Agama::Storage::ConfigConversions::FromJSON - .new(config_json, product_config: product_config) - .convert - end - - let(:product_config) { Agama::Config.new } - let(:config_json) do { drives: [ @@ -987,7 +1292,8 @@ def partition_config(name: nil, filesystem: nil, size: nil) filesystem: { path: "/home", type: "xfs" - } + }, + size: "2 GiB" } ] } @@ -1012,6 +1318,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) system_vg = devicegraph.find_by_name("/dev/system") system_pvs = system_vg.lvm_pvs.map(&:plain_blk_device) system_lvs = system_vg.lvm_lvs + expect(system_pvs).to contain_exactly( an_object_having_attributes(name: "/dev/sda2", size: 40.GiB) ) @@ -1060,7 +1367,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) an_object_having_attributes( lv_name: "home", lv_type: Y2Storage::LvType::NORMAL, - size: 5.GiB - 4.MiB, + size: 2.GiB, filesystem: an_object_having_attributes( type: Y2Storage::Filesystems::Type::XFS, mount_path: "/home" @@ -1071,14 +1378,6 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "when a LVM physical volume is not found" do - let(:initial_config) do - Agama::Storage::ConfigConversions::FromJSON - .new(config_json, product_config: product_config) - .convert - end - - let(:product_config) { Agama::Config.new } - let(:config_json) do { drives: [ @@ -1127,14 +1426,6 @@ def partition_config(name: nil, filesystem: nil, size: nil) end context "when a LVM thin pool volume is not found" do - let(:initial_config) do - Agama::Storage::ConfigConversions::FromJSON - .new(config_json, product_config: product_config) - .convert - end - - let(:product_config) { Agama::Config.new } - let(:config_json) do { drives: [ From aa00a4f5ea766a4cab85f9dcb1dfb5f4a55d8e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 23 Sep 2024 15:57:01 +0100 Subject: [PATCH 5/5] service: changelog --- service/package/rubygem-agama-yast.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 7a012b4d3..e843a97ff 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Sep 23 14:55:53 UTC 2024 - José Iván López González + +- storage: add support for resizing partitions using its current + size as min or max limit (gh#openSUSE/agama#1617). + ------------------------------------------------------------------- Fri Sep 20 13:09:47 UTC 2024 - Ancor Gonzalez Sosa