diff --git a/rust/agama-lib/share/examples/storage/drives.json b/rust/agama-lib/share/examples/storage/drives.json index a462ae4fa1..33cf33eb62 100644 --- a/rust/agama-lib/share/examples/storage/drives.json +++ b/rust/agama-lib/share/examples/storage/drives.json @@ -2,11 +2,12 @@ "storage": { "boot": { "configure": true, - "device": "/dev/vda" + "device": "vda" }, "drives": [ { "search": "/dev/vda", + "alias": "vda", "ptableType": "gpt", "partitions": [ { diff --git a/rust/agama-lib/share/examples/storage/model.json b/rust/agama-lib/share/examples/storage/model.json index a90734569a..689f502d9d 100644 --- a/rust/agama-lib/share/examples/storage/model.json +++ b/rust/agama-lib/share/examples/storage/model.json @@ -1,4 +1,11 @@ { + "boot": { + "configure": true, + "device": { + "default": false, + "name": "/dev/vda" + } + }, "drives": [ { "name": "/dev/vda", diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index e069a3fb70..967bae4bae 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -4,19 +4,38 @@ "type": "object", "additionalProperties": false, "properties": { + "boot": { "$ref": "#/$defs/boot" }, "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } } }, "$defs": { + "boot": { + "type": "object", + "additionalProperties": false, + "required": ["configure"], + "properties": { + "configure": { "type": "boolean" }, + "device": { "$ref": "#/$defs/bootDevice" } + } + }, + "bootDevice": { + "type": "object", + "additionalProperties": false, + "required": ["default"], + "properties": { + "default": { "type": "boolean" }, + "name": { "type": "string" } + } + }, "drive": { "type": "object", "additionalProperties": false, "required": ["name"], "properties": { "name": { "type": "string" }, - "alias": { "type": "string" }, + "alias": { "$ref": "#/$defs/alias" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, "spacePolicy": { "$ref": "#/$defs/spacePolicy" }, @@ -32,7 +51,7 @@ "additionalProperties": false, "properties": { "name": { "type": "string" }, - "alias": { "type": "string" }, + "alias": { "$ref": "#/$defs/alias" }, "id": { "$ref": "#/$defs/partitionId" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, @@ -43,6 +62,10 @@ "resizeIfNeeded": { "type": "boolean" } } }, + "alias": { + "description": "Alias used to reference a device.", + "type": "string" + }, "spacePolicy": { "enum": ["delete", "resize", "keep", "custom"] }, diff --git a/rust/agama-lib/share/storage.schema.json b/rust/agama-lib/share/storage.schema.json index 18d3265729..00461d4e87 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -30,11 +30,7 @@ "description": "Whether to configure partitions for booting.", "type": "boolean" }, - "device": { - "description": "The target installation device is used by default.", - "type": "string", - "examples": ["/dev/vda"] - } + "device": { "$ref": "#/$defs/alias" } } }, "driveElement": { diff --git a/service/lib/agama/storage/boot_settings.rb b/service/lib/agama/storage/boot_settings.rb new file mode 100644 index 0000000000..fe94b173c1 --- /dev/null +++ b/service/lib/agama/storage/boot_settings.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + # Boot settings. + class BootSettings + # Whether to configure partitions for booting. + # + # @return [Boolean] + attr_accessor :configure + alias_method :configure?, :configure + + # Boot device name. + # + # @return [String] + attr_accessor :device + + # Constructor + def initialize + @configure = true + end + end + end +end diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index dbc249223d..3df7501307 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -21,7 +21,6 @@ require "agama/copyable" require "agama/storage/configs/boot" -require "agama/storage/config_conversions/from_json" module Agama module Storage @@ -61,11 +60,16 @@ def initialize @nfs_mounts = [] end - # Name of the device that will presumably be used to boot the target system + # Name of the device that will be used to boot the target system, if any. # - # @return [String, nil] nil if there is no enough information to infer a possible boot disk + # @note The config has to be solved. + # + # @return [String, nil] def boot_device - explicit_boot_device || implicit_boot_device + return unless boot.configure? && boot.device.device_alias + + boot_drive = drives.find { |d| d.alias?(boot.device.device_alias) } + boot_drive&.found_device&.name end # return [Array] @@ -77,68 +81,6 @@ def partitions def logical_volumes volume_groups.flat_map(&:logical_volumes) end - - private - - # Device used for booting the target system - # - # @return [String, nil] nil if no disk is explicitly chosen - def explicit_boot_device - return nil unless boot.configure? - - boot.device - end - - # Device that seems to be expected to be used for booting, according to the drive definitions - # - # @return [String, nil] nil if the information cannot be inferred from the config - def implicit_boot_device - implicit_drive_boot_device || implicit_lvm_boot_device - end - - # @see #implicit_boot_device - # - # @return [String, nil] nil if the information cannot be inferred from the list of drives - def implicit_drive_boot_device - root_drive = drives.find do |drive| - drive.partitions.any? { |p| p.filesystem&.root? } - end - - root_drive&.found_device&.name - end - - # @see #implicit_boot_device - # - # @return [String, nil] nil if the information cannot be inferred from the list of LVM VGs - def implicit_lvm_boot_device - root_vg = root_volume_group - return nil unless root_vg - - root_drives = drives.select { |d| drive_for_vg?(d, root_vg) } - names = root_drives.map { |d| d.found_device&.name }.compact - # Return the first name in alphabetical order - names.min - end - - # @see #implicit_lvm_boot_device - # - # @return [Configs::VolumeGroup, nil] - def root_volume_group - volume_groups.find do |vg| - vg.logical_volumes.any? { |lv| lv.filesystem&.root? } - end - end - - # @see #implicit_lvm_boot_device - # - # @return [Boolean] - def drive_for_vg?(drive, volume_group) - return true if volume_group.physical_volumes_devices.any? { |d| drive.alias?(d) } - - volume_group.physical_volumes.any? do |pv| - drive.partitions.any? { |p| p.alias?(pv) } - end - end end end end diff --git a/service/lib/agama/storage/config_checker.rb b/service/lib/agama/storage/config_checker.rb index 2d0b062db5..9cefb995f4 100644 --- a/service/lib/agama/storage/config_checker.rb +++ b/service/lib/agama/storage/config_checker.rb @@ -19,24 +19,20 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/issue" -require "agama/storage/volume_templates_builder" -require "yast/i18n" -require "y2storage/mount_point" +require "agama/config" +require "agama/storage/config_checkers/boot" +require "agama/storage/config_checkers/drive" +require "agama/storage/config_checkers/volume_group" +require "agama/storage/config_checkers/volume_groups" module Agama module Storage # Class for checking a storage config. - # - # TODO: Split in smaller checkers, for example: ConfigFilesystemChecker, etc. class ConfigChecker - include Yast::I18n - - # @param config [Storage::Config] - # @param product_config [Agama::Config] - def initialize(config, product_config) - textdomain "agama" - @config = config + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config, nil] + def initialize(storage_config, product_config = nil) + @storage_config = storage_config @product_config = product_config || Agama::Config.new end @@ -44,437 +40,56 @@ def initialize(config, product_config) # # @return [Array] def issues - drives_issues + volume_groups_issues + [ + boot_issues, + drives_issues, + volume_groups_issues + ].flatten end private # @return [Storage::Config] - attr_reader :config + attr_reader :storage_config # @return [Agama::Config] attr_reader :product_config - # Issues from drives. - # - # @return [Array] - def drives_issues - config.drives.flat_map { |d| drive_issues(d) } - end - - # Issues from a drive config. - # - # @param config [Configs::Drive] - # @return [Array] - def drive_issues(config) - [ - search_issue(config), - filesystem_issues(config), - encryption_issues(config), - partitions_issues(config) - ].flatten.compact - 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 - return if config.search.skip_device? - - if config.is_a?(Agama::Storage::Configs::Drive) - error(_("No device found for a mandatory drive")) - else - error(_("No device found for a mandatory partition")) - end - end - - # Issues related to the filesystem. + # Issues from boot config. # - # @param config [#filesystem] # @return [Array] - def filesystem_issues(config) - filesystem = config.filesystem - return [] unless filesystem - - [ - missing_filesystem_issue(filesystem), - invalid_filesystem_issue(filesystem) - ].compact - end - - # @see #filesystem_issues - # - # @param config [Configs::Filesystem] - # @return [Issue, nil] - def missing_filesystem_issue(config) - return if config.reuse? - return if config.type&.fs_type - - error( - format( - # TRANSLATORS: %s is the replaced by a mount path (e.g., "/home"). - _("Missing file system type for '%s'"), - config.path - ) - ) + def boot_issues + ConfigCheckers::Boot.new(storage_config, product_config).issues end - # @see #filesystem_issues - # - # @param config [Configs::Filesystem] - # @return [Issue, nil] - def invalid_filesystem_issue(config) - return if config.reuse? - - type = config.type&.fs_type - return unless type - - path = config.path - types = suitable_filesystem_types(path) - return if types.include?(type) - - # Let's consider a type as valid if the product does not define any suitable type. - return if types.empty? - - error( - format( - # TRANSLATORS: %{filesystem} is replaced by a file system type (e.g., "Btrfs") and - # %{path} is replaced by a mount path (e.g., "/home"). - _("The file system type '%{filesystem}' is not suitable for '%{path}'"), - filesystem: type.to_human_string, - path: path - ) - ) - end - - # Issues related to encryption. + # Issues from drives. # - # @param config [Configs::Drive, Configs::Partition, Configs::LogicalVolume] # @return [Array] - def encryption_issues(config) - encryption = config.encryption - return [] unless encryption - - [ - missing_encryption_password_issue(encryption), - unavailable_encryption_method_issue(encryption), - wrong_encryption_method_issue(config) - ].compact - end - - # @see #encryption_issues - # - # @param config [Configs::Encryption] - # @return [Issue, nil] - def missing_encryption_password_issue(config) - return unless config.missing_password? - - error( - format( - # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device - # (e.g., 'luks1', 'random_swap'). - _("No passphrase provided (required for using the method '%{crypt_method}')."), - crypt_method: config.method.to_human_string - ) - ) - end - - # @see #encryption_issues - # - # @param config [Configs::Encryption] - # @return [Issue, nil] - def unavailable_encryption_method_issue(config) - method = config.method - return if !method || available_encryption_methods.include?(method) - - error( - format( - # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device - # (e.g., 'luks1', 'random_swap'). - _("Encryption method '%{crypt_method}' is not available in this system."), - crypt_method: method.to_human_string - ) - ) - end - - # @see #unavailable_encryption_method_issue - # - # @return [Array] - def available_encryption_methods - tpm_fde = Y2Storage::EncryptionMethod::TPM_FDE - - methods = Y2Storage::EncryptionMethod.available - methods << tpm_fde if tpm_fde.possible? - methods - end - - # @see #encryption_issues - # - # @param config [Configs::Drive, Configs::Partition, Configs::LogicalVolume] - # @return [Issue, nil] - def wrong_encryption_method_issue(config) - method = config.encryption&.method - return unless method&.only_for_swap? - return if config.filesystem&.path == Y2Storage::MountPoint::SWAP_PATH.to_s - - error( - format( - # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device - # (e.g., 'luks1', 'random_swap'). - _("'%{crypt_method}' is not a suitable method to encrypt the device."), - crypt_method: method.to_human_string - ) - ) + def drives_issues + storage_config.drives.flat_map { |d| drive_issues(d) } end - # 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. - # - # @param config [Configs::Partition] - # @return [Array] - def partition_issues(config) - [ - search_issue(config), - filesystem_issues(config), - encryption_issues(config) - ].flatten.compact + def drive_issues(config) + ConfigCheckers::Drive.new(config, storage_config, product_config).issues end - # Issues from volume groups. - # # @return [Array] def volume_groups_issues - [ - overused_physical_volumes_devices_issues, - config.volume_groups.flat_map { |v| volume_group_issues(v) } - ].flatten - end + section_issues = ConfigCheckers::VolumeGroups.new(storage_config, product_config).issues + issues = storage_config.volume_groups.flat_map { |v| volume_group_issues(v) } - # Issues for overused target devices for physical volumes. - # - # @note The Agama proposal is not able to calculate if the same target device is used by more - # than one volume group having several target devices. - # - # @return [Array] - def overused_physical_volumes_devices_issues - overused = overused_physical_volumes_devices - return [] if overused.none? - - overused.map do |device| - error( - format( - # TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). - _("The device '%s' is used several times as target device for physical volumes"), - device - ) - ) - end - end - - # Aliases of overused target devices for physical volumes. - # - # @return [Array] - def overused_physical_volumes_devices - config.volume_groups - .map(&:physical_volumes_devices) - .map(&:uniq) - .select { |d| d.size > 1 } - .flatten - .tally - .select { |_, v| v > 1 } - .keys - end - - # Issues from a volume group config. - # - # @param config [Configs::VolumeGroup] - # @return [Array] - def volume_group_issues(config) [ - logical_volumes_issues(config), - physical_volumes_issues(config), - physical_volumes_devices_issues(config), - physical_volumes_encryption_issues(config) + section_issues, + issues ].flatten end - # Issues from a logical volumes. - # - # @param config [Configs::VolumeGroup] - # @return [Array] - def logical_volumes_issues(config) - config.logical_volumes.flat_map { |v| logical_volume_issues(v, config) } - end - - # Issues from a logical volume config. - # - # @param lv_config [Configs::LogicalVolume] - # @param vg_config [Configs::VolumeGroup] - # - # @return [Array] - def logical_volume_issues(lv_config, vg_config) - [ - filesystem_issues(lv_config), - encryption_issues(lv_config), - missing_thin_pool_issue(lv_config, vg_config) - ].compact.flatten - end - - # @see #logical_volume_issues - # - # @param lv_config [Configs::LogicalVolume] - # @param vg_config [Configs::VolumeGroup] - # - # @return [Issue, nil] - def missing_thin_pool_issue(lv_config, vg_config) - return unless lv_config.thin_volume? - - pool = vg_config.logical_volumes - .select(&:pool?) - .find { |p| p.alias == lv_config.used_pool } - - return if pool - - error( - format( - # TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). - _("There is no LVM thin pool volume with alias '%s'"), - lv_config.used_pool - ) - ) - end - - # Issues from physical volumes. - # - # @param config [Configs::VolumeGroup] - # @return [Array] - def physical_volumes_issues(config) - config.physical_volumes.map { |v| missing_physical_volume_issue(v) }.compact - end - - # @see #physical_volumes_issues - # - # @param pv_alias [String] - # @return [Issue, nil] - def missing_physical_volume_issue(pv_alias) - configs = config.drives + config.drives.flat_map(&:partitions) - return if configs.any? { |c| c.alias == pv_alias } - - error( - format( - # TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). - _("There is no LVM physical volume with alias '%s'"), - pv_alias - ) - ) - end - - # Issues from physical volumes devices (target devices). - # # @param config [Configs::VolumeGroup] # @return [Array] - def physical_volumes_devices_issues(config) - config.physical_volumes_devices - .map { |d| missing_physical_volumes_device_issue(d) } - .compact - end - - # @see #physical_volumes_devices_issue - # - # @param device_alias [String] - # @return [Issue, nil] - def missing_physical_volumes_device_issue(device_alias) - return if config.drives.any? { |d| d.alias == device_alias } - - error( - format( - # TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). - _("There is no target device for LVM physical volumes with alias '%s'"), - device_alias - ) - ) - end - - # Issues from physical volumes encryption. - # - # @param config [Configs::VolumeGroup] - # @return [Array] - def physical_volumes_encryption_issues(config) - encryption = config.physical_volumes_encryption - return [] unless encryption - - [ - missing_encryption_password_issue(encryption), - unavailable_encryption_method_issue(encryption), - wrong_physical_volumes_encryption_method_issue(encryption) - ].compact - end - - # @see #physical_volumes_encryption_issues - # - # @param config [Configs::Encryption] - # @return [Issue, nil] - def wrong_physical_volumes_encryption_method_issue(config) - method = config.method - return if method.nil? || valid_physical_volumes_encryption_method?(method) - - error( - format( - # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device - # (e.g., 'luks1'). - _("'%{crypt_method}' is not a suitable method to encrypt the physical volumes."), - crypt_method: method.to_human_string - ) - ) - end - - # Whether an encryption method can be used for encrypting physical volumes. - # - # @param method [Y2Storage::EncryptionMethod] - # @return [Boolean] - def valid_physical_volumes_encryption_method?(method) - valid_methods = [ - Y2Storage::EncryptionMethod::LUKS1, - Y2Storage::EncryptionMethod::LUKS2, - Y2Storage::EncryptionMethod::PERVASIVE_LUKS2, - Y2Storage::EncryptionMethod::TPM_FDE - ] - - valid_methods.include?(method) - end - - # Suitable file system types for the given path. - # - # @param path [String, nil] - # @return [Array] - def suitable_filesystem_types(path = nil) - volume_builder.for(path || "").outline.filesystems - end - - # @return [VolumeTemplatesBuilder] - def volume_builder - @volume_builder ||= VolumeTemplatesBuilder.new_from_config(product_config) - end - - # Creates an error issue. - # - # @param message [String] - # @return [Issue] - def error(message) - Agama::Issue.new( - message, - source: Agama::Issue::Source::CONFIG, - severity: Agama::Issue::Severity::ERROR - ) + def volume_group_issues(config) + ConfigCheckers::VolumeGroup.new(config, storage_config, product_config).issues end end end diff --git a/service/lib/agama/storage/config_checkers.rb b/service/lib/agama/storage/config_checkers.rb new file mode 100644 index 0000000000..1903c22f07 --- /dev/null +++ b/service/lib/agama/storage/config_checkers.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/boot" +require "agama/storage/config_checkers/drive" +require "agama/storage/config_checkers/encryption" +require "agama/storage/config_checkers/filesystem" +require "agama/storage/config_checkers/logical_volume" +require "agama/storage/config_checkers/partition" +require "agama/storage/config_checkers/physical_volumes_encryption" +require "agama/storage/config_checkers/search" +require "agama/storage/config_checkers/volume_group" +require "agama/storage/config_checkers/volume_groups" + +module Agama + module Storage + # Name space for config checkers. + module ConfigCheckers + end + end +end diff --git a/service/lib/agama/storage/config_checkers/base.rb b/service/lib/agama/storage/config_checkers/base.rb new file mode 100644 index 0000000000..a55397236c --- /dev/null +++ b/service/lib/agama/storage/config_checkers/base.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/issue" + +module Agama + module Storage + module ConfigCheckers + # Base class for checking a config. + class Base + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(storage_config, product_config) + @storage_config = storage_config + @product_config = product_config + end + + # List of issues (implemented by derived classes). + # + # @return [Array] + def issues + raise "#issues is not defined" + end + + private + + # @return [Storage::Config] + attr_reader :storage_config + + # @return [Agama::Config] + attr_reader :product_config + + # Creates an error issue. + # + # @param message [String] + # @return [Issue] + def error(message) + Agama::Issue.new( + message, + source: Agama::Issue::Source::CONFIG, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/boot.rb b/service/lib/agama/storage/config_checkers/boot.rb new file mode 100644 index 0000000000..4bc0cebd3a --- /dev/null +++ b/service/lib/agama/storage/config_checkers/boot.rb @@ -0,0 +1,78 @@ +# 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_checkers/base" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking the boot config. + class Boot < Base + include Yast::I18n + + # Boot config issues. + # + # @return [Array] + def issues + [ + missing_alias_issue, + invalid_alias_issue + ].compact + end + + private + + # @return [Boolean] + def configure? + storage_config.boot.configure? + end + + # @return [String, nil] + def device_alias + storage_config.boot.device.device_alias + end + + # @return [Issue, nil] + def missing_alias_issue + return unless configure? && device_alias.nil? + + error(_("There is no boot device alias")) + end + + # @return [Issue, nil] + def invalid_alias_issue + return unless configure? && device_alias && !valid_alias? + + # TRANSLATORS: %s is replaced by a device alias (e.g., "boot"). + error(format(_("There is no boot device with alias '%s'"), device_alias)) + end + + # @return [Boolean] + def valid_alias? + return false unless device_alias + + storage_config.drives.any? { |d| d.alias?(device_alias) } + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/drive.rb b/service/lib/agama/storage/config_checkers/drive.rb new file mode 100644 index 0000000000..74ff8b528b --- /dev/null +++ b/service/lib/agama/storage/config_checkers/drive.rb @@ -0,0 +1,66 @@ +# 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_checkers/base" +require "agama/storage/config_checkers/with_encryption" +require "agama/storage/config_checkers/with_filesystem" +require "agama/storage/config_checkers/with_partitions" +require "agama/storage/config_checkers/with_search" + +module Agama + module Storage + module ConfigCheckers + # Class for checking a drive config. + class Drive < Base + include WithEncryption + include WithFilesystem + include WithPartitions + include WithSearch + + # @param config [Configs::Drive] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super(storage_config, product_config) + + @config = config + end + + # Drive config issues. + # + # @return [Array] + def issues + [ + search_issues, + filesystem_issues, + encryption_issues, + partitions_issues + ].flatten + end + + private + + # @return [Configs::Drive] + attr_reader :config + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/encryption.rb b/service/lib/agama/storage/config_checkers/encryption.rb new file mode 100644 index 0000000000..95590623e9 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/encryption.rb @@ -0,0 +1,125 @@ +# 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_checkers/base" +require "yast/i18n" +require "y2storage/encryption_method" +require "y2storage/mount_point" + +module Agama + module Storage + module ConfigCheckers + # Class for checking the encryption config. + class Encryption < Base + include Yast::I18n + + # @param config [#encryption] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super(storage_config, product_config) + + textdomain "agama" + @config = config + end + + # Encryption config issues. + # + # @return [Array] + def issues + return [] unless encryption + + [ + missing_password_issue, + unavailable_method_issue, + wrong_method_issue + ].compact + end + + private + + # @return [#encryption] + attr_reader :config + + # @return [Configs::Encryption, nil] + def encryption + config.encryption + end + + # @return [Issue, nil] + def missing_password_issue + return unless encryption.missing_password? + + error( + format( + # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device + # (e.g., 'luks1', 'random_swap'). + _("No passphrase provided (required for using the method '%{crypt_method}')."), + crypt_method: encryption.method.to_human_string + ) + ) + end + + # @return [Issue, nil] + def unavailable_method_issue + method = encryption.method + return if !method || available_encryption_methods.include?(method) + + error( + format( + # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device + # (e.g., 'luks1', 'random_swap'). + _("Encryption method '%{crypt_method}' is not available in this system."), + crypt_method: method.to_human_string + ) + ) + end + + # @see #unavailable_method_issue + # + # @return [Array] + def available_encryption_methods + tpm_fde = Y2Storage::EncryptionMethod::TPM_FDE + + methods = Y2Storage::EncryptionMethod.available + methods << tpm_fde if tpm_fde.possible? + methods + end + + # @return [Issue, nil] + def wrong_method_issue + method = encryption&.method + return unless method&.only_for_swap? + return if config.filesystem&.path == Y2Storage::MountPoint::SWAP_PATH.to_s + + error( + format( + # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device + # (e.g., 'luks1', 'random_swap'). + _("'%{crypt_method}' is not a suitable method to encrypt the device."), + crypt_method: method.to_human_string + ) + ) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/filesystem.rb b/service/lib/agama/storage/config_checkers/filesystem.rb new file mode 100644 index 0000000000..f1f87fe122 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/filesystem.rb @@ -0,0 +1,114 @@ +# 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_checkers/base" +require "agama/storage/volume_templates_builder" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking the filesystem config. + class Filesystem < Base + include Yast::I18n + + # @param config [#filesystem] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super(storage_config, product_config) + + textdomain "agama" + @config = config + end + + # Filesystem config issues. + # + # @return [Array] + def issues + return [] unless filesystem + + [ + missing_filesystem_issue, + invalid_filesystem_issue + ].compact + end + + private + + # @return [#filesystem] + attr_reader :config + + # @return [Configs::Filesystem, nil] + def filesystem + config.filesystem + end + + # @return [Issue, nil] + def missing_filesystem_issue + return if filesystem.reuse? + return if filesystem.type&.fs_type + + # TRANSLATORS: %s is the replaced by a mount path (e.g., "/home"). + error(format(_("Missing file system type for '%s'"), filesystem.path)) + end + + # @return [Issue, nil] + def invalid_filesystem_issue + return if filesystem.reuse? + + type = filesystem.type&.fs_type + return unless type + + path = filesystem.path + types = suitable_filesystem_types(path) + return if types.include?(type) + + # Let's consider a type as valid if the product does not define any suitable type. + return if types.empty? + + error( + format( + # TRANSLATORS: %{filesystem} is replaced by a file system type (e.g., "Btrfs") and + # %{path} is replaced by a mount path (e.g., "/home"). + _("The file system type '%{filesystem}' is not suitable for '%{path}'"), + filesystem: type.to_human_string, + path: path + ) + ) + end + + # Suitable file system types for the given path. + # + # @param path [String, nil] + # @return [Array] + def suitable_filesystem_types(path = nil) + volume_builder.for(path || "").outline.filesystems + end + + # @return [VolumeTemplatesBuilder] + def volume_builder + @volume_builder ||= VolumeTemplatesBuilder.new_from_config(product_config) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/logical_volume.rb b/service/lib/agama/storage/config_checkers/logical_volume.rb new file mode 100644 index 0000000000..63212855db --- /dev/null +++ b/service/lib/agama/storage/config_checkers/logical_volume.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/base" +require "agama/storage/config_checkers/with_encryption" +require "agama/storage/config_checkers/with_filesystem" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking a logical volume config. + class LogicalVolume < Base + include Yast::I18n + include WithEncryption + include WithFilesystem + + # @param config [Configs::LogicalVolume] + # @param volume_group_config [Configs::VolumeGroup] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, volume_group_config, storage_config, product_config) + super(storage_config, product_config) + + textdomain "agama" + @config = config + @volume_group_config = volume_group_config + end + + # Logical volume config issues. + # + # @return [Array] + def issues + [ + filesystem_issues, + encryption_issues, + missing_thin_pool_issue + ].compact.flatten + end + + private + + # @return [Configs::LogicalVolue] + attr_reader :config + + # @return [Configs::VolumeGroup] + attr_reader :volume_group_config + + # @return [Issue, nil] + def missing_thin_pool_issue + return unless config.thin_volume? + + pool = volume_group_config.logical_volumes + .select(&:pool?) + .find { |p| p.alias == config.used_pool } + + return if pool + + # TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). + error(format(_("There is no LVM thin pool volume with alias '%s'"), config.used_pool)) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/partition.rb b/service/lib/agama/storage/config_checkers/partition.rb new file mode 100644 index 0000000000..d05f0db563 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/partition.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/base" +require "agama/storage/config_checkers/with_encryption" +require "agama/storage/config_checkers/with_filesystem" +require "agama/storage/config_checkers/with_search" + +module Agama + module Storage + module ConfigCheckers + # Class for checking the partition config. + class Partition < Base + include WithEncryption + include WithFilesystem + include WithSearch + + # @param config [Configs::Partition] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super(storage_config, product_config) + + @config = config + end + + # Partition config issues. + # + # @return [Array] + def issues + [ + search_issues, + filesystem_issues, + encryption_issues + ].flatten.compact + end + + private + + # @return [Configs::Partition] + attr_reader :config + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb b/service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb new file mode 100644 index 0000000000..9032679a16 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/encryption" +require "yast/i18n" +require "y2storage/encryption_method" + +module Agama + module Storage + module ConfigCheckers + # Class for checking the physical volumes encryption config. + class PhysicalVolumesEncryption < Encryption + include Yast::I18n + + # @param config [Configs::VolumeGroup] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super + + textdomain "agama" + end + + private + + # @return [Configs::VolumeGroup] + attr_reader :config + + # @return [Configs::Encryption, nil] + def encryption + config.physical_volumes_encryption + end + + # @see Encryption#issues + # + # @return [Issue, nil] + def wrong_method_issue + method = encryption.method + return if method.nil? || valid_method?(method) + + error( + format( + # TRANSLATORS: 'method' is the identifier of the method to encrypt the device + # (e.g., 'luks1'). + _("'%{method}' is not a suitable method to encrypt the physical volumes."), + method: method.to_human_string + ) + ) + end + + # Whether an encryption method can be used for encrypting physical volumes. + # + # @param method [Y2Storage::EncryptionMethod] + # @return [Boolean] + def valid_method?(method) + valid_methods = [ + Y2Storage::EncryptionMethod::LUKS1, + Y2Storage::EncryptionMethod::LUKS2, + Y2Storage::EncryptionMethod::PERVASIVE_LUKS2, + Y2Storage::EncryptionMethod::TPM_FDE + ] + + valid_methods.include?(method) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/search.rb b/service/lib/agama/storage/config_checkers/search.rb new file mode 100644 index 0000000000..5d8ea9b1e4 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/search.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/base" +require "agama/storage/configs/drive" +require "agama/storage/configs/logical_volume" +require "agama/storage/configs/partition" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking the search config. + class Search < Base + include Yast::I18n + + # @param config [#search] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super(storage_config, product_config) + + textdomain "agama" + @config = config + end + + # Search config issues. + # + # @return [Array] + def issues + return [] unless search + + [not_found_issue].compact + end + + private + + # @return [#search] + attr_reader :config + + # @return [Configs::Search, nil] + def search + config.search + end + + # @return [Issue, nil] + def not_found_issue + return if search.device || search.skip_device? + + if search.name + # TRANSLATORS: %s is replaced by a device name (e.g., "/dev/vda"). + error(format(_("Mandatory device %s not found"), search.name)) + else + # TRANSLATORS: %s is replaced by a device type (e.g., "drive"). + error(format(_("Mandatory %s not found"), device_type)) + end + end + + # @return [String] + def device_type + case config + when Agama::Storage::Configs::Drive + _("drive") + when Agama::Storage::Configs::Partition + _("partition") + when Agama::Storage::Configs::LogicalVolume + _("LVM logical volume") + else + _("device") + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/volume_group.rb b/service/lib/agama/storage/config_checkers/volume_group.rb new file mode 100644 index 0000000000..cbcae6025c --- /dev/null +++ b/service/lib/agama/storage/config_checkers/volume_group.rb @@ -0,0 +1,127 @@ +# 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_checkers/base" +require "agama/storage/config_checkers/logical_volume" +require "agama/storage/config_checkers/physical_volumes_encryption" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking a volume group config. + class VolumeGroup < Base + include Yast::I18n + + # @param config [Configs::VolumeGroup] + # @param storage_config [Storage::Config] + # @param product_config [Agama::Config] + def initialize(config, storage_config, product_config) + super(storage_config, product_config) + + textdomain "agama" + @config = config + end + + # Volume group config issues. + # + # @return [Array] + def issues + [ + logical_volumes_issues, + physical_volumes_issues, + physical_volumes_devices_issues, + physical_volumes_encryption_issues + ].flatten + end + + private + + # @return [Configs::VolumeGroup] + attr_reader :config + + # Issues from logical volumes. + # + # @return [Array] + def logical_volumes_issues + config.logical_volumes.flat_map do |logical_volume| + ConfigCheckers::LogicalVolume + .new(logical_volume, config, storage_config, product_config) + .issues + end + end + + # Issues from physical volumes. + # + # @return [Array] + def physical_volumes_issues + config.physical_volumes.map { |v| missing_physical_volume_issue(v) }.compact + end + + # @see #physical_volumes_issues + # + # @param pv_alias [String] + # @return [Issue, nil] + def missing_physical_volume_issue(pv_alias) + configs = storage_config.drives + storage_config.drives.flat_map(&:partitions) + return if configs.any? { |c| c.alias == pv_alias } + + # TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). + error(format(_("There is no LVM physical volume with alias '%s'"), pv_alias)) + end + + # Issues from physical volumes devices (target devices). + # + # @return [Array] + def physical_volumes_devices_issues + config.physical_volumes_devices + .map { |d| missing_physical_volumes_device_issue(d) } + .compact + end + + # @see #physical_volumes_devices_issue + # + # @param device_alias [String] + # @return [Issue, nil] + def missing_physical_volumes_device_issue(device_alias) + return if storage_config.drives.any? { |d| d.alias == device_alias } + + error( + format( + # TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). + _("There is no target device for LVM physical volumes with alias '%s'"), + device_alias + ) + ) + end + + # Issues from physical volumes encryption. + # + # @return [Array] + def physical_volumes_encryption_issues + ConfigCheckers::PhysicalVolumesEncryption + .new(config, storage_config, product_config) + .issues + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/volume_groups.rb b/service/lib/agama/storage/config_checkers/volume_groups.rb new file mode 100644 index 0000000000..4113deb481 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/volume_groups.rb @@ -0,0 +1,78 @@ +# 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_checkers/base" +require "yast/i18n" + +module Agama + module Storage + module ConfigCheckers + # Class for checking the volume groups. + class VolumeGroups < Base + include Yast::I18n + + # Volume groups issues. + # + # @return [Array] + def issues + overused_physical_volumes_devices_issues + end + + private + + # Issues for overused target devices for physical volumes. + # + # @note The Agama proposal is not able to calculate if the same target device is used by + # more than one volume group having several target devices. + # + # @return [Array] + def overused_physical_volumes_devices_issues + overused = overused_physical_volumes_devices + return [] if overused.none? + + overused.map do |device| + error( + format( + # TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). + _("The device '%s' is used several times as target device for physical volumes"), + device + ) + ) + end + end + + # Aliases of overused target devices for physical volumes. + # + # @return [Array] + def overused_physical_volumes_devices + storage_config.volume_groups + .map(&:physical_volumes_devices) + .map(&:uniq) + .select { |d| d.size > 1 } + .flatten + .tally + .select { |_, v| v > 1 } + .keys + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/with_encryption.rb b/service/lib/agama/storage/config_checkers/with_encryption.rb new file mode 100644 index 0000000000..89b26dc1af --- /dev/null +++ b/service/lib/agama/storage/config_checkers/with_encryption.rb @@ -0,0 +1,36 @@ +# 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_checkers/filesystem" + +module Agama + module Storage + module ConfigCheckers + # Mixin for encryption issues. + module WithEncryption + # @return [Array] + def encryption_issues + ConfigCheckers::Encryption.new(config, storage_config, product_config).issues + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/with_filesystem.rb b/service/lib/agama/storage/config_checkers/with_filesystem.rb new file mode 100644 index 0000000000..57a4642c55 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/with_filesystem.rb @@ -0,0 +1,36 @@ +# 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_checkers/filesystem" + +module Agama + module Storage + module ConfigCheckers + # Mixin for filesystem issues. + module WithFilesystem + # @return [Array] + def filesystem_issues + ConfigCheckers::Filesystem.new(config, storage_config, product_config).issues + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/with_partitions.rb b/service/lib/agama/storage/config_checkers/with_partitions.rb new file mode 100644 index 0000000000..f0e59c25b0 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/with_partitions.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_checkers/partition" + +module Agama + module Storage + module ConfigCheckers + # Mixin for partitions issues. + module WithPartitions + # @return [Array] + def partitions_issues + config.partitions.flat_map do |partition_config| + ConfigCheckers::Partition.new(partition_config, storage_config, product_config).issues + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_checkers/with_search.rb b/service/lib/agama/storage/config_checkers/with_search.rb new file mode 100644 index 0000000000..6c1b8b0663 --- /dev/null +++ b/service/lib/agama/storage/config_checkers/with_search.rb @@ -0,0 +1,36 @@ +# 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_checkers/search" + +module Agama + module Storage + module ConfigCheckers + # Mixin for search issues. + module WithSearch + # @return [Array] + def search_issues + ConfigCheckers::Search.new(config, storage_config, product_config).issues + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb index fa120ab880..bb6596b2b9 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb @@ -21,6 +21,7 @@ require "agama/storage/config_conversions/from_json_conversions/base" require "agama/storage/configs/boot" +require "agama/storage/configs/boot_device" module Agama module Storage @@ -43,9 +44,20 @@ def default_config def conversions { configure: boot_json[:configure], - device: boot_json[:device] + device: convert_device } end + + # @return [Configs::BootDevice, nil] + def convert_device + boot_device_json = boot_json[:device] + return unless boot_device_json + + Configs::BootDevice.new.tap do |config| + config.default = false + config.device_alias = boot_json[:device] + end + end end end end diff --git a/service/lib/agama/storage/config_conversions/from_model.rb b/service/lib/agama/storage/config_conversions/from_model.rb index e48bc8bfa7..10e78e3a09 100644 --- a/service/lib/agama/storage/config_conversions/from_model.rb +++ b/service/lib/agama/storage/config_conversions/from_model.rb @@ -28,8 +28,10 @@ module ConfigConversions # Config conversion from model according to the JSON schema. class FromModel # @param model_json [Hash] - def initialize(model_json) + # @param product_config [Agama::Config, nil] + def initialize(model_json, product_config: nil) @model_json = model_json + @product_config = product_config || Agama::Config.new end # Performs the conversion from model according to the JSON schema. @@ -37,13 +39,16 @@ def initialize(model_json) # @return [Storage::Config] def convert # TODO: Raise error if model_json does not match the JSON schema. - FromModelConversions::Config.new(model_json).convert + FromModelConversions::Config.new(model_json, product_config).convert end private # @return [Hash] attr_reader :model_json + + # @return [Agama::Config] + attr_reader :product_config end end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions.rb index 267a567fd5..30a80ea9a1 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions.rb @@ -19,6 +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/config_conversions/from_model_conversions/boot" +require "agama/storage/config_conversions/from_model_conversions/boot_device" require "agama/storage/config_conversions/from_model_conversions/config" require "agama/storage/config_conversions/from_model_conversions/drive" require "agama/storage/config_conversions/from_model_conversions/filesystem" diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb new file mode 100644 index 0000000000..abd656eb73 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/boot_device" +require "agama/storage/configs/boot" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Boot conversion from model according to the JSON schema. + class Boot < Base + private + + # @see Base + # @return [Configs::Boot] + def default_config + Configs::Boot.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + configure: model_json[:configure], + device: convert_device + } + end + + # @return [Configs::BootDevice, nil] + def convert_device + boot_device_model = model_json[:device] + return if boot_device_model.nil? + + FromModelConversions::BootDevice.new(boot_device_model).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/boot_device.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/boot_device.rb new file mode 100644 index 0000000000..27f4f0406e --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/boot_device.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/configs/boot_device" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Boot device conversion from model according to the JSON schema. + class BootDevice < Base + private + + # @see Base + # @return [Configs::Boot] + def default_config + Configs::BootDevice.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + default: model_json[:default] + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb index 29a0af95cd..382f49528d 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/boot" require "agama/storage/config_conversions/from_model_conversions/drive" require "agama/storage/config" @@ -29,8 +30,18 @@ module ConfigConversions module FromModelConversions # Config conversion from model according to the JSON schema. class Config < Base + # @param model_json [Hash] + # @param product_config [Agama::Config] + def initialize(model_json, product_config) + super(model_json) + @product_config = product_config + end + private + # @return [Agama::Config] + attr_reader :product_config + # @see Base # @return [Storage::Config] def default_config @@ -38,16 +49,38 @@ def default_config end # @see Base#conversions + # The conversion of boot and drives is special because the relationship between them. + # + # A boot model can indicates a boot device name. If so, the following steps are needed: + # * Add a drive model for the boot device if there is no drive model for it, see + # {#convert_drives}. + # * Add an alias to the drive config of the boot device if it has no alias, and set that + # alias to the boot device config, see #{convert_boot_device_alias}. + # # @return [Hash] def conversions + boot_config = convert_boot + drive_configs = convert_drives + drive_config = boot_drive_config(drive_configs) + + convert_boot_device_alias(boot_config, drive_config) + { - drives: convert_drives + boot: boot_config, + drives: drive_configs } end + # @return [Configs::Boot, nil] + def convert_boot + boot_model = model_json[:boot] + return unless boot_model + + FromModelConversions::Boot.new(boot_model).convert + end + # @return [Array, nil] def convert_drives - drive_models = model_json[:drives] return unless drive_models drive_models.map { |d| convert_drive(d) } @@ -56,7 +89,90 @@ def convert_drives # @param drive_model [Hash] # @return [Configs::Drive] def convert_drive(drive_model) - FromModelConversions::Drive.new(drive_model).convert + FromModelConversions::Drive.new(drive_model, product_config).convert + end + + # Conversion for the boot device alias. + # + # It requieres both boot and drives already converted. + # + # @param boot_config [Configs::Boot, nil] The boot config can be modified. + # @param drive_config [Configs::Drive, nil] The drive config can be modifed. + def convert_boot_device_alias(boot_config, drive_config) + return unless boot_config && drive_config + return unless boot_config.configure? && !boot_config.device.default? + + drive_config.ensure_alias + boot_config.device.device_alias = drive_config.alias + end + + # Drive config for the boot device, if any. + # + # @param drive_configs [Array, nil] + # @return [Configs::Drive, nil] + def boot_drive_config(drive_configs) + return unless drive_configs && boot_device_name + + drive_configs.find { |d| d.search.name == boot_device_name } + end + + # Drive models to convert to drive configs. + # + # It includes all the drives from the drive section of the model, adding a drive for the + # selected boot device if needed. See {#calculate_drive_models}. + # + # @return [Array, nil] + def drive_models + return @drive_models if @calculated_drive_models + + @drive_models = calculate_drive_models + end + + # @see #drive_models + # @return [Array, nil] + def calculate_drive_models + @calculated_drive_models = true + + models = model_json[:drives] + return if models.nil? && !missing_boot_drive? + + models ||= [] + # The main use case for using a specific device for booting is to share the boot + # partition with other installed systems. So let's ensure the partitions are not deleted + # by setting the "keep" space policy. + models << { name: boot_device_name, spacePolicy: "keep" } if missing_boot_drive? + models + end + + # Whether a drive model for the boot device is missing in the list of drives. See + # {#calculate_missing_boot_device}. + # + # @return [Boolean] + def missing_boot_drive? + return @missing_boot_drive if @calculated_missing_boot_drive + + @missing_boot_drive ||= calculate_missing_boot_drive + end + + # @see #missing_boot_drive? + # @return [Boolean] + def calculate_missing_boot_drive + @calculated_missing_boot_drive = true + + configure_boot = model_json.dig(:boot, :configure) + default_boot = model_json.dig(:boot, :device, :default) + + return false unless configure_boot && !default_boot && !boot_device_name.nil? + + drive_models = model_json[:drives] || [] + drive_models.none? { |d| d[:name] == boot_device_name } + end + + # Name of the device selected for booting, if any. + # + # @return [String, nil] + def boot_device_name + @boot_device_name ||= model_json.dig(:boot, :device, :name) end end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb index 97bedf6cc0..ab55e01243 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb @@ -32,13 +32,23 @@ module ConfigConversions module FromModelConversions # Drive conversion from model according to the JSON schema. class Drive < Base - private - include WithFilesystem include WithPtableType include WithPartitions include WithSearch + # @param model_json [Hash] + # @param product_config [Agama::Config] + def initialize(model_json, product_config) + super(model_json) + @product_config = product_config + end + + private + + # @return [Agama::Config] + attr_reader :product_config + alias_method :drive_model, :model_json # @see Base diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb index da8c4bd5f7..d099004776 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb @@ -30,7 +30,9 @@ module FromModelConversions module WithPartitions # @return [Array] def convert_partitions - space_policy = model_json[:spacePolicy] + # If the model does not indicate a space policy, then the space policy defined by the + # product is applied. + space_policy = model_json[:spacePolicy] || product_config.space_policy case space_policy when "keep" @@ -55,6 +57,8 @@ def partition_configs partitions.map { |p| convert_partition(p) } end + # Partitions with any usage (format, mount, etc). + # # @return [Array] def used_partition_configs used_partitions.map { |p| convert_partition(p) } @@ -67,35 +71,37 @@ def partitions # @return [Array] def used_partitions - partitions.select { |p| used_partition?(p) } + partitions.reject { |p| space_policy_partition?(p) } end + # Whether the partition only represents a space policy action. + # # @param partition_model [Hash] # @return [Boolean] - def used_partition?(partition_model) - new_partition?(partition_model) || reused_partition?(partition_model) + def space_policy_partition?(partition_model) + partition_model[:delete] || + partition_model[:deleteIfNeeded] || + resize_action_partition?(partition_model) end # @param partition_model [Hash] # @return [Boolean] - def new_partition?(partition_model) - partition_model[:name].nil? && - !partition_model[:delete] && - !partition_model[:deleteIfNeeded] + def resize_action_partition?(partition_model) + return false if partition_model[:name].nil? || any_usage?(partition_model) + + return true if partition_model[:resizeIfNeeded] + + partition_model[:size] && !partition_model.dig(:size, :default) end + # TODO: improve check by ensuring the alias is referenced by other device. + # # @param partition_model [Hash] # @return [Boolean] - def reused_partition?(partition_model) - # TODO: improve check by ensuring the alias is referenced by other device. - any_usage = partition_model[:mountPath] || + def any_usage?(partition_model) + partition_model[:mountPath] || partition_model[:filesystem] || partition_model[:alias] - - any_usage && - partition_model[:name] && - !partition_model[:delete] && - !partition_model[:deleteIfNeeded] end # @return [Configs::Partition] diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb index 698f3f09fe..2a5245564a 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb @@ -39,7 +39,7 @@ def initialize(config) def conversions { configure: config.configure?, - device: config.device + device: config.device.device_alias } end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions.rb b/service/lib/agama/storage/config_conversions/to_model_conversions.rb index 87dede1eda..153a2d9146 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions.rb @@ -20,6 +20,8 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/boot" +require "agama/storage/config_conversions/to_model_conversions/boot_device" require "agama/storage/config_conversions/to_model_conversions/config" require "agama/storage/config_conversions/to_model_conversions/drive" require "agama/storage/config_conversions/to_model_conversions/filesystem" diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/boot.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/boot.rb new file mode 100644 index 0000000000..135504c552 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/boot.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/boot_device" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Boot config conversion to model according to the JSON schema. + class Boot < Base + # @param config [Storage::Config] + def initialize(config) + super() + @config = config + end + + private + + # @see Base#conversions + def conversions + { + configure: config.boot.configure?, + device: convert_device + } + end + + # @return [Hash] + def convert_device + return unless config.boot.configure? + + ToModelConversions::BootDevice.new(config).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/boot_device.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/boot_device.rb new file mode 100644 index 0000000000..71e1283f01 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/boot_device.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/to_model_conversions/base" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Boot device config conversion to model according to the JSON schema. + class BootDevice < Base + # @param config [Storage::Config] + def initialize(config) + super() + @config = config + end + + private + + # @see Base#conversions + def conversions + { + default: config.boot.device.default?, + name: config.boot_device + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index 40e3da02e7..b0ba09e169 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/boot" require "agama/storage/config_conversions/to_model_conversions/drive" module Agama @@ -38,7 +39,15 @@ def initialize(config) # @see Base#conversions def conversions - { drives: convert_drives } + { + boot: convert_boot, + drives: convert_drives + } + end + + # @return [Hash] + def convert_boot + ToModelConversions::Boot.new(config).convert end # @return [Array] diff --git a/service/lib/agama/storage/config_encryption_solver.rb b/service/lib/agama/storage/config_encryption_solver.rb deleted file mode 100644 index 60a02e8a74..0000000000 --- a/service/lib/agama/storage/config_encryption_solver.rb +++ /dev/null @@ -1,115 +0,0 @@ -# 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_builder" - -module Agama - module Storage - # Solver for the encryption configs. - # - # The encryption configs are solved by assigning the default encryption values defined by the - # productd, if needed. - class ConfigEncryptionSolver - # @param product_config [Agama::Config] - def initialize(product_config) - @product_config = product_config - end - - # Solves all the encryption configs within a given config. - # - # @note The config object is modified. - # - # @param config [Config] - def solve(config) - @config = config - - solve_encryptions - solve_physical_volumes_encryptions - end - - private - - # @return [Agama::Config] - attr_reader :product_config - - # @return [Config] - attr_reader :config - - def solve_encryptions - configs_with_encryption.each { |c| solve_encryption(c) } - end - - # @param config [#encryption] - def solve_encryption(config) - return unless config.encryption - - encryption = config.encryption - encryption.method ||= default_encryption.method - - # Recovering values from the default encryption only makes sense if the encryption method is - # the same. - solve_encryption_values(encryption) if encryption.method == default_encryption.method - end - - def solve_physical_volumes_encryptions - config.volume_groups.each { |c| solve_physical_volumes_encryption(c) } - end - - # @param config [Configs::VolumeGroup] - def solve_physical_volumes_encryption(config) - return unless config.physical_volumes_encryption - - encryption = config.physical_volumes_encryption - encryption.method ||= default_encryption.method - - # Recovering values from the default encryption only makes sense if the encryption method is - # the same. - solve_encryption_values(encryption) if encryption.method == default_encryption.method - end - - # @param config [Configs::Encryption] - def solve_encryption_values(config) - config.password ||= default_encryption.password - config.pbkd_function ||= default_encryption.pbkd_function - config.label ||= default_encryption.label - config.cipher ||= default_encryption.cipher - config.key_size ||= default_encryption.key_size - end - - # @return [Array<#encryption>] - def configs_with_encryption - config.drives + config.partitions + config.logical_volumes - end - - # Default encryption defined by the product. - # - # @return [Configs::Encryption] - def default_encryption - @default_encryption ||= config_builder.default_encryption - end - - # @return [ConfigBuilder] - def config_builder - @config_builder ||= ConfigBuilder.new(product_config) - end - end - end -end diff --git a/service/lib/agama/storage/config_filesystem_solver.rb b/service/lib/agama/storage/config_filesystem_solver.rb deleted file mode 100644 index abb9ba423a..0000000000 --- a/service/lib/agama/storage/config_filesystem_solver.rb +++ /dev/null @@ -1,106 +0,0 @@ -# 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_builder" - -module Agama - module Storage - # Solver for the filesystem configs. - # - # The filesystem configs are solved by assigning the default filesystem values defined by the - # productd, if needed. - class ConfigFilesystemSolver - # @param product_config [Agama::Config] - def initialize(product_config) - @product_config = product_config - end - - # Solves all the filesystem configs within a given config. - # - # @note The config object is modified. - # - # @param config [Config] - def solve(config) - @config = config - - configs_with_filesystem.each { |c| solve_filesystem(c) } - end - - private - - # @return [Agama::Config] - attr_reader :product_config - - # @return [Config] - attr_reader :config - - # @param config [#filesystem] - def solve_filesystem(config) - return unless config.filesystem - - default_filesystem = default_filesystem(config.filesystem.path) - - config.filesystem.type ||= default_filesystem.type - config.filesystem.type.btrfs ||= default_filesystem.type.btrfs - solve_btrfs_values(config) - end - - # @param config [#filesystem] - def solve_btrfs_values(config) - btrfs = config.filesystem.type.btrfs - return unless btrfs - - default_btrfs = default_btrfs(config.filesystem.path) - - btrfs.snapshots = default_btrfs.snapshots? if btrfs.snapshots.nil? - btrfs.read_only = default_btrfs.read_only? if btrfs.read_only.nil? - btrfs.subvolumes ||= default_btrfs.subvolumes - btrfs.default_subvolume ||= (default_btrfs.default_subvolume || "") - end - - # @return [Array<#filesystem>] - def configs_with_filesystem - config.drives + config.partitions + config.logical_volumes - end - - # Default filesystem defined by the product. - # - # @param path [String, nil] - # @return [Configs::Filesystem] - def default_filesystem(path = nil) - config_builder.default_filesystem(path) - end - - # Default btrfs config defined by the product. - # - # @param path [String, nil] - # @return [Configs::Btrfs] - def default_btrfs(path = nil) - default_filesystem(path).type.btrfs - end - - # @return [ConfigBuilder] - def config_builder - @config_builder ||= ConfigBuilder.new(product_config) - end - end - end -end diff --git a/service/lib/agama/storage/config_search_solver.rb b/service/lib/agama/storage/config_search_solver.rb deleted file mode 100644 index 057ec099a7..0000000000 --- a/service/lib/agama/storage/config_search_solver.rb +++ /dev/null @@ -1,188 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -module Agama - module Storage - # Solver for the search configs. - class ConfigSearchSolver - # @param devicegraph [Y2Storage::Devicegraph] used to find the corresponding devices that - # will get associated to each search element. - # @param analyzer [Y2Storage::DiskAnalyzer, nil] optionally used to filter candidate disks - def initialize(devicegraph, analyzer) - @devicegraph = devicegraph - @disk_analyzer = analyzer - end - - # Solves all the search configs within a given config. - # - # @note The config object is modified. - # - # @param config [Agama::Storage::Config] - def solve(config) - @sids = [] - config.drives = config.drives.flat_map { |d| solve_drive(d) } - end - - private - - # @return [Y2Storage::Devicegraph] - attr_reader :devicegraph - - # @return [Y2Storage::DiskAnalyzer, nil] - attr_reader :disk_analyzer - - # @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) - devices = find_drives(original_drive.search) - return without_device(original_drive) if devices.empty? - - devices.map do |device| - drive_copy(original_drive, device) - end - end - - # Marks the search of the given config object as solved - # - # @note The config object is modified. - # - # @param config [Configs::Drive, Configs::Partition] - # @return [Configs::Drive, Configs::Partition] - def without_device(config) - config.search.solve - config - end - - # see #solve_drive - def drive_copy(original_drive, device) - drive_config = original_drive.copy - drive_config.search.solve(device) - add_found(drive_config) - - return drive_config unless drive_config.partitions? - - drive_config.partitions = drive_config.partitions.flat_map do |partition_config| - solve_partition(partition_config, device) - end - - drive_config - end - - # see #solve_drive - # - # @note The given partition object can be modified - # - # @param original_partition [Configs::Partition] - # @param drive_device [Y2Storage::Partitionable] - # @return [Configs::Partition, Array] - def solve_partition(original_partition, drive_device) - return original_partition unless original_partition.search - - partitions = find_partitions(original_partition.search, drive_device) - return without_device(original_partition) if partitions.empty? - - partitions.map do |partition| - partition_config = original_partition.copy - partition_config.search.solve(partition) - add_found(partition_config) - - partition_config - end - end - - # Finds the drives matching the given search config. - # - # @param search_config [Agama::Storage::Configs::Search] - # @return [Y2Storage::Device, nil] - def find_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] - # @return [Y2Storage::Device, nil] - 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 - - # 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 [Y2Storage::Device, nil] - 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 diff --git a/service/lib/agama/storage/config_size_solver.rb b/service/lib/agama/storage/config_size_solver.rb deleted file mode 100644 index 3785b96da3..0000000000 --- a/service/lib/agama/storage/config_size_solver.rb +++ /dev/null @@ -1,206 +0,0 @@ -# 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) - size = size_from_device(config.found_device) - config.size.min ||= size.min - config.size.max ||= size.max - 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 device [Y2Storage::Device] - # @return [Configs::Size] - def size_from_device(device) - Configs::Size.new.tap do |config| - 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 - - # Whether the config has to be considered. - # - # Note that a config could be ignored if a device is not found for its search. - # - # @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 1fbff4e1fe..2f4c5d6016 100644 --- a/service/lib/agama/storage/config_solver.rb +++ b/service/lib/agama/storage/config_solver.rb @@ -19,10 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/config_encryption_solver" -require "agama/storage/config_filesystem_solver" -require "agama/storage/config_search_solver" -require "agama/storage/config_size_solver" +require "agama/storage/config_solvers" module Agama module Storage @@ -35,13 +32,13 @@ module Storage # See doc/storage_proposal_from_profile.md for a complete description of how the config is # generated from a profile. class ConfigSolver - # @param devicegraph [Y2Storage::Devicegraph] initial layout of the system # @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(devicegraph, product_config, disk_analyzer: nil) - @devicegraph = devicegraph + def initialize(product_config, devicegraph, disk_analyzer: nil) @product_config = product_config + @devicegraph = devicegraph @disk_analyzer = disk_analyzer end @@ -51,21 +48,22 @@ def initialize(devicegraph, product_config, disk_analyzer: nil) # # @param config [Config] def solve(config) - ConfigEncryptionSolver.new(product_config).solve(config) - ConfigFilesystemSolver.new(product_config).solve(config) - ConfigSearchSolver.new(devicegraph, disk_analyzer).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) # Sizes must be solved once the searches are solved. - ConfigSizeSolver.new(devicegraph, product_config).solve(config) + ConfigSolvers::Size.new(product_config, devicegraph).solve(config) end private - # @return [Y2Storage::Devicegraph] - attr_reader :devicegraph - # @return [Agama::Config] attr_reader :product_config + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + # @return [Y2Storage::DiskAnalyzer, nil] attr_reader :disk_analyzer end diff --git a/service/lib/agama/storage/config_solvers.rb b/service/lib/agama/storage/config_solvers.rb new file mode 100644 index 0000000000..a8e89d7c47 --- /dev/null +++ b/service/lib/agama/storage/config_solvers.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_solvers/boot" +require "agama/storage/config_solvers/encryption" +require "agama/storage/config_solvers/filesystem" +require "agama/storage/config_solvers/search" +require "agama/storage/config_solvers/size" + +module Agama + module Storage + # Name space for config solvers. + module ConfigSolvers + end + end +end diff --git a/service/lib/agama/storage/config_solvers/base.rb b/service/lib/agama/storage/config_solvers/base.rb new file mode 100644 index 0000000000..e300aa9397 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/base.rb @@ -0,0 +1,56 @@ +# 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_builder" + +module Agama + module Storage + module ConfigSolvers + # Base class for config solvers. + class Base + # @param product_config [Agama::Config] + def initialize(product_config) + @product_config = product_config + end + + # Solves a given config. + # + # @param _config [Config] + def solve(_config) + raise "#solve is not defined" + end + + private + + # @return [Agama::Config] + attr_reader :product_config + + # @return [Config] + attr_reader :config + + # @return [ConfigBuilder] + def config_builder + @config_builder ||= ConfigBuilder.new(product_config) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_solvers/boot.rb b/service/lib/agama/storage/config_solvers/boot.rb new file mode 100644 index 0000000000..ad17840237 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/boot.rb @@ -0,0 +1,120 @@ +# 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_solvers/base" + +module Agama + module Storage + module ConfigSolvers + # Solver for the boot config. + class Boot < Base + # Solves the boot config within a given config. + # + # @note The config object is modified. + # + # @param config [Config] + def solve(config) + @config = config + solve_device_alias + end + + private + + # Finds a device for booting and sets its alias, if needed. + def solve_device_alias + return unless config.boot.configure? && config.boot.device.default? + + drive_config = root_drive_config + return unless drive_config + + drive_config.ensure_alias + config.boot.device.device_alias = drive_config.alias + end + + # Config of the drive used for allocating root, directly or inderectly. + # + # @return [Configs::Drive, nil] nil if the boot device cannot be inferred from the config. + def root_drive_config + drive_config = config.drives.find { |d| root_drive_config?(d) } + + drive_config || root_lvm_device_config + end + + # Config of the first drive used to allocate the root volume group config, if any. + # + # @return [Configs::Drive, nil] + def root_lvm_device_config + volume_group_config = root_volume_group_config + return unless volume_group_config + + config.drives + .select { |d| candidate_for_physical_volumes?(d, volume_group_config) } + .first + end + + # Config of the volume group containing the root logical volume, if any. + # + # @return [Configs::VolumeGroup, nil] + def root_volume_group_config + config.volume_groups.find { |v| root_volume_group_config?(v) } + end + + # Whether the given drive config contains a root partition config. + # + # @param config [Configs::Drive] + # @return [Boolean] + def root_drive_config?(config) + config.partitions.any? { |p| root_config?(p) } + end + + # Whether the given volume group config contains a root logical volume config. + # + # @param config [Configs::VolumeGroup] + # @return [Boolean] + def root_volume_group_config?(config) + config.logical_volumes.any? { |l| root_config?(l) } + end + + # Whether the given config if for the root filesystem. + # + # @param config [#filesystem] + # @return [Boolean] + def root_config?(config) + config.filesystem&.root? + end + + # Whether the given drive config can be used to allocate physcial volumes. + # + # @param drive [Configs::Drive] + # @param volume_group [Configs::VolumeGroup] + # + # @return [Boolean] + def candidate_for_physical_volumes?(drive, volume_group) + return true if volume_group.physical_volumes_devices.any? { |d| drive.alias?(d) } + + volume_group.physical_volumes.any? do |pv| + drive.partitions.any? { |p| p.alias?(pv) } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_solvers/encryption.rb b/service/lib/agama/storage/config_solvers/encryption.rb new file mode 100644 index 0000000000..1652a36395 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/encryption.rb @@ -0,0 +1,101 @@ +# 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_solvers/base" + +module Agama + module Storage + module ConfigSolvers + # Solver for the encryption configs. + # + # The encryption configs are solved by assigning the default encryption values defined by the + # productd, if needed. + class Encryption < Base + # Solves all the encryption configs within a given config. + # + # @note The config object is modified. + # + # @param config [Config] + def solve(config) + @config = config + + solve_encryptions + solve_physical_volumes_encryptions + end + + private + + def solve_encryptions + configs_with_encryption.each { |c| solve_encryption(c) } + end + + # @param config [#encryption] + def solve_encryption(config) + return unless config.encryption + + encryption = config.encryption + encryption.method ||= default_encryption.method + + # Recovering values from the default encryption only makes sense if the encryption method + # is the same. + solve_encryption_values(encryption) if encryption.method == default_encryption.method + end + + def solve_physical_volumes_encryptions + config.volume_groups.each { |c| solve_physical_volumes_encryption(c) } + end + + # @param config [Configs::VolumeGroup] + def solve_physical_volumes_encryption(config) + return unless config.physical_volumes_encryption + + encryption = config.physical_volumes_encryption + encryption.method ||= default_encryption.method + + # Recovering values from the default encryption only makes sense if the encryption method + # is the same. + solve_encryption_values(encryption) if encryption.method == default_encryption.method + end + + # @param config [Configs::Encryption] + def solve_encryption_values(config) + config.password ||= default_encryption.password + config.pbkd_function ||= default_encryption.pbkd_function + config.label ||= default_encryption.label + config.cipher ||= default_encryption.cipher + config.key_size ||= default_encryption.key_size + end + + # @return [Array<#encryption>] + def configs_with_encryption + config.drives + config.partitions + config.logical_volumes + end + + # Default encryption defined by the product. + # + # @return [Configs::Encryption] + def default_encryption + @default_encryption ||= config_builder.default_encryption + end + end + end + end +end diff --git a/service/lib/agama/storage/config_solvers/filesystem.rb b/service/lib/agama/storage/config_solvers/filesystem.rb new file mode 100644 index 0000000000..a6feac7c6c --- /dev/null +++ b/service/lib/agama/storage/config_solvers/filesystem.rb @@ -0,0 +1,92 @@ +# 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_solvers/base" + +module Agama + module Storage + module ConfigSolvers + # Solver for the filesystem configs. + # + # The filesystem configs are solved by assigning the default filesystem values defined by the + # productd, if needed. + class Filesystem < Base + # Solves all the filesystem configs within a given config. + # + # @note The config object is modified. + # + # @param config [Config] + def solve(config) + @config = config + + configs_with_filesystem.each { |c| solve_filesystem(c) } + end + + private + + # @param config [#filesystem] + def solve_filesystem(config) + return unless config.filesystem + + default_filesystem = default_filesystem(config.filesystem.path) + + config.filesystem.type ||= default_filesystem.type + config.filesystem.type.btrfs ||= default_filesystem.type.btrfs + solve_btrfs_values(config) + end + + # @param config [#filesystem] + def solve_btrfs_values(config) + btrfs = config.filesystem.type.btrfs + return unless btrfs + + default_btrfs = default_btrfs(config.filesystem.path) + + btrfs.snapshots = default_btrfs.snapshots? if btrfs.snapshots.nil? + btrfs.read_only = default_btrfs.read_only? if btrfs.read_only.nil? + btrfs.subvolumes ||= default_btrfs.subvolumes + btrfs.default_subvolume ||= (default_btrfs.default_subvolume || "") + end + + # @return [Array<#filesystem>] + def configs_with_filesystem + config.drives + config.partitions + config.logical_volumes + end + + # Default filesystem defined by the product. + # + # @param path [String, nil] + # @return [Configs::Filesystem] + def default_filesystem(path = nil) + config_builder.default_filesystem(path) + end + + # Default btrfs config defined by the product. + # + # @param path [String, nil] + # @return [Configs::Btrfs] + def default_btrfs(path = nil) + default_filesystem(path).type.btrfs + end + end + end + end +end diff --git a/service/lib/agama/storage/config_solvers/search.rb b/service/lib/agama/storage/config_solvers/search.rb new file mode 100644 index 0000000000..c16e5a0582 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/search.rb @@ -0,0 +1,195 @@ +# 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_solvers/base" + +module Agama + module Storage + module ConfigSolvers + # Solver for the search configs. + class Search < Base + # @param product_config [Agama::Config] + # @param devicegraph [Y2Storage::Devicegraph] used to find the corresponding devices that + # will get associated to each search element. + # @param analyzer [Y2Storage::DiskAnalyzer, nil] optionally used to filter candidate disks + def initialize(product_config, devicegraph, analyzer) + super(product_config) + @devicegraph = devicegraph + @disk_analyzer = analyzer + end + + # Solves all the search configs within a given config. + # + # @note The config object is modified. + # + # @param config [Agama::Storage::Config] + def solve(config) + @sids = [] + config.drives = config.drives.flat_map { |d| solve_drive(d) } + end + + private + + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + + # @return [Y2Storage::DiskAnalyzer, nil] + attr_reader :disk_analyzer + + # @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) + devices = find_drives(original_drive.search) + return without_device(original_drive) if devices.empty? + + devices.map do |device| + drive_copy(original_drive, device) + end + end + + # Marks the search of the given config object as solved + # + # @note The config object is modified. + # + # @param config [Configs::Drive, Configs::Partition] + # @return [Configs::Drive, Configs::Partition] + def without_device(config) + config.search.solve + config + end + + # see #solve_drive + def drive_copy(original_drive, device) + drive_config = original_drive.copy + drive_config.search.solve(device) + add_found(drive_config) + + return drive_config unless drive_config.partitions? + + drive_config.partitions = drive_config.partitions.flat_map do |partition_config| + solve_partition(partition_config, device) + end + + drive_config + end + + # see #solve_drive + # + # @note The given partition object can be modified + # + # @param original_partition [Configs::Partition] + # @param drive_device [Y2Storage::Partitionable] + # @return [Configs::Partition, Array] + def solve_partition(original_partition, drive_device) + return original_partition unless original_partition.search + + partitions = find_partitions(original_partition.search, drive_device) + return without_device(original_partition) if partitions.empty? + + partitions.map do |partition| + partition_config = original_partition.copy + partition_config.search.solve(partition) + add_found(partition_config) + + partition_config + end + end + + # Finds the drives matching the given search config. + # + # @param search_config [Agama::Storage::Configs::Search] + # @return [Y2Storage::Device, nil] + def find_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] + # @return [Y2Storage::Device, nil] + 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 + + # 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 [Y2Storage::Device, nil] + 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/size.rb b/service/lib/agama/storage/config_solvers/size.rb new file mode 100644 index 0000000000..8eb372dc00 --- /dev/null +++ b/service/lib/agama/storage/config_solvers/size.rb @@ -0,0 +1,197 @@ +# 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_solvers/base" +require "agama/storage/configs/size" + +module Agama + module Storage + module ConfigSolvers + # Solver for the size configs. + # + # It assigns proper size values according to the product and the system. + class Size < Base + # @param product_config [Agama::Config] + # @param devicegraph [Y2Storage::Devicegraph] + def initialize(product_config, devicegraph) + super(product_config) + @devicegraph = devicegraph + 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 + + 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) + size = size_from_device(config.found_device) + config.size.min ||= size.min + config.size.max ||= size.max + 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 device [Y2Storage::Device] + # @return [Configs::Size] + def size_from_device(device) + Configs::Size.new.tap do |config| + 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 + + # Whether the config has to be considered. + # + # Note that a config could be ignored if a device is not found for its search. + # + # @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 + end + end + end +end diff --git a/service/lib/agama/storage/configs.rb b/service/lib/agama/storage/configs.rb index fc5f7e8486..f746638b4e 100644 --- a/service/lib/agama/storage/configs.rb +++ b/service/lib/agama/storage/configs.rb @@ -28,6 +28,7 @@ module Configs end require "agama/storage/configs/boot" +require "agama/storage/configs/boot_device" require "agama/storage/configs/btrfs" require "agama/storage/configs/drive" require "agama/storage/configs/encryption" diff --git a/service/lib/agama/storage/configs/boot.rb b/service/lib/agama/storage/configs/boot.rb index 88071b086d..efb10e6a2d 100644 --- a/service/lib/agama/storage/configs/boot.rb +++ b/service/lib/agama/storage/configs/boot.rb @@ -19,6 +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/boot_device" + module Agama module Storage module Configs @@ -30,15 +32,15 @@ class Boot attr_accessor :configure alias_method :configure?, :configure - # Device to use for booting. + # Boot device config. # - # @return [String, nil] if nil, then the proposal decides the booting device, normally the - # device for allocating root. + # @return [BootDevice] attr_accessor :device # Constructor def initialize @configure = true + @device = BootDevice.new end end end diff --git a/service/lib/agama/storage/configs/boot_device.rb b/service/lib/agama/storage/configs/boot_device.rb new file mode 100644 index 0000000000..4b07716f48 --- /dev/null +++ b/service/lib/agama/storage/configs/boot_device.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + # Config for the boot device. + class BootDevice + # @return [Boolean] true means that the boot device is automatically inferred. See + # {ConfigSolver}. + attr_accessor :default + alias_method :default?, :default + + # @return [String, nil] + attr_accessor :device_alias + + def initialize + @default = true + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/with_alias.rb b/service/lib/agama/storage/configs/with_alias.rb index e61d9cec7a..250d8d93e0 100644 --- a/service/lib/agama/storage/configs/with_alias.rb +++ b/service/lib/agama/storage/configs/with_alias.rb @@ -19,6 +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 "securerandom" + module Agama module Storage module Configs @@ -33,6 +35,11 @@ module WithAlias def alias?(value) self.alias == value end + + # Ensures the config has a value for alias. + def ensure_alias + self.alias ||= SecureRandom.alphanumeric(10) + end end end end diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index fff8bd40ad..825194f085 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -132,7 +132,10 @@ def calculate_from_json(source_json) # @param model_json [Hash] Source config model according to the JSON schema. # @return [Boolean] Whether the proposal successes. def calculate_from_model(model_json) - config = ConfigConversions::FromModel.new(model_json).convert + config = ConfigConversions::FromModel + .new(model_json, product_config: product_config) + .convert + calculate_agama(config) end diff --git a/service/lib/agama/storage/proposal_settings.rb b/service/lib/agama/storage/proposal_settings.rb index 595d8da953..15e9f40d5c 100644 --- a/service/lib/agama/storage/proposal_settings.rb +++ b/service/lib/agama/storage/proposal_settings.rb @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/configs/boot" +require "agama/storage/boot_settings" require "agama/storage/device_settings" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings_conversions" @@ -36,7 +36,7 @@ class ProposalSettings # Boot config. # - # @return [Configs::Boot] + # @return [BootSettings] attr_accessor :boot # Encryption settings. @@ -56,7 +56,7 @@ class ProposalSettings def initialize @device = DeviceSettings::Disk.new - @boot = Configs::Boot.new + @boot = BootSettings.new @encryption = EncryptionSettings.new @space = SpaceSettings.new @volumes = [] diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index c368386c0c..ad367483d5 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(initial_devicegraph, product_config, disk_analyzer: disk_analyzer) + .new(product_config, initial_devicegraph, disk_analyzer: disk_analyzer) .solve(config) issues = Agama::Storage::ConfigChecker diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 8a423168e3..ef777ac41b 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -722,6 +722,7 @@ def serialize(value) storage: { drives: [ { + alias: "root", partitions: [ { filesystem: { path: "/" } @@ -736,9 +737,17 @@ def serialize(value) it "returns the serialized config model" do expect(subject.recover_model).to eq( serialize({ + boot: { + configure: true, + device: { + default: true, + name: "/dev/sda" + } + }, drives: [ { name: "/dev/sda", + alias: "root", spacePolicy: "keep", partitions: [ { diff --git a/service/test/agama/storage/config_checker_test.rb b/service/test/agama/storage/config_checker_test.rb index 6efdf161e3..3f4185df19 100644 --- a/service/test/agama/storage/config_checker_test.rb +++ b/service/test/agama/storage/config_checker_test.rb @@ -258,15 +258,106 @@ allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) Agama::Storage::ConfigSolver - .new(devicegraph, product_config) + .new(product_config, devicegraph) .solve(config) end let(:scenario) { "disks.yaml" } + context "if the boot configuration is enabled" do + let(:config_json) do + { + boot: { + configure: true, + device: device_alias + }, + drives: [ + { + alias: "disk" + } + ] + } + end + + context "and there is no device alias" do + let(:device_alias) { nil } + + it "includes the expected issue" do + issues = subject.issues + expect(issues.size).to eq(1) + + issue = issues.first + expect(issue.error?).to eq(true) + expect(issue.description).to eq("There is no boot device alias") + end + end + + context "and the given alias does not exist" do + let(:device_alias) { "foo" } + + it "includes the expected issue" do + issues = subject.issues + expect(issues.size).to eq(1) + + issue = issues.first + expect(issue.error?).to eq(true) + expect(issue.description).to eq("There is no boot device with alias 'foo'") + end + end + + context "and the given alias exists" do + let(:device_alias) { "disk" } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + end + + context "if the boot configuration is not enabled" do + let(:config_json) do + { + boot: { + configure: false, + device: device_alias + }, + drives: [ + { + alias: "disk" + } + ] + } + end + + context "and there is no device alias" do + let(:device_alias) { nil } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + + context "and the given alias does not exist" do + let(:device_alias) { "foo" } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + + context "and the given alias exists" do + let(:device_alias) { "disk" } + + it "does not include any issue" do + expect(subject.issues).to be_empty + end + end + end + context "if a drive has not found device" do let(:config_json) do { + boot: { configure: false }, drives: [ { search: { @@ -295,7 +386,7 @@ issue = issues.first expect(issue.error?).to eq(true) - expect(issue.description).to eq("No device found for a mandatory drive") + expect(issue.description).to eq("Mandatory device /dev/vdd not found") end end end @@ -303,6 +394,7 @@ context "if a drive has a found device" do let(:config_json) do { + boot: { configure: false }, drives: [ { search: "/dev/vda" } ] @@ -317,6 +409,7 @@ context "if a drive has encryption" do let(:config_json) do { + boot: { configure: false }, drives: [ { encryption: encryption, @@ -332,6 +425,7 @@ context "if a drive has filesystem" do let(:config_json) do { + boot: { configure: false }, drives: [ { filesystem: filesystem @@ -348,6 +442,7 @@ context "if a drive has partitions" do let(:config_json) do { + boot: { configure: false }, drives: [ { partitions: [partition] @@ -383,7 +478,7 @@ issue = issues.first expect(issue.error?).to eq(true) - expect(issue.description).to eq("No device found for a mandatory partition") + expect(issue.description).to eq("Mandatory device /dev/vdb1 not found") end end end @@ -423,6 +518,7 @@ context "if a volume group has logical volumes" do let(:config_json) do { + boot: { configure: false }, volumeGroups: [ { logicalVolumes: [ @@ -487,6 +583,7 @@ context "if a volume group has an unknown physical volume" do let(:config_json) do { + boot: { configure: false }, drives: [ { alias: "first-disk" @@ -513,6 +610,7 @@ context "if a volume group has an unknown target device for physical volumes" do let(:config_json) do { + boot: { configure: false }, drives: [ { alias: "first-disk" @@ -546,6 +644,7 @@ context "if a volume group has encryption for physical volumes" do let(:config_json) do { + boot: { configure: false }, drives: [ { alias: "first-disk" @@ -638,6 +737,7 @@ context "if there are overused physical volumes devices" do let(:config_json) do { + boot: { configure: false }, drives: [ { alias: "disk1" }, { alias: "disk2" }, @@ -688,6 +788,7 @@ context "if the config has several issues" do let(:config_json) do { + boot: { configure: false }, drives: [ { search: "/dev/vdd", @@ -705,7 +806,7 @@ it "includes the expected issues" do expect(subject.issues).to contain_exactly( an_object_having_attributes( - description: match(/No device found for a mandatory drive/) + description: match("Mandatory device /dev/vdd not found") ), an_object_having_attributes( description: match(/No passphrase provided/) diff --git a/service/test/agama/storage/config_conversions/from_json_test.rb b/service/test/agama/storage/config_conversions/from_json_test.rb index 62aa519836..af2e7d64c1 100644 --- a/service/test/agama/storage/config_conversions/from_json_test.rb +++ b/service/test/agama/storage/config_conversions/from_json_test.rb @@ -747,7 +747,9 @@ config = subject.convert expect(config.boot).to be_a(Agama::Storage::Configs::Boot) expect(config.boot.configure).to eq(true) - expect(config.boot.device).to be_nil + expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) + expect(config.boot.device.default).to eq(true) + expect(config.boot.device.device_alias).to be_nil end it "sets #drives to the expected value" do @@ -767,16 +769,33 @@ { boot: { configure: true, - device: "/dev/sdb" + device: device } } end + let(:device) { "sdb" } + it "sets #boot to the expected value" do config = subject.convert expect(config.boot).to be_a(Agama::Storage::Configs::Boot) - expect(config.boot.configure).to eq true - expect(config.boot.device).to eq "/dev/sdb" + expect(config.boot.configure).to eq(true) + expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) + expect(config.boot.device.default).to eq(false) + expect(config.boot.device.device_alias).to eq("sdb") + end + + context "if boot does not specify 'device'" do + let(:device) { nil } + + it "sets #boot to the expected value" do + config = subject.convert + expect(config.boot).to be_a(Agama::Storage::Configs::Boot) + expect(config.boot.configure).to eq(true) + expect(config.boot.device).to be_a(Agama::Storage::Configs::BootDevice) + expect(config.boot.device.default).to eq(true) + expect(config.boot.device.device_alias).to be_nil + end end end diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index 31ec4226e5..c0908f0056 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -21,7 +21,9 @@ require_relative "../../../test_helper" require "agama/config" +require "agama/storage/config" require "agama/storage/config_conversions" +require "agama/storage/configs" require "y2storage/encryption_method" require "y2storage/filesystems/mount_by_type" require "y2storage/filesystems/type" @@ -51,10 +53,60 @@ end end -shared_examples "without partitions" do |config_proc| - it "sets #partitions to the expected value" do - config = config_proc.call(subject.convert) - expect(config.partitions).to eq([]) +shared_examples "without spacePolicy" do |config_proc| + context "if the default space policy is 'keep'" do + let(:product_space_policy) { "keep" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions).to be_empty + end + end + + context "if the default space policy is 'delete'" do + let(:product_space_policy) { "delete" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(1) + + partition = partitions.first + expect(partition.search.name).to be_nil + expect(partition.search.if_not_found).to eq(:skip) + expect(partition.search.max).to be_nil + expect(partition.delete?).to eq(true) + end + end + + context "if the default space policy is 'resize'" do + let(:product_space_policy) { "resize" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(1) + + partition = partitions.first + expect(partition.search.name).to be_nil + expect(partition.search.if_not_found).to eq(:skip) + expect(partition.search.max).to be_nil + expect(partition.delete?).to eq(false) + expect(partition.size.default?).to eq(false) + expect(partition.size.min).to eq(Y2Storage::DiskSize.zero) + expect(partition.size.max).to be_nil + end + end + + context "if the default space policy is 'custom'" do + let(:product_space_policy) { "custom" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions).to be_empty + end end end @@ -333,120 +385,6 @@ end end -shared_examples "with resizeIfNeeded" do |config_proc| - context "if 'resizeIfNeeded' is true" do - let(:resizeIfNeeded) { true } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(false) - expect(size.min).to eq(Y2Storage::DiskSize.zero) - expect(size.max).to be_nil - end - end - - context "if 'resizeIfNeeded' is false" do - let(:resizeIfNeeded) { false } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to be_nil - expect(size.max).to be_nil - end - end -end - -shared_examples "with size and resizeIfNeeded" do |config_proc| - let(:size) do - { - default: true, - min: 1.GiB.to_i, - max: 10.GiB.to_i - } - end - - context "if 'resizeIfNeeded' is true" do - let(:resizeIfNeeded) { true } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(false) - expect(size.min).to eq(Y2Storage::DiskSize.zero) - expect(size.max).to be_nil - end - end - - context "if 'resizeIfNeeded' is false" do - let(:resizeIfNeeded) { false } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end -end - -shared_examples "with size and resize" do |config_proc| - let(:size) do - { - default: true, - min: 1.GiB.to_i, - max: 10.GiB.to_i - } - end - - context "if 'resize' is true" do - let(:resize) { true } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end - - context "if 'size' is false" do - let(:resize) { false } - - it "sets #size to the expected value" do - config = config_proc.call(subject.convert) - size = config.size - expect(size).to be_a(Agama::Storage::Configs::Size) - expect(size.default?).to eq(true) - expect(size.min).to eq(1.GiB) - expect(size.max).to eq(10.GiB) - end - end -end - -shared_examples "with delete" do |config_proc| - it "sets #delete to true" do - config = config_proc.call(subject.convert) - expect(config.delete?).to eq(true) - end -end - -shared_examples "with deleteIfNeeded" do |config_proc| - it "sets #delete_if_needed to true" do - config = config_proc.call(subject.convert) - expect(config.delete_if_needed?).to eq(true) - end -end - shared_examples "with partitions" do |config_proc| let(:partitions) do [ @@ -563,31 +501,6 @@ let(:partition) { { size: size } } include_examples "with size", partition_proc end - - context "if a partition spicifies 'resizeIfNeeded'" do - let(:partition) { { resizeIfNeeded: resizeIfNeeded } } - include_examples "with resizeIfNeeded", partition_proc - end - - context "if a partition spicifies both 'size' and 'resizeIfNeeded'" do - let(:partition) { { size: size, resizeIfNeeded: resizeIfNeeded } } - include_examples "with size and resizeIfNeeded", partition_proc - end - - context "if a partition spicifies both 'size' and 'resize'" do - let(:partition) { { size: size, resize: resize } } - include_examples "with size and resize", partition_proc - end - - context "if a partition specifies 'delete'" do - let(:partition) { { delete: true } } - include_examples "with delete", partition_proc - end - - context "if a partition specifies 'deleteIfNeeded'" do - let(:partition) { { deleteIfNeeded: true } } - include_examples "with deleteIfNeeded", partition_proc - end end shared_examples "with spacePolicy" do |config_proc| @@ -650,39 +563,54 @@ shared_examples "with spacePolicy and partitions" do |config_proc| let(:partitions) do [ + # Partition exists and it is used. { name: "/dev/vda1", - mountPath: "/data" + mountPath: "/test1", + size: { default: true, min: 10.GiB.to_i } }, + # Partition exists and it is used. { - name: "/dev/vda2", - mountPath: "swap", - filesystem: { type: "swap" } + name: "/dev/vda2", + mountPath: "/test2", + resizeIfNeeded: true, + size: { default: false, min: 10.GiB.to_i } }, + # Partition exists and it is used. { name: "/dev/vda3", - mountPath: "/home", - size: { default: false, min: 1.GiB.to_i, max: 10.GiB.to_i } + mountPath: "/test3", + resize: true, + size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } }, + # Partition exists and it is not used (space action). { name: "/dev/vda4", - resizeIfNeeded: true + resizeIfNeeded: true, + size: { default: false, min: 10.GiB.to_i } }, + # Partition exists and it is not used (space action). { - name: "/dev/vda5", - deleteIfNeeded: true + name: "/dev/vda5", + resize: true, + size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } }, + # Partition exists and it is not used (space action). { - name: "/dev/vda6", - size: { default: false, min: 5.GiB } + name: "/dev/vda6", + delete: true }, + # Partition exists and it is not used (space action). { - name: "/dev/vda7", - delete: true + name: "/dev/vda7", + deleteIfNeeded: true }, + # Partition does not exist. { - mountPath: "/", - filesystem: { type: "btrfs" } + mountPath: "/", + resizeIfNeeded: true, + size: { default: false, min: 10.GiB.to_i }, + filesystem: { type: "btrfs" } } ] end @@ -697,9 +625,6 @@ expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[2].size.default?).to eq(false) - expect(partitions[2].size.min).to eq(1.GiB) - expect(partitions[2].size.max).to eq(10.GiB) expect(partitions[3].filesystem.path).to eq("/") end end @@ -714,9 +639,6 @@ expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[2].size.default?).to eq(false) - expect(partitions[2].size.min).to eq(1.GiB) - expect(partitions[2].size.max).to eq(10.GiB) expect(partitions[3].filesystem.path).to eq("/") expect(partitions[4].search.name).to be_nil expect(partitions[4].search.max).to be_nil @@ -734,9 +656,6 @@ expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[2].size.default?).to eq(false) - expect(partitions[2].size.min).to eq(1.GiB) - expect(partitions[2].size.max).to eq(10.GiB) expect(partitions[3].filesystem.path).to eq("/") expect(partitions[4].search.name).to be_nil expect(partitions[4].search.max).to be_nil @@ -756,31 +675,152 @@ expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[2].size.default?).to eq(false) - expect(partitions[2].size.min).to eq(1.GiB) - expect(partitions[2].size.max).to eq(10.GiB) expect(partitions[3].search.name).to eq("/dev/vda4") - expect(partitions[3].size.default?).to eq(false) - expect(partitions[3].size.min).to eq(Y2Storage::DiskSize.zero) - expect(partitions[3].size.max).to be_nil expect(partitions[4].search.name).to eq("/dev/vda5") - expect(partitions[4].delete_if_needed?).to eq(true) expect(partitions[5].search.name).to eq("/dev/vda6") - expect(partitions[5].size.default?).to eq(false) - expect(partitions[5].size.min).to eq(5.GiB) - expect(partitions[5].size.max).to eq(Y2Storage::DiskSize.unlimited) expect(partitions[6].search.name).to eq("/dev/vda7") - expect(partitions[6].delete?).to eq(true) expect(partitions[7].filesystem.path).to eq("/") end + + context "if a partition spicifies 'resizeIfNeeded'" do + let(:partitions) { [{ resizeIfNeeded: resizeIfNeeded }] } + + context "if 'resizeIfNeeded' is true" do + let(:resizeIfNeeded) { true } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.partitions.first.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(Y2Storage::DiskSize.zero) + expect(size.max).to be_nil + end + end + + context "if 'resizeIfNeeded' is false" do + let(:resizeIfNeeded) { false } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.partitions.first.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to be_nil + expect(size.max).to be_nil + end + end + end + + context "if a partition spicifies both 'size' and 'resizeIfNeeded'" do + let(:partitions) { [{ size: size, resizeIfNeeded: resizeIfNeeded }] } + + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + context "if 'resizeIfNeeded' is true" do + let(:resizeIfNeeded) { true } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.partitions.first.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(Y2Storage::DiskSize.zero) + expect(size.max).to be_nil + end + end + + context "if 'resizeIfNeeded' is false" do + let(:resizeIfNeeded) { false } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.partitions.first.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + end + + context "if a partition spicifies both 'size' and 'resize'" do + let(:partitions) { [{ size: size, resize: resize }] } + + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + context "if 'resize' is true" do + let(:resize) { true } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.partitions.first.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + + context "if 'size' is false" do + let(:resize) { false } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.partitions.first.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + end + + context "if a partition specifies 'delete'" do + let(:partitions) { [{ delete: true }] } + + it "sets #delete to true" do + config = config_proc.call(subject.convert) + partition = config.partitions.first + expect(partition.delete?).to eq(true) + end + end + + context "if a partition specifies 'deleteIfNeeded'" do + let(:partitions) { [{ deleteIfNeeded: true }] } + + it "sets #delete_if_needed to true" do + config = config_proc.call(subject.convert) + partition = config.partitions.first + expect(partition.delete_if_needed?).to eq(true) + end + end end end describe Agama::Storage::ConfigConversions::FromModel do subject do - described_class.new(model_json) + described_class.new(model_json, product_config: product_config) + end + + let(:product_config) do + Agama::Config.new({ "storage" => { "space_policy" => product_space_policy } }) end + let(:product_space_policy) { nil } + before do # Speed up tests by avoding real check of TPM presence. allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) @@ -797,12 +837,170 @@ context "with an empty JSON" do let(:model_json) { {} } + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + expect(boot).to be_a(Agama::Storage::Configs::Boot) + expect(boot.configure?).to eq(true) + expect(boot.device).to be_a(Agama::Storage::Configs::BootDevice) + expect(boot.device.default?).to eq(true) + expect(boot.device.device_alias).to be_nil + end + it "sets #drives to the expected value" do config = subject.convert expect(config.drives).to be_empty end end + context "with a JSON specifying 'boot'" do + let(:model_json) do + { + boot: { + configure: configure, + device: { + default: default, + name: name + } + }, + drives: drives + } + end + + let(:default) { false } + let(:name) { nil } + let(:drives) { [] } + + context "if boot is set to be configured" do + let(:configure) { true } + + context "and the boot device is set to default" do + let(:default) { true } + let(:name) { "/dev/vda" } + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(true) + expect(boot.device.device_alias).to be_nil + end + end + + context "and the boot device is not set to default" do + let(:default) { false } + + context "and the boot device does not specify 'name'" do + let(:name) { nil } + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to be_nil + end + end + + context "and the boot device specifies a 'name'" do + let(:name) { "/dev/vda" } + + context "and there is a drive model for the given boot device name" do + let(:drives) do + [ + { name: "/dev/vda", alias: device_alias } + ] + end + + context "and the drive model specifies an alias" do + let(:device_alias) { "boot" } + + it "does not add more drives" do + config = subject.convert + expect(config.drives.size).to eq(1) + + drive = config.drives.first + expect(drive.alias).to eq("boot") + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq("boot") + end + end + + context "and the drive model does not specify an alias" do + let(:device_alias) { nil } + + it "does not add more drives" do + config = subject.convert + expect(config.drives.size).to eq(1) + end + + it "sets an alias to the boot drive config" do + config = subject.convert + drive = config.drives.first + expect(drive.alias).to_not be_nil + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + drive = config.drives.first + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq(drive.alias) + end + end + end + + context "and there is no drive model for the given boot device name" do + let(:drives) do + [ + { name: "/dev/vdb" } + ] + end + + it "adds a drive for the boot device" do + config = subject.convert + expect(config.drives.size).to eq(2) + + drive = config.drives.find { |d| d.search.name == name } + expect(drive.alias).to_not be_nil + expect(drive.partitions).to be_empty + end + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + drive = config.drives.find { |d| d.search.name == name } + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq(drive.alias) + end + end + end + end + end + + context "if boot is not set to be configured" do + let(:configure) { false } + let(:default) { true } + let(:name) { "/dev/vda" } + + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + expect(boot.configure?).to eq(false) + expect(boot.device.default?).to eq(true) + expect(boot.device.device_alias).to be_nil + end + end + end + context "with a JSON specifying 'drives'" do let(:model_json) do { drives: drives } @@ -870,9 +1068,9 @@ include_examples "without ptableType", drive_proc end - context "if a drive does not spicify neither 'spacePolicy' nor 'partitions'" do + context "if a drive does not specifies 'spacePolicy'" do let(:drive) { {} } - include_examples "without partitions", drive_proc + include_examples "without spacePolicy", drive_proc end context "if a drive specifies 'name'" do diff --git a/service/test/agama/storage/config_conversions/to_json_test.rb b/service/test/agama/storage/config_conversions/to_json_test.rb index 4b6b9d1062..b67a519927 100644 --- a/service/test/agama/storage/config_conversions/to_json_test.rb +++ b/service/test/agama/storage/config_conversions/to_json_test.rb @@ -615,7 +615,7 @@ { boot: { configure: true, - device: "/dev/vdb" + device: "vdb" } } end @@ -625,7 +625,7 @@ expect(boot_json).to eq( { configure: true, - device: "/dev/vdb" + device: "vdb" } ) end diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index fed7db4b8f..f6b23fefed 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -602,7 +602,7 @@ Agama::Storage::ConfigConversions::FromJSON .new(config_json) .convert - .tap { |c| Agama::Storage::ConfigSolver.new(devicegraph, product_config).solve(c) } + .tap { |c| Agama::Storage::ConfigSolver.new(product_config, devicegraph).solve(c) } end before do @@ -624,12 +624,102 @@ it "generates the expected JSON" do expect(subject.convert).to eq( { + boot: { + configure: true, + device: { default: true } + }, drives: [] } ) end end + context "if #boot is set to be configured" do + let(:config_json) do + { + boot: { + configure: true, + device: device_alias + }, + drives: [ + { + search: "/dev/vda", + partitions: [ + { filesystem: { path: "/" } } + ] + }, + { + search: "/dev/vdb", + alias: "vdb" + } + ] + } + end + + context "and uses the default boot device" do + let(:device_alias) { nil } + + it "generates the expected JSON for 'boot'" do + boot_model = subject.convert[:boot] + + expect(boot_model).to eq( + { + configure: true, + device: { + default: true, + name: "/dev/vda" + } + } + ) + end + end + + context "and uses a specific boot device" do + let(:device_alias) { "vdb" } + + it "generates the expected JSON for 'boot'" do + boot_model = subject.convert[:boot] + + expect(boot_model).to eq( + { + configure: true, + device: { + default: false, + name: "/dev/vdb" + } + } + ) + end + end + end + + context "if #boot is set to not be configured" do + let(:config_json) do + { + boot: { + configure: false, + device: "vda" + }, + drives: [ + { + search: "/dev/vda", + alias: "vda" + } + ] + } + end + + it "generates the expected JSON for 'boot'" do + boot_model = subject.convert[:boot] + + expect(boot_model).to eq( + { + configure: false + } + ) + end + end + context "if #drives is configured" do let(:config_json) do { drives: drives } @@ -645,9 +735,9 @@ let(:drive) { {} } it "generates the expected JSON for 'drives'" do - drives_json = subject.convert[:drives] + drives_model = subject.convert[:drives] - expect(drives_json).to eq( + expect(drives_model).to eq( [ { name: "/dev/vda", spacePolicy: "keep", partitions: [] }, { name: "/dev/vdb", spacePolicy: "keep", partitions: [] } @@ -659,9 +749,9 @@ let(:drive) { { search: "/dev/vdd" } } it "generates the expected JSON for 'drives'" do - drives_json = subject.convert[:drives] + drives_model = subject.convert[:drives] - expect(drives_json).to eq( + expect(drives_model).to eq( [ { name: "/dev/vda", spacePolicy: "keep", partitions: [] } ] @@ -673,9 +763,9 @@ let(:drive) { { search: "/dev/vda" } } it "generates the expected JSON for 'drives'" do - drives_json = subject.convert[:drives] + drives_model = subject.convert[:drives] - expect(drives_json).to eq( + expect(drives_model).to eq( [ { name: "/dev/vda", spacePolicy: "keep", partitions: [] }, { name: "/dev/vdb", spacePolicy: "keep", partitions: [] } diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index 8b3c1253ec..3592f57e61 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -110,11 +110,145 @@ .and_return(true) end - subject { described_class.new(devicegraph, product_config, disk_analyzer: disk_analyzer) } + subject { described_class.new(product_config, devicegraph, disk_analyzer: disk_analyzer) } describe "#solve" do let(:scenario) { "empty-hd-50GiB.yaml" } + context "if a config does not specify the boot device alias" do + let(:config_json) do + { + boot: { configure: true }, + drives: [ + { + alias: device_alias, + partitions: [ + { filesystem: { path: "/" } } + ] + } + ] + } + end + + let(:device_alias) { "root" } + + context "and the boot device is set to be the default" do + before do + config.boot.device.default = true + end + + it "sets the alias of the root drive as boot device alias" do + subject.solve(config) + boot = config.boot + expect(boot.device.device_alias).to eq("root") + end + + context "and the root drive has no alias" do + let(:device_alias) { nil } + + it "sets an alias to the root drive" do + subject.solve(config) + drive = config.drives.first + expect(drive.alias).to_not be_nil + end + + it "sets the alias of root drive as boot device alias" do + subject.solve(config) + boot = config.boot + drive = config.drives.first + expect(boot.device.device_alias).to eq(drive.alias) + end + + context "and root is over a logical volume" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + boot: { configure: true }, + drives: [ + { + search: "/dev/vda", + alias: device_alias, + partitions: [ + { search: "/dev/vda2", alias: "pv" } + ] + }, + { search: "/dev/vdb", alias: "disk2" } + ], + volumeGroups: [ + { + physicalVolumes: ["disk2", "pv"], + logicalVolumes: [ + { filesystem: { path: "/" } } + ] + } + ] + } + end + + let(:device_alias) { "disk1" } + + it "sets the alias of first partitioned pv drive as boot device alias" do + subject.solve(config) + boot = config.boot + expect(boot.device.device_alias).to eq("disk1") + end + + context "and the drive has no alias" do + let(:device_alias) { nil } + + it "sets an alias to the drive" do + subject.solve(config) + drive = config.drives.find { |d| d.search.name == "/dev/vda" } + expect(drive.alias).to_not be_nil + end + + it "sets the alias of the drive as boot device alias" do + subject.solve(config) + boot = config.boot + drive = config.drives.find { |d| d.search.name == "/dev/vda" } + expect(boot.device.device_alias).to eq(drive.alias) + end + end + end + end + end + + context "and the boot device is not set to be the default" do + before do + config.boot.device.default = false + end + + it "does not set a boot device alias" do + subject.solve(config) + boot = config.boot + expect(boot.device.device_alias).to be_nil + end + end + + context "and boot is not set to be configured" do + let(:config_json) do + { + boot: { configure: false }, + drives: [ + { + alias: "disk1", + partitions: [ + { filesystem: { path: "/" } } + ] + } + ] + } + end + + it "does not set a boot device alias" do + subject.solve(config) + boot = config.boot + expect(boot.device.device_alias).to be_nil + end + end + end + context "if a config does not specify all the encryption properties" do let(:config_json) do { diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index a5dda91ee3..eaf9eca62e 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -345,6 +345,7 @@ def drive(partitions) storage: { drives: [ { + alias: "root", partitions: [ { filesystem: { path: "/" } @@ -359,9 +360,17 @@ def drive(partitions) it "returns the config model" do expect(subject.model_json).to eq( { + boot: { + configure: true, + device: { + default: true, + name: "/dev/sda" + } + }, drives: [ { name: "/dev/sda", + alias: "root", spacePolicy: "keep", partitions: [ { diff --git a/service/test/y2storage/agama_proposal_search_test.rb b/service/test/y2storage/agama_proposal_search_test.rb index c58a2c73a4..4945e04526 100644 --- a/service/test/y2storage/agama_proposal_search_test.rb +++ b/service/test/y2storage/agama_proposal_search_test.rb @@ -182,7 +182,7 @@ it "register an error and returns nil" do expect(proposal.propose).to be_nil expect(proposal.issues_list).to include an_object_having_attributes( - description: /mandatory partition/, + description: "Mandatory partition not found", severity: Agama::Issue::Severity::ERROR ) end diff --git a/service/test/y2storage/agama_proposal_test.rb b/service/test/y2storage/agama_proposal_test.rb index bc818c2e23..945e73b5ab 100644 --- a/service/test/y2storage/agama_proposal_test.rb +++ b/service/test/y2storage/agama_proposal_test.rb @@ -310,6 +310,10 @@ def partition_config(name: nil, filesystem: nil, size: nil) drive_config(name: name, filesystem: "ext3").tap { |c| c.filesystem.reuse = true } end + before do + config.boot.configure = false + end + context "if the drive is already formatted" do let(:name) { "/dev/vdc" } @@ -501,7 +505,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) it "registers a critical issue" do proposal.propose expect(proposal.issues_list).to include an_object_having_attributes( - description: /mandatory drive/, + description: "Mandatory drive not found", severity: Agama::Issue::Severity::ERROR ) end @@ -590,7 +594,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) it "registers a critical issue" do proposal.propose expect(proposal.issues_list).to include an_object_having_attributes( - description: /mandatory partition/, + description: "Mandatory partition not found", severity: Agama::Issue::Severity::ERROR ) end @@ -1100,6 +1104,7 @@ def partition_config(name: nil, filesystem: nil, size: nil) before do allow_any_instance_of(Y2Storage::Arch).to receive(:ram_size).and_return(8.GiB) + config.boot.configure = false end context "and the partition size is not indicated" do diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index 7951ae948b..079d6bb8f4 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -1,9 +1,14 @@ +/* eslint-disable */ /** * This file was automatically generated by json-schema-to-typescript. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, * and run json-schema-to-typescript to regenerate this file. */ +/** + * Alias used to reference a device. + */ +export type Alias = string; export type FilesystemType = | "bcachefs" | "btrfs" @@ -29,18 +34,25 @@ export type PartitionId = "linux" | "swap" | "lvm" | "raid" | "esp" | "prep" | " * Config model */ export interface Config { + boot?: Boot; drives?: Drive[]; } +export interface Boot { + configure: boolean; + device?: BootDevice; +} +export interface BootDevice { + default: boolean; + name?: string; +} export interface Drive { name: string; - alias?: string; + alias?: Alias; mountPath?: string; filesystem?: Filesystem; spacePolicy?: SpacePolicy; ptableType?: PtableType; partitions?: Partition[]; - boot?: string; - volumeGroups: string[]; } export interface Filesystem { default: boolean; @@ -49,15 +61,13 @@ export interface Filesystem { } export interface Partition { name?: string; - alias?: string; + alias?: Alias; id?: PartitionId; mountPath?: string; filesystem?: Filesystem; size?: Size; delete?: boolean; - // TODO: ignore deleteIfNeeded?: boolean; - // TODO: ignore resize?: boolean; resizeIfNeeded?: boolean; } diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index 811f83d0e2..131eb69bf1 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -27,8 +27,8 @@ import { StorageSection } from "~/components/overview"; import * as ConfigModel from "~/api/storage/types/config-model"; const mockDevices = [ - { name: "/dev/sda", size: 536870912000, volumeGroups: [] }, - { name: "/dev/sdb", size: 697932185600, volumeGroups: [] }, + { name: "/dev/sda", size: 536870912000 }, + { name: "/dev/sdb", size: 697932185600 }, ]; const mockConfig = { drives: [] as ConfigModel.Drive[] }; @@ -54,7 +54,7 @@ describe("when the configuration does not include any device", () => { describe("when the configuration contains one drive", () => { beforeEach(() => { - mockConfig.drives = [{ name: "/dev/sda", spacePolicy: "delete", volumeGroups: [] }]; + mockConfig.drives = [{ name: "/dev/sda", spacePolicy: "delete" }]; }); it("renders the proposal summary", async () => { @@ -105,8 +105,8 @@ describe("when the configuration contains one drive", () => { describe("when the configuration contains several drives", () => { beforeEach(() => { mockConfig.drives = [ - { name: "/dev/sda", spacePolicy: "delete", volumeGroups: [] }, - { name: "/dev/sdb", spacePolicy: "delete", volumeGroups: [] }, + { name: "/dev/sda", spacePolicy: "delete" }, + { name: "/dev/sdb", spacePolicy: "delete" }, ]; }); diff --git a/web/src/components/storage/BootConfigField.tsx b/web/src/components/storage/BootConfigField.tsx index e9398a9379..b275592d55 100644 --- a/web/src/components/storage/BootConfigField.tsx +++ b/web/src/components/storage/BootConfigField.tsx @@ -39,7 +39,7 @@ import { STORAGE as PATHS } from "~/routes/paths"; const Link = ({ isBold = false }: { isBold?: boolean }) => { const text = _("Change boot options"); - return {isBold ? {text} : text}; + return {isBold ? {text} : text}; }; export type BootConfig = { diff --git a/web/src/components/storage/BootSelection.test.tsx b/web/src/components/storage/BootSelection.test.tsx index 354c4571a1..f2d8450770 100644 --- a/web/src/components/storage/BootSelection.test.tsx +++ b/web/src/components/storage/BootSelection.test.tsx @@ -24,9 +24,10 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { mockNavigateFn, plainRender } from "~/test-utils"; import BootSelection from "./BootSelection"; import { StorageDevice } from "~/types/storage"; +import { BootHook } from "~/queries/storage/config-model"; const sda: StorageDevice = { sid: 59, @@ -94,50 +95,62 @@ const sdc: StorageDevice = { udevPaths: ["pci-0000:00-19"], }; -let props; - -describe.skip("BootSelection", () => { - beforeEach(() => { - props = { - isOpen: true, - configureBoot: false, - availableDevices: [sda, sdb, sdc], - bootDevice: undefined, - defaultBootDevice: undefined, - onCancel: jest.fn(), - onAccept: jest.fn(), - }; - }); +const mockAvailableDevices = [sda, sdb, sdc]; + +const mockBoot: BootHook = { + configure: false, + isDefault: false, + deviceName: undefined, + setDevice: jest.fn(), + setDefault: jest.fn(), + disable: jest.fn(), +}; + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => mockNavigateFn, +})); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useAvailableDevices: () => mockAvailableDevices, +})); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useBoot: () => mockBoot, +})); + +describe("BootSelection", () => { const automaticOption = () => screen.queryByRole("radio", { name: "Automatic" }); const selectDiskOption = () => screen.queryByRole("radio", { name: "Select a disk" }); const notConfigureOption = () => screen.queryByRole("radio", { name: "Do not configure" }); const diskSelector = () => screen.queryByRole("combobox", { name: /choose a disk/i }); it("offers an option to configure boot in the installation disk", () => { - plainRender(); + plainRender(); expect(automaticOption()).toBeInTheDocument(); }); it("offers an option to configure boot in a selected disk", () => { - plainRender(); + plainRender(); expect(selectDiskOption()).toBeInTheDocument(); expect(diskSelector()).toBeInTheDocument(); }); it("offers an option to not configure boot", () => { - plainRender(); + plainRender(); expect(notConfigureOption()).toBeInTheDocument(); }); describe("if the current value is set to boot from the installation disk", () => { beforeEach(() => { - props.configureBoot = true; - props.bootDevice = undefined; + mockBoot.configure = true; + mockBoot.isDefault = true; }); it("selects 'Automatic' option by default", () => { - plainRender(); + plainRender(); expect(automaticOption()).toBeChecked(); expect(selectDiskOption()).not.toBeChecked(); expect(diskSelector()).toBeDisabled(); @@ -147,12 +160,13 @@ describe.skip("BootSelection", () => { describe("if the current value is set to boot from a selected disk", () => { beforeEach(() => { - props.configureBoot = true; - props.bootDevice = sdb; + mockBoot.configure = true; + mockBoot.isDefault = false; + mockBoot.deviceName = sda.name; }); it("selects 'Select a disk' option by default", () => { - plainRender(); + plainRender(); expect(automaticOption()).not.toBeChecked(); expect(selectDiskOption()).toBeChecked(); expect(diskSelector()).toBeEnabled(); @@ -162,12 +176,11 @@ describe.skip("BootSelection", () => { describe("if the current value is set to not configure boot", () => { beforeEach(() => { - props.configureBoot = false; - props.bootDevice = sdb; + mockBoot.configure = false; }); it("selects 'Do not configure' option by default", () => { - plainRender(); + plainRender(); expect(automaticOption()).not.toBeChecked(); expect(selectDiskOption()).not.toBeChecked(); expect(diskSelector()).toBeDisabled(); @@ -175,78 +188,48 @@ describe.skip("BootSelection", () => { }); }); - it("does not call onAccept on cancel", async () => { - const { user } = plainRender(); + it("does not change the boot options on cancel", async () => { + const { user } = plainRender(); const cancel = screen.getByRole("button", { name: "Cancel" }); await user.click(cancel); - expect(props.onAccept).not.toHaveBeenCalled(); + expect(mockBoot.setDevice).not.toHaveBeenCalled(); + expect(mockBoot.setDefault).not.toHaveBeenCalled(); + expect(mockBoot.disable).not.toHaveBeenCalled(); }); - describe("if the 'Automatic' option is selected", () => { - beforeEach(() => { - props.configureBoot = false; - props.bootDevice = undefined; - }); - - it("calls onAccept with the selected options on accept", async () => { - const { user } = plainRender(); - - await user.click(automaticOption()); + it("applies the expected boot options when 'Automatic' is selected", async () => { + const { user } = plainRender(); + await user.click(automaticOption()); - const accept = screen.getByRole("button", { name: "Confirm" }); - await user.click(accept); + const accept = screen.getByRole("button", { name: "Accept" }); + await user.click(accept); - expect(props.onAccept).toHaveBeenCalledWith({ - configureBoot: true, - bootDevice: undefined, - }); - }); + expect(mockBoot.setDefault).toHaveBeenCalled(); }); - describe("if the 'Select a disk' option is selected", () => { - beforeEach(() => { - props.configureBoot = false; - props.bootDevice = undefined; - }); + it("applies the expected boot options when a disk is selected", async () => { + const { user } = plainRender(); - it("calls onAccept with the selected options on accept", async () => { - const { user } = plainRender(); + await user.click(selectDiskOption()); + const selector = diskSelector(); + const sdbOption = within(selector).getByRole("option", { name: /sdb/ }); + await user.selectOptions(selector, sdbOption); - await user.click(selectDiskOption()); - const selector = diskSelector(); - const sdbOption = within(selector).getByRole("option", { name: /sdb/ }); - await user.selectOptions(selector, sdbOption); + const accept = screen.getByRole("button", { name: "Accept" }); + await user.click(accept); - const accept = screen.getByRole("button", { name: "Confirm" }); - await user.click(accept); - - expect(props.onAccept).toHaveBeenCalledWith({ - configureBoot: true, - bootDevice: sdb, - }); - }); + expect(mockBoot.setDevice).toHaveBeenCalledWith(sdb.name); }); - describe("if the 'Do not configure' option is selected", () => { - beforeEach(() => { - props.configureBoot = true; - props.bootDevice = sda; - }); - - it("calls onAccept with the selected options on accept", async () => { - const { user } = plainRender(); + it("applies the expected boot options when 'No configure' is selected", async () => { + const { user } = plainRender(); + await user.click(notConfigureOption()); - await user.click(notConfigureOption()); + const accept = screen.getByRole("button", { name: "Accept" }); + await user.click(accept); - const accept = screen.getByRole("button", { name: "Confirm" }); - await user.click(accept); - - expect(props.onAccept).toHaveBeenCalledWith({ - configureBoot: false, - bootDevice: undefined, - }); - }); + expect(mockBoot.disable).toHaveBeenCalled(); }); }); diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index 27fc49fa29..5f4b56a8d6 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -27,10 +27,11 @@ import { DevicesFormSelect } from "~/components/storage"; import { Page } from "~/components/core"; import { deviceLabel } from "~/components/storage/utils"; import { StorageDevice } from "~/types/storage"; -import { useAvailableDevices, useProposalMutation, useProposalResult } from "~/queries/storage"; +import { useAvailableDevices } from "~/queries/storage"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; +import { useBoot } from "~/queries/storage/config-model"; // FIXME: improve classNames // FIXME: improve and rename to BootSelectionDialog @@ -39,50 +40,49 @@ const BOOT_AUTO_ID = "boot-auto"; const BOOT_MANUAL_ID = "boot-manual"; const BOOT_DISABLED_ID = "boot-disabled"; +type BootSelectionState = { + load: boolean; + selectedOption?: string; + configureBoot?: boolean; + bootDevice?: StorageDevice; + defaultBootDevice?: StorageDevice; + availableDevices?: StorageDevice[]; +}; + /** * Allows the user to select the boot configuration. */ export default function BootSelectionDialog() { - type BootSelectionState = { - load: boolean; - selectedOption?: string; - configureBoot?: boolean; - bootDevice?: StorageDevice; - defaultBootDevice?: StorageDevice; - availableDevices?: StorageDevice[]; - }; - const [state, setState] = useState({ load: false }); - const { settings } = useProposalResult(); const availableDevices = useAvailableDevices(); - const updateProposal = useProposalMutation(); const navigate = useNavigate(); + const boot = useBoot(); useEffect(() => { if (state.load) return; let selectedOption: string; - const { bootDevice, configureBoot, defaultBootDevice } = settings; - if (!configureBoot) { + if (!boot.configure) { selectedOption = BOOT_DISABLED_ID; - } else if (configureBoot && bootDevice === "") { + } else if (boot.isDefault) { selectedOption = BOOT_AUTO_ID; } else { selectedOption = BOOT_MANUAL_ID; } - const findDevice = (name: string) => availableDevices.find((d) => d.name === name); + const bootDevice = availableDevices.find((d) => d.name === boot.deviceName); + const defaultBootDevice = boot.isDefault ? bootDevice : undefined; setState({ load: true, - bootDevice: findDevice(bootDevice) || findDevice(defaultBootDevice) || availableDevices[0], - configureBoot, - defaultBootDevice: findDevice(defaultBootDevice), + bootDevice: bootDevice || availableDevices[0], + configureBoot: boot.configure, + defaultBootDevice, availableDevices, selectedOption, }); - }, [availableDevices, settings, state.load]); + }, [availableDevices, boot, state.load]); if (!state.load) return; @@ -92,12 +92,18 @@ export default function BootSelectionDialog() { // const formData = new FormData(e.target); // const mode = formData.get("bootMode"); // const device = formData.get("bootDevice"); - const newSettings = { - configureBoot: state.selectedOption !== BOOT_DISABLED_ID, - bootDevice: state.selectedOption === BOOT_MANUAL_ID ? state.bootDevice.name : undefined, - }; - await updateProposal.mutateAsync({ ...settings, ...newSettings }); + switch (state.selectedOption) { + case BOOT_DISABLED_ID: + boot.disable(); + break; + case BOOT_AUTO_ID: + boot.setDefault(); + break; + default: + boot.setDevice(state.bootDevice?.name); + } + navigate(".."); }; @@ -126,20 +132,20 @@ partitions in the appropriate disk.", setState({ ...state, selectedOption: e.target.value }); }; - const setBootDevice = (v) => { + const changeBootDevice = (v) => { setState({ ...state, bootDevice: v }); }; return ( -

{_("Select booting partition")}

+

{_("Boot options")}

{description}

- + diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 4c9e95bd12..b8b5714f45 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -21,6 +21,7 @@ */ import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { _ } from "~/i18n"; import { useHref } from "react-router-dom"; import { @@ -31,8 +32,10 @@ import { DropdownItem, Divider, } from "@patternfly/react-core"; +import { STORAGE as PATHS } from "~/routes/paths"; export default function ConfigEditorMenu() { + const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); const toggle = () => setIsOpen(!isOpen); @@ -58,7 +61,9 @@ export default function ConfigEditorMenu() { {_("Add LVM volume group")} {_("Add MD RAID")} - {_("Change boot options")} + navigate(PATHS.bootDevice)}> + {_("Change boot options")} + {_("Reinstall an existing system")} diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index cbcbcae82d..396184bba3 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -29,7 +29,7 @@ import { useAvailableDevices } from "~/queries/storage"; import { configModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; -import { useChangeDrive, useSetSpacePolicy } from "~/queries/storage"; +import { useDrive } from "~/queries/storage/config-model"; import * as driveUtils from "~/components/storage/utils/drive"; import { typeDescription, contentDescription } from "~/components/storage/utils/device"; import { Icon } from "../layout"; @@ -89,13 +89,13 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); - const setSpacePolicy = useSetSpacePolicy(); + const { setSpacePolicy } = useDrive(drive.name); const onToggle = () => setIsOpen(!isOpen); const onSpacePolicyChange = (spacePolicy: configModel.SpacePolicy) => { if (spacePolicy === "custom") { return navigate(generatePath(PATHS.spacePolicy, { id: baseName(drive.name) })); } else { - setSpacePolicy(drive.name, spacePolicy); + setSpacePolicy(spacePolicy); setIsOpen(false); } }; @@ -155,40 +155,42 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { ); }; -const SearchSelectorIntro = ({ drive }) => { - const mainText = (drive: configModel.Drive): string => { +const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { + const { isBoot, isExplicitBoot } = useDrive(drive.name); + // TODO: Get volume groups associated to the drive. + const volumeGroups = []; + + const mainText = (): string => { if (driveUtils.hasReuse(drive)) { // The current device will be the only option to choose from return _("This uses existing partitions at the device"); } - const boot = driveUtils.explicitBoot(drive); - if (!driveUtils.hasFilesystem(drive)) { // The current device will be the only option to choose from if (driveUtils.hasPv(drive)) { - if (drive.volumeGroups.length > 1) { - if (boot) { + if (volumeGroups.length > 1) { + if (isExplicitBoot) { return _( "This device will contain the configured LVM groups and any partition needed to boot", ); } return _("This device will contain the configured LVM groups"); } - if (boot) { + if (isExplicitBoot) { return sprintf( // TRANSLATORS: %s is the name of the LVM _("This device will contain the LVM group '%s' and any partition needed to boot"), - drive.volumeGroups[0], + volumeGroups[0], ); } // TRANSLATORS: %s is the name of the LVM - return sprintf(_("This device will contain the LVM group '%s'"), drive.volumeGroups[0]); + return sprintf(_("This device will contain the LVM group '%s'"), volumeGroups[0]); } // The current device will be the only option to choose from - if (boot) { + if (isExplicitBoot) { return _("This device will contain any partition needed for booting"); } @@ -212,17 +214,16 @@ const SearchSelectorIntro = ({ drive }) => { ); }; - const extraText = (drive: configModel.Drive): string => { + const extraText = (): string => { // Nothing to add in these cases if (driveUtils.hasReuse(drive)) return; if (!driveUtils.hasFilesystem(drive)) return; const name = baseName(drive.name); - const boot = driveUtils.explicitBoot(drive); if (driveUtils.hasPv(drive)) { - if (drive.volumeGroups.length > 1) { - if (boot) { + if (volumeGroups.length > 1) { + if (isExplicitBoot) { return sprintf( // TRANSLATORS: %s is the name of the disk (eg. sda) _("%s will still contain the configured LVM groups and any partition needed to boot"), @@ -234,12 +235,12 @@ const SearchSelectorIntro = ({ drive }) => { return sprintf(_("The configured LVM groups will remain at %s"), name); } - if (boot) { + if (isExplicitBoot) { return sprintf( // TRANSLATORS: %1$s is the name of the disk (eg. sda) and %2$s the name of the LVM _("%1$s will still contain the LVM group '%2$s' and any partition needed to boot"), name, - drive.volumeGroups[0], + volumeGroups[0], ); } @@ -247,23 +248,23 @@ const SearchSelectorIntro = ({ drive }) => { // TRANSLATORS: %1$s is the name of the LVM and %2$s the name of the disk (eg. sda) _("The LVM group '%1$s' will remain at %2$s"), name, - drive.volumeGroups[0], + volumeGroups[0], ); } - if (boot) { + if (isExplicitBoot) { // TRANSLATORS: %s is the name of the disk (eg. sda) return sprintf(_("Partitions needed for booting will remain at %s"), name); } - if (drive.boot) { + if (isBoot) { return _("Partitions needed for booting will also be adapted"); } }; - const Content = ({ drive }) => { - const main = mainText(drive); - const extra = extraText(drive); + const Content = () => { + const main = mainText(); + const extra = extraText(); if (extra) { return ( @@ -280,7 +281,7 @@ const SearchSelectorIntro = ({ drive }) => { return (
  • - +
  • ); }; @@ -362,17 +363,21 @@ const SearchSelectorSingleOption = ({ selected }) => { }; const SearchSelectorOptions = ({ drive, selected, onChange }) => { + const { isExplicitBoot } = useDrive(drive.name); + // const boot = isExplicitBoot(drive.name); + if (driveUtils.hasReuse(drive)) return ; if (!driveUtils.hasFilesystem(drive)) { - if (driveUtils.hasPv(drive) || driveUtils.explicitBoot(drive)) { + if (driveUtils.hasPv(drive) || isExplicitBoot) { return ; } return ; } - return ; + // TODO: use withNewVg prop once LVM is added. + return ; }; const SearchSelector = ({ drive, selected, onChange }) => { @@ -385,8 +390,10 @@ const SearchSelector = ({ drive, selected, onChange }) => { }; const RemoveDriveOption = ({ drive }) => { + const { isExplicitBoot } = useDrive(drive.name); + if (driveUtils.hasPv(drive)) return; - if (driveUtils.explicitBoot(drive)) return; + if (isExplicitBoot) return; if (driveUtils.hasRoot(drive)) return; return ( @@ -403,9 +410,9 @@ const DriveSelector = ({ drive, selected }) => { const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); - const changeDrive = useChangeDrive(); + const driveHandler = useDrive(drive.name); const onDriveChange = (newDriveName: string) => { - changeDrive(drive.name, newDriveName); + driveHandler.switch(newDriveName); setIsOpen(false); }; const onToggle = () => setIsOpen(!isOpen); @@ -446,10 +453,12 @@ const DriveSelector = ({ drive, selected }) => { }; const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { + const { isBoot } = useDrive(drive.name); + const text = (drive: configModel.Drive): string => { if (driveUtils.hasRoot(drive)) { if (driveUtils.hasPv(drive)) { - if (drive.boot) { + if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s to install, host LVM and boot"); } @@ -457,7 +466,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { return _("Use %s to install and host LVM"); } - if (drive.boot) { + if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s to install and boot"); } @@ -467,7 +476,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { if (driveUtils.hasFilesystem(drive)) { if (driveUtils.hasPv(drive)) { - if (drive.boot) { + if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s for LVM, additional partitions and booting"); } @@ -475,7 +484,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { return _("Use %s for LVM and additional partitions"); } - if (drive.boot) { + if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s for additional partitions and booting"); } @@ -484,7 +493,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { } if (driveUtils.hasPv(drive)) { - if (drive.boot) { + if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s to host LVM and boot"); } @@ -492,7 +501,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { return _("Use %s to host LVM"); } - if (drive.boot) { + if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s to boot"); } diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index a9ef947835..ed06999023 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -29,7 +29,8 @@ import { baseName, deviceChildren } from "~/components/storage/utils"; import { _ } from "~/i18n"; import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; import { configModel } from "~/api/storage/types"; -import { useConfigModel, useDevices, useSetCustomSpacePolicy } from "~/queries/storage"; +import { useDevices } from "~/queries/storage"; +import { useConfigModel, useDrive } from "~/queries/storage/config-model"; import { toStorageDevice } from "./device-utils"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { sprintf } from "sprintf-js"; @@ -49,9 +50,9 @@ export default function SpacePolicySelection() { const model = useConfigModel({ suspense: true }); const devices = useDevices("system", { suspense: true }); const device = devices.find((d) => baseName(d.name) === id); - const setCustomSpacePolicy = useSetCustomSpacePolicy(); const children = deviceChildren(device); const drive = model.drives.find((d) => baseName(d.name) === id); + const { setSpacePolicy } = useDrive(drive.name); const partitionDeviceAction = (device: StorageDevice) => { const partition = drive.partitions?.find((p) => p.name === device.name); @@ -87,9 +88,7 @@ export default function SpacePolicySelection() { const onSubmit = (e) => { e.preventDefault(); - - setCustomSpacePolicy(drive.name, actions); - + setSpacePolicy("custom", actions); navigate(".."); }; diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index 2c1277cf55..a267cb8975 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -159,12 +159,10 @@ const hasReuse = (drive: configModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath && p.name); }; +// TODO: maybe it should be moved to Drive hook from config-model. +// eslint-disable-next-line @typescript-eslint/no-unused-vars const hasPv = (drive: configModel.Drive): boolean => { - return drive.volumeGroups && drive.volumeGroups.length > 0; -}; - -const explicitBoot = (drive: configModel.Drive): boolean => { - return drive.boot && drive.boot === "explicit"; + return false; }; export { @@ -172,7 +170,6 @@ export { hasReuse, hasFilesystem, hasRoot, - explicitBoot, label, spacePolicyEntry, contentActionsDescription, diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index e74606adf0..72fe11a17a 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -152,7 +152,7 @@ const useDevices = ( /** * Hook that returns the list of available devices for installation. */ -const useAvailableDevices = () => { +const useAvailableDevices = (): StorageDevice[] => { const findDevice = (devices: StorageDevice[], sid: number) => { const device = devices.find((d) => d.sid === sid); diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index a78764a08f..0d0e27046d 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -26,39 +26,129 @@ import { configModel } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; import { SpacePolicyAction } from "~/types/storage"; +function copyModel(model: configModel.Config): configModel.Config { + return JSON.parse(JSON.stringify(model)); +} + +function isNewPartition(partition: configModel.Partition): boolean { + return partition.name === undefined; +} + +function isSpacePartition(partition: configModel.Partition): boolean { + return partition.resizeIfNeeded || partition.delete || partition.deleteIfNeeded; +} + +function isUsedPartition(partition: configModel.Partition): boolean { + return partition.filesystem !== undefined || partition.alias !== undefined; +} + +function isReusedPartition(partition: configModel.Partition): boolean { + return !isNewPartition(partition) && isUsedPartition(partition) && !isSpacePartition(partition); +} + function findDrive(model: configModel.Config, driveName: string): configModel.Drive | undefined { const drives = model.drives || []; return drives.find((d) => d.name === driveName); } -// TODO: add a second drive if needed (e.g., reusing a partition). -function changeDrive(model: configModel.Config, driveName: string, newDriveName: string) { +function removeDrive(model: configModel.Config, driveName: string) { + model.drives = model.drives.filter((d) => d.name !== driveName); +} + +function isUsedDrive(model: configModel.Config, driveName: string) { const drive = findDrive(model, driveName); - if (drive === undefined) return; + if (drive === undefined) return false; - drive.name = newDriveName; - // TODO: assign space policy according to the use-case. - if (drive.spacePolicy === "custom") drive.spacePolicy = "keep"; + return drive.partitions?.some((p) => isNewPartition(p) || isReusedPartition(p)); } -function setSpacePolicy( - model: configModel.Config, +function isBoot(model: configModel.Config, driveName: string): boolean { + return model.boot?.configure && driveName === model.boot?.device?.name; +} + +function isExplicitBoot(model: configModel.Config, driveName: string): boolean { + return !model.boot?.device?.default && driveName === model.boot?.device?.name; +} + +function setBoot(originalModel: configModel.Config, boot: configModel.Boot) { + const model = copyModel(originalModel); + const name = model.boot?.device?.name; + const remove = name !== undefined && isExplicitBoot(model, name) && !isUsedDrive(model, name); + + if (remove) removeDrive(model, name); + + model.boot = boot; + return model; +} + +function setBootDevice(originalModel: configModel.Config, deviceName: string): configModel.Config { + return setBoot(originalModel, { + configure: true, + device: { + default: false, + name: deviceName, + }, + }); +} + +function setDefaultBootDevice(originalModel: configModel.Config): configModel.Config { + return setBoot(originalModel, { + configure: true, + device: { + default: true, + }, + }); +} + +function disableBoot(originalModel: configModel.Config): configModel.Config { + return setBoot(originalModel, { configure: false }); +} + +function switchDrive( + originalModel: configModel.Config, driveName: string, - spacePolicy: "keep" | "delete" | "resize", -) { + newDriveName: string, +): configModel.Config { + if (driveName === newDriveName) return; + + const model = copyModel(originalModel); const drive = findDrive(model, driveName); if (drive === undefined) return; - drive.spacePolicy = spacePolicy; + const newPartitions = (drive.partitions || []).filter(isNewPartition); + const existingPartitions = (drive.partitions || []).filter((p) => !isNewPartition(p)); + const reusedPartitions = existingPartitions.filter(isReusedPartition); + const keepDrive = isExplicitBoot(model, driveName) || reusedPartitions.length; + const newDrive = findDrive(model, newDriveName); + + if (keepDrive) { + drive.partitions = existingPartitions; + } else { + removeDrive(model, driveName); + } + + if (newDrive) { + newDrive.partitions ||= []; + newDrive.partitions = [...newDrive.partitions, ...newPartitions]; + } else { + model.drives.push({ + name: newDriveName, + partitions: newPartitions, + spacePolicy: drive.spacePolicy === "custom" ? undefined : drive.spacePolicy, + }); + } + + return model; } function setCustomSpacePolicy( - model: configModel.Config, + originalModel: configModel.Config, driveName: string, actions: SpacePolicyAction[], -) { +): configModel.Config { + const model = copyModel(originalModel); const drive = findDrive(model, driveName); - if (drive === undefined) return; + if (drive === undefined) return model; drive.spacePolicy = "custom"; drive.partitions ||= []; @@ -90,6 +180,24 @@ function setCustomSpacePolicy( }); } }); + + return model; +} + +function setSpacePolicy( + originalModel: configModel.Config, + driveName: string, + spacePolicy: configModel.SpacePolicy, + actions?: SpacePolicyAction[], +): configModel.Config { + if (spacePolicy === "custom") + return setCustomSpacePolicy(originalModel, driveName, actions || []); + + const model = copyModel(originalModel); + const drive = findDrive(model, driveName); + if (drive !== undefined) drive.spacePolicy = spacePolicy; + + return model; } const configModelQuery = { @@ -121,43 +229,48 @@ export function useConfigModelMutation() { return useMutation(query); } -type ModelActionF = (model: configModel.Config) => void; +export type BootHook = { + configure: boolean; + isDefault: boolean; + deviceName?: string; + setDevice: (deviceName: string) => void; + setDefault: () => void; + disable: () => void; +}; -function useApplyModelAction() { - const originalModel = useConfigModel({ suspense: true }); +export function useBoot(): BootHook { + const model = useConfigModel(); const { mutate } = useConfigModelMutation(); - const model = JSON.parse(JSON.stringify(originalModel)); - - return (action: ModelActionF) => { - action(model); - mutate(model); + return { + configure: model?.boot?.configure || false, + isDefault: model?.boot?.device?.default || false, + deviceName: model?.boot?.device?.name, + setDevice: (deviceName: string) => mutate(setBootDevice(model, deviceName)), + setDefault: () => mutate(setDefaultBootDevice(model)), + disable: () => mutate(disableBoot(model)), }; } -export function useChangeDrive() { - const applyModelAction = useApplyModelAction(); - - return (driveName: string, newDriveName: string) => { - const action: ModelActionF = (model) => changeDrive(model, driveName, newDriveName); - applyModelAction(action); - }; -} - -export function useSetSpacePolicy() { - const applyModelAction = useApplyModelAction(); +export type DriveHook = { + isBoot: boolean; + isExplicitBoot: boolean; + switch: (newName: string) => void; + setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => void; +}; - return (deviceName: string, spacePolicy: "keep" | "delete" | "resize") => { - const action: ModelActionF = (model) => setSpacePolicy(model, deviceName, spacePolicy); - applyModelAction(action); - }; -} +export function useDrive(name: string): DriveHook | undefined { + const model = useConfigModel(); + const { mutate } = useConfigModelMutation(); -export function useSetCustomSpacePolicy() { - const applyModelAction = useApplyModelAction(); + if (findDrive(model, name) === undefined) return; - return (deviceName: string, actions: SpacePolicyAction[]) => { - const action: ModelActionF = (model) => setCustomSpacePolicy(model, deviceName, actions); - applyModelAction(action); + return { + isBoot: isBoot(model, name), + isExplicitBoot: isExplicitBoot(model, name), + switch: (newName) => mutate(switchDrive(model, name, newName)), + setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => { + mutate(setSpacePolicy(model, name, policy, actions)); + }, }; } diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index de6c97b825..395bc4d4b4 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -65,7 +65,7 @@ const SOFTWARE = { const STORAGE = { root: "/storage", targetDevice: "/storage/target-device", - bootingPartition: "/storage/booting-partition", + bootDevice: "/storage/boot-device", spacePolicy: "/storage/space-policy/:id", iscsi: "/storage/iscsi", dasd: "/storage/dasd", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 975c3b8d86..7a94b22553 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -47,7 +47,7 @@ const routes = (): Route => ({ element: , }, { - path: PATHS.bootingPartition, + path: PATHS.bootDevice, element: , }, {