diff --git a/rust/agama-lib/share/examples/storage/model.json b/rust/agama-lib/share/examples/storage/model.json index 7f2155246e..dfb6249729 100644 --- a/rust/agama-lib/share/examples/storage/model.json +++ b/rust/agama-lib/share/examples/storage/model.json @@ -13,7 +13,6 @@ "drives": [ { "name": "/dev/vda", - "alias": "root", "mountPath": "/", "filesystem": { "default": true, @@ -75,6 +74,33 @@ } } ] + }, + { + "name": "/dev/vdc", + "spacePolicy": "delete" + } + ], + "volumeGroups": [ + { + "vgName": "vg0", + "extentSize": 1024, + "targetDevices": ["/dev/vdc"], + "logicalVolumes": [ + { + "lvName": "lv0", + "mountPath": "/data", + "filesystem": { + "default": false, + "type": "ext4" + }, + "size": { + "default": true, + "min": 1111 + }, + "stripes": 8, + "stripeSize": 1204 + } + ] } ] } diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index e1fcf25fda..03afb9ca51 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -9,6 +9,10 @@ "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } + }, + "volumeGroups": { + "type": "array", + "items": { "$ref": "#/$defs/volumeGroup" } } }, "$defs": { @@ -52,7 +56,6 @@ "required": ["name"], "properties": { "name": { "type": "string" }, - "alias": { "$ref": "#/$defs/alias" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, "spacePolicy": { "$ref": "#/$defs/spacePolicy" }, @@ -68,7 +71,6 @@ "additionalProperties": false, "properties": { "name": { "type": "string" }, - "alias": { "$ref": "#/$defs/alias" }, "id": { "$ref": "#/$defs/partitionId" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, @@ -79,9 +81,34 @@ "resizeIfNeeded": { "type": "boolean" } } }, - "alias": { - "description": "Alias used to reference a device.", - "type": "string" + "volumeGroup": { + "type": "object", + "additionalProperties": false, + "required": ["vgName"], + "properties": { + "vgName": { "type": "string" }, + "extentSize": { "type": "integer" }, + "targetDevices": { + "type": "array", + "items": { "type": "string" } + }, + "logicalVolumes": { + "type": "array", + "items": { "$ref": "#/$defs/logicalVolume" } + } + } + }, + "logicalVolume": { + "type": "object", + "additionalProperties": false, + "properties": { + "lvName": { "type": "string" }, + "mountPath": { "type": "string" }, + "filesystem": { "$ref": "#/$defs/filesystem" }, + "size": { "$ref": "#/$defs/size" }, + "stripes": { "type": "integer" }, + "stripeSize": { "type": "integer" } + } }, "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 27ec46f8e3..808433fd67 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -159,6 +159,7 @@ "description": "LVM volume group.", "type": "object", "additionalProperties": false, + "required": ["name"], "properties": { "name": { "description": "Volume group name.", diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 5ec901f56c..be430944d6 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -50,6 +50,11 @@ Mon Mar 10 12:13:19 UTC 2025 - José Iván López González - Package and install the storage model schema (gh#agama-project/agama#2135). +------------------------------------------------------------------- +Fri Mar 7 11:40:56 UTC 2025 - José Iván López González + +- Extend storage model schema with LVM (gh#agama-project/agama#2089). + ------------------------------------------------------------------- Thu Mar 6 12:51:42 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index daf7560099..d38443ebd7 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -60,16 +60,39 @@ def initialize @nfs_mounts = [] end - # Name of the device that will be used to boot the target system, if any. - # - # @note The config has to be solved. - # - # @return [String, nil] + # @return [Configs::Drive, nil] def 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 + drives.find { |d| d.alias?(boot.device.device_alias) } + end + + # Device config containing root. + # + # @return [Configs::Drive, Configs::VolumeGroup, nil] + def root_device + root_drive || root_volume_group + end + + # Drive config containing root. + # + # @return [Configs::Drive, nil] + def root_drive + drives.find { |d| d.root? || d.partitions.any?(&:root?) } + end + + # Volume group config containing a logical volume for root. + # + # @return [Configs::LogicalVolume, nil] + def root_volume_group + volume_groups.find { |v| v.logical_volumes.any?(&:root?) } + end + + # Drive with the given alias. + # + # @return [Configs::Drive, nil] + def drive(device_alias) + drives.find { |d| d.alias?(device_alias) } end # @return [Array] diff --git a/service/lib/agama/storage/config_checkers/boot.rb b/service/lib/agama/storage/config_checkers/boot.rb index 41f3f5c5ed..72b629211c 100644 --- a/service/lib/agama/storage/config_checkers/boot.rb +++ b/service/lib/agama/storage/config_checkers/boot.rb @@ -51,16 +51,17 @@ def device_alias storage_config.boot.device.device_alias end + # Error if a boot device is required and unknown. + # + # This happens when the config solver is not able to infer a boot device, see + # {ConfigSolvers::Boot}. + # # @return [Issue, nil] def missing_alias_issue return unless configure? && device_alias.nil? - # Currently this situation only happens because the config solver was not able to find - # a device config containing a root volume. The message could become inaccurate if the - # solver logic changes. error( - _("The boot device cannot be automatically selected because there is no root (/) " \ - "file system"), + _("The boot device cannot be automatically selected"), kind: :no_root ) end diff --git a/service/lib/agama/storage/config_checkers/volume_group.rb b/service/lib/agama/storage/config_checkers/volume_group.rb index 50e91b95a4..37d08baa88 100644 --- a/service/lib/agama/storage/config_checkers/volume_group.rb +++ b/service/lib/agama/storage/config_checkers/volume_group.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -41,16 +41,17 @@ def initialize(config, storage_config, product_config) @config = config end - # Volume group config issues. + # Issues from a volume group config. # # @return [Array] def issues [ + name_issue, logical_volumes_issues, physical_volumes_issues, physical_volumes_devices_issues, physical_volumes_encryption_issues - ].flatten + ].compact.flatten end private @@ -58,6 +59,15 @@ def issues # @return [Configs::VolumeGroup] attr_reader :config + # Issue if the volume group name is missing. + # + # @return [Issue, nil] + def name_issue + return if config.name && !config.name.empty? + + error(_("There is a volume group without name")) + end + # Issues from logical volumes. # # @return [Array] 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 5e86901b9d..dc9cefcd2d 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions.rb @@ -26,9 +26,11 @@ require "agama/storage/config_conversions/from_model_conversions/encryption" require "agama/storage/config_conversions/from_model_conversions/filesystem" require "agama/storage/config_conversions/from_model_conversions/filesystem_type" +require "agama/storage/config_conversions/from_model_conversions/logical_volume" require "agama/storage/config_conversions/from_model_conversions/partition" require "agama/storage/config_conversions/from_model_conversions/search" require "agama/storage/config_conversions/from_model_conversions/size" +require "agama/storage/config_conversions/from_model_conversions/volume_group" module Agama module Storage 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 index abd656eb73..fa798fe52f 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -29,8 +29,18 @@ module ConfigConversions module FromModelConversions # Boot conversion from model according to the JSON schema. class Boot < Base + # @param model_json [Hash] Boot model. + # @param drives [Array] + def initialize(model_json, drives) + super(model_json) + @drives = drives + end + private + # @return [Array] + attr_reader :drives + # @see Base # @return [Configs::Boot] def default_config @@ -51,7 +61,7 @@ def convert_device boot_device_model = model_json[:device] return if boot_device_model.nil? - FromModelConversions::BootDevice.new(boot_device_model).convert + FromModelConversions::BootDevice.new(boot_device_model, drives).convert 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 index 27f4f0406e..febd55e067 100644 --- 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -28,8 +28,18 @@ module ConfigConversions module FromModelConversions # Boot device conversion from model according to the JSON schema. class BootDevice < Base + # @param model_json [Hash] Boot device model. + # @param drives [Array] + def initialize(model_json, drives) + super(model_json) + @drives = drives + end + private + # @return [Array] + attr_reader :drives + # @see Base # @return [Configs::Boot] def default_config @@ -40,9 +50,24 @@ def default_config # @return [Hash] def conversions { - default: model_json[:default] + default: model_json[:default], + device_alias: convert_device_alias } end + + # @return [String, nil] + def convert_device_alias + # Avoid setting an alias if using the default boot device. + return if model_json[:default] + + name = model_json[:name] + return unless name + + drive = drives.find { |d| d.device_name == name } + return unless drive + + drive.ensure_alias + 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 336ea261c9..d4f4864650 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 @@ -22,6 +22,7 @@ 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_conversions/from_model_conversions/volume_group" require "agama/storage/config" module Agama @@ -49,37 +50,31 @@ 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 + boot: convert_boot(drives || []), + drives: drives, + volume_groups: convert_volume_groups(drives || []) } end + # @param drives [Array] # @return [Configs::Boot, nil] - def convert_boot + def convert_boot(drives) boot_model = model_json[:boot] return unless boot_model - FromModelConversions::Boot.new(boot_model).convert + FromModelConversions::Boot.new(boot_model, drives).convert end + # @return [Array, nil] def convert_drives + add_missing_drives + + drive_models = model_json[:drives] return unless drive_models drive_models.map { |d| convert_drive(d) } @@ -93,87 +88,91 @@ def convert_drive(drive_model) .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 + # @param drives [Array] + # @return [Hash] + def convert_volume_groups(drives) + volume_group_models = model_json[:volumeGroups] + return unless volume_group_models + + volume_group_models.map { |v| convert_volume_group(v, drives) } end - # Drive config for the boot device, if any. + # @param volume_group_model [Hash] + # @param drives [Array] # - # @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 } + # @return [Configs::VolumeGroup] + def convert_volume_group(volume_group_model, drives) + FromModelConversions::VolumeGroup + .new(volume_group_model, drives, model_json[:encryption]) + .convert end - # Drive models to convert to drive configs. + # Add missing drives to the model. # - # 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}. + # Adds a drive for the selected boot device and for the LVM target devices if needed. # # @return [Array, nil] - def drive_models - return @drive_models if @calculated_drive_models + def add_missing_drives + model_json[:drives] ||= [] + drives = model_json[:drives] - @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? + # Add boot device, if needed. + drives << boot_device if missing_boot_device? - 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 + # Add target devices, if needed. + lvm_target_names.each do |name| + drives << lvm_target_device(name) if missing_drive?(name) + end end - # Whether a drive model for the boot device is missing in the list of drives. See - # {#calculate_missing_boot_device}. + # Whether the boot drive is missing in the model. # # @return [Boolean] - def missing_boot_drive? - return @missing_boot_drive if @calculated_missing_boot_drive + def missing_boot_device? + configure_boot = model_json.dig(:boot, :configure) + default_boot = model_json.dig(:boot, :device, :default) + boot_device_name = model_json.dig(:boot, :device, :name) + + return false unless configure_boot && !default_boot && !boot_device_name.nil? - @missing_boot_drive ||= calculate_missing_boot_drive + missing_drive?(boot_device_name) end - # @see #missing_boot_drive? + # Whether a drive with the given name is missing in the model. + # + # @param name [String] # @return [Boolean] - def calculate_missing_boot_drive - @calculated_missing_boot_drive = true + def missing_drive?(name) + drives = model_json[:drives] || [] + drives.none? { |d| d[:name] == name } + end - configure_boot = model_json.dig(:boot, :configure) - default_boot = model_json.dig(:boot, :device, :default) + # All the target devices for creating LVM physical volumes. + # + # @return [Array] + def lvm_target_names + volume_groups = model_json[:volumeGroups] || [] + volume_groups.flat_map { |v| v[:targetDevices] || [] } + end - return false unless configure_boot && !default_boot && !boot_device_name.nil? + # Drive model for the boot device. + # + # @return [Hash] + def boot_device + name = model_json.dig(:boot, :device, :name) - drive_models = model_json[:drives] || [] - drive_models.none? { |d| d[:name] == boot_device_name } + # 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. + { name: name, spacePolicy: "keep" } end - # Name of the device selected for booting, if any. + # Drive model for a LVM target device. # - # @return [String, nil] - def boot_device_name - @boot_device_name ||= model_json.dig(:boot, :device, :name) + # @param name [String] + # @return [Hash] + def lvm_target_device(name) + { name: name, spacePolicy: product_config.space_policy } 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 461ee6d9cb..e229d873b4 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 @@ -67,7 +67,6 @@ def default_config def conversions { search: convert_search, - alias: drive_model[:alias], filesystem: convert_filesystem, ptable_type: convert_ptable_type, partitions: convert_partitions(encryption_model) diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb new file mode 100644 index 0000000000..fd040277ef --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/with_filesystem" +require "agama/storage/config_conversions/from_model_conversions/with_size" +require "agama/storage/configs/logical_volume" +require "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Logical volume conversion from model according to the JSON schema. + class LogicalVolume < Base + include WithFilesystem + include WithSize + + private + + alias_method :logical_volume_model, :model_json + + # @see Base + # @return [Configs::LogicalVolume] + def default_config + Configs::LogicalVolume.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + name: logical_volume_model[:lvName], + filesystem: convert_filesystem, + size: convert_size, + stripes: logical_volume_model[:stripes], + stripe_size: convert_stripe_size + } + end + + # @return [Y2Storage::DiskSize, nil] + def convert_stripe_size + value = logical_volume_model[:stripeSize] + return unless value + + Y2Storage::DiskSize.new(value) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb index 577dc02345..ca53d87971 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb @@ -63,7 +63,6 @@ def default_config def conversions { search: convert_search, - alias: partition_model[:alias], encryption: convert_encryption, filesystem: convert_filesystem, size: convert_size, diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb new file mode 100644 index 0000000000..e178bb2c3f --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/logical_volume" +require "agama/storage/config_conversions/from_model_conversions/encryption" +require "agama/storage/configs/volume_group" +require "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Volume group conversion from model according to the JSON schema. + class VolumeGroup < Base + # @param model_json [Hash] + # @param drives [Array] + # @param encryption_model [Hash, nil] + def initialize(model_json, drives, encryption_model = nil) + super(model_json) + @drives = drives + @encryption_model = encryption_model + end + + private + + alias_method :volume_group_model, :model_json + + # @return [Array] + attr_reader :drives + + # @return [Hash, nil] + attr_reader :encryption_model + + # @see Base + # @return [Configs::VolumeGroup] + def default_config + Configs::VolumeGroup.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + name: volume_group_model[:vgName], + extent_size: convert_extent_size, + physical_volumes_devices: convert_physical_volumes_devices, + physical_volumes_encryption: convert_physical_volumes_encryption, + logical_volumes: convert_logical_volumes + } + end + + # @return [Y2Storage::DiskSize, nil] + def convert_extent_size + value = volume_group_model[:extentSize] + return unless value + + Y2Storage::DiskSize.new(value) + end + + # @return [Array, nil] + def convert_physical_volumes_devices + target_names = volume_group_model[:targetDevices] + return unless target_names + + target_names + .map { |n| drive(n)&.ensure_alias } + .compact + end + + # @return [Configs::Encryption, nil] + def convert_physical_volumes_encryption + return unless encryption_model + + FromModelConversions::Encryption.new(encryption_model).convert + end + + # @return [Array, nil] + def convert_logical_volumes + logical_volumes_model = volume_group_model[:logicalVolumes] + return unless logical_volumes_model + + logical_volumes_model.map { |l| FromModelConversions::LogicalVolume.new(l).convert } + end + + # @param name [String] + # @return [Configs::Drive, nil] + def drive(name) + drives.find { |d| d.device_name == name } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb index 1ff08b3bb7..e1855d4ce5 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb @@ -29,7 +29,7 @@ module FromModelConversions module WithEncryption # @return [Configs::Encryption, nil] def convert_encryption - # Do not encrypt reused partitions. + # Do not encrypt a reused device. return if model_json[:name] return if encryption_model.nil? 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 ec8e68fc34..c5ba9d4219 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 @@ -43,40 +43,47 @@ def convert_partitions(encryption_model = nil) when "resize" [used_partition_configs(encryption_model), resize_all_partition_config].flatten else - partition_configs(encryption_model) + [used_partition_configs(encryption_model), action_partition_configs].flatten end end - # @param partition_model [Hash] # @param encryption_model [Hash, nil] - # - # @return [Configs::Partition] - def convert_partition(partition_model, encryption_model = nil) - FromModelConversions::Partition.new(partition_model, encryption_model).convert + # @return [Array] + def used_partition_configs(encryption_model = nil) + used_partitions.map { |p| convert_partition(p, encryption_model) } end # @return [Array] - # @param encryption_model [Hash, nil] - def partition_configs(encryption_model = nil) - partitions.map { |p| convert_partition(p, encryption_model) } + def action_partition_configs + action_partitions.map { |p| convert_partition(p) } end # Partitions with any usage (format, mount, etc). - # @param encryption_model [Hash, nil] # - # @return [Array] - def used_partition_configs(encryption_model = nil) - used_partitions.map { |p| convert_partition(p, encryption_model) } + # @return [Array] + def used_partitions + partitions.reject { |p| space_policy_partition?(p) } end + # Partitions representing a space policy action (delete, resize if needed), excluding + # the keep actions. + # + # Omitting the partitions that only represent a keep action is important. Otherwise, the + # resulting config would contain a partition without any usage (delete, resize, format, + # etc) and without a mount path. Such a partition is not supported by the model yet (see + # {ModelSupportChecker}) and would make impossible to build a model again from the + # resulting config. + # # @return [Array] - def partitions - model_json[:partitions] || [] + def action_partitions + partitions + .select { |p| space_policy_partition?(p) } + .reject { |p| keep_action_partition?(p) } end # @return [Array] - def used_partitions - partitions.reject { |p| space_policy_partition?(p) } + def partitions + model_json[:partitions] || [] end # Whether the partition only represents a space policy action. @@ -84,29 +91,48 @@ def used_partitions # @param partition_model [Hash] # @return [Boolean] def space_policy_partition?(partition_model) - partition_model[:delete] || - partition_model[:deleteIfNeeded] || - resize_action_partition?(partition_model) + delete_action_partition?(partition_model) || + resize_action_partition?(partition_model) || + keep_action_partition?(partition_model) + end + + # @param partition_model [Hash] + # @return [Boolean] + def delete_action_partition?(partition_model) + partition_model[:delete] || partition_model[:deleteIfNeeded] end # @param partition_model [Hash] # @return [Boolean] def resize_action_partition?(partition_model) - return false if partition_model[:name].nil? || any_usage?(partition_model) + return false if delete_action_partition?(partition_model) - return true if partition_model[:resizeIfNeeded] + return false if any_usage?(partition_model) - partition_model[:size] && !partition_model.dig(:size, :default) + partition_model[:name] && ( + 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 keep_action_partition?(partition_model) + return false if delete_action_partition?(partition_model) + + return false if resize_action_partition?(partition_model) + + return false if any_usage?(partition_model) + + !partition_model[:name].nil? + end + + # TODO: improve check by ensuring the partition is referenced by other device. # # @param partition_model [Hash] # @return [Boolean] def any_usage?(partition_model) - partition_model[:mountPath] || - partition_model[:filesystem] || - partition_model[:alias] + partition_model[:mountPath] || partition_model[:filesystem] end # @return [Configs::Partition] @@ -118,6 +144,14 @@ def delete_all_partition_config def resize_all_partition_config Configs::Partition.new_for_shrink_any_if_needed end + + # @param partition_model [Hash] + # @param encryption_model [Hash, nil] + # + # @return [Configs::Partition] + def convert_partition(partition_model, encryption_model = nil) + FromModelConversions::Partition.new(partition_model, encryption_model).convert + end end 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 1d2c252603..8c4760dfdd 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions.rb @@ -26,9 +26,11 @@ require "agama/storage/config_conversions/to_model_conversions/drive" require "agama/storage/config_conversions/to_model_conversions/encryption" require "agama/storage/config_conversions/to_model_conversions/filesystem" +require "agama/storage/config_conversions/to_model_conversions/logical_volume" require "agama/storage/config_conversions/to_model_conversions/partition" require "agama/storage/config_conversions/to_model_conversions/size" require "agama/storage/config_conversions/to_model_conversions/space_policy" +require "agama/storage/config_conversions/to_model_conversions/volume_group" module Agama module Storage 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 index 71e1283f01..23b68ac257 100644 --- 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -39,7 +39,7 @@ def initialize(config) def conversions { default: config.boot.device.default?, - name: config.boot_device + name: config.boot_device&.device_name } 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 2634fbe1b4..2527480fb2 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 @@ -23,6 +23,7 @@ require "agama/storage/config_conversions/to_model_conversions/boot" require "agama/storage/config_conversions/to_model_conversions/encryption" require "agama/storage/config_conversions/to_model_conversions/drive" +require "agama/storage/config_conversions/to_model_conversions/volume_group" module Agama module Storage @@ -41,9 +42,10 @@ def initialize(config) # @see Base#conversions def conversions { - boot: convert_boot, - encryption: convert_encryption, - drives: convert_drives + boot: convert_boot, + encryption: convert_encryption, + drives: convert_drives, + volumeGroups: convert_volume_groups } end @@ -65,9 +67,14 @@ def convert_drives valid_drives.map { |d| ToModelConversions::Drive.new(d).convert } end + # @return [Array] + def convert_volume_groups + config.volume_groups.map { |v| ToModelConversions::VolumeGroup.new(v, config).convert } + end + # @return [Array] def valid_drives - config.drives.select(&:found_device) + config.drives.reject { |d| d.search&.skip_device? } end # TODO: proper support for a base encryption. @@ -81,19 +88,30 @@ def base_encryption root_encryption || first_encryption end - # Encryption from root partition. + # Encryption for root. + # + # @note If root is a logical volume, then the encryption of the automatically generated + # physical volumes is considered. Encryption from logical volumes is ignored. # - # @return [Configs::Encryption, nil] nil if there is no encryption for root partition. + # @return [Configs::Encryption, nil] nil if there is no encryption for root. def root_encryption - root_partition = config.partitions.find { |p| p.filesystem&.root? } - root_partition&.encryption + root_partition&.encryption || config.root_volume_group&.physical_volumes_encryption end - # Encryption from the first encrypted partition. + # Partition config for root. # - # @return [Configs::Encryption, nil] nil if there is no encrypted partition. + # @return [Configs::Partition, nil] + def root_partition + config.partitions.find(&:root?) + end + + # Encryption from the first encrypted partition or from the first volume group with + # automatically generated and encrypted physical volumes. + # + # @return [Configs::Encryption, nil] def first_encryption - config.partitions.find(&:encryption)&.encryption + config.partitions.find(&:encryption)&.encryption || + config.volume_groups.find(&:physical_volumes_encryption)&.physical_volumes_encryption end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb index 0542d7f4fe..78dbc52887 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -45,8 +45,7 @@ def initialize(config) # @see Base#conversions def conversions { - name: config.found_device&.name, - alias: config.alias, + name: config.device_name, mountPath: config.filesystem&.path, filesystem: convert_filesystem, spacePolicy: convert_space_policy, diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb new file mode 100644 index 0000000000..b99fd93c79 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/with_filesystem" +require "agama/storage/config_conversions/to_model_conversions/with_size" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # LVM logical volume conversion to model according to the JSON schema. + class LogicalVolume < Base + include WithFilesystem + include WithSize + + # @param config [Configs::LogicalVolume] + def initialize(config) + super() + @config = config + end + + private + + # @see Base#conversions + def conversions + { + lvName: config.name, + alias: config.alias, + mountPath: config.filesystem&.path, + filesystem: convert_filesystem, + size: convert_size, + stripes: config.stripes, + stripeSize: config.stripe_size&.to_i + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb index 04e7d4ceda..8911a0a463 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -43,8 +43,7 @@ def initialize(config) # @see Base#conversions def conversions { - name: config.found_device&.name, - alias: config.alias, + name: config.device_name, id: config.id&.to_s, mountPath: config.filesystem&.path, filesystem: convert_filesystem, diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb index 13ab91151a..b8000f920e 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -39,11 +39,16 @@ def initialize(config) def conversions { default: config.default?, - min: config.min&.to_i, + min: convert_min_size, max: convert_max_size } end + # @return [Integer] + def convert_min_size + config.min&.to_i || 0 + end + # @return [Integer, nil] def convert_max_size max = config.max diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb new file mode 100644 index 0000000000..f380d10aaf --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/logical_volume" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # LVM volume group conversion to model according to the JSON schema. + class VolumeGroup < Base + include WithFilesystem + + # @param config [Configs::VolumeGroup] + # @param storage_config [Storage::Config] + def initialize(config, storage_config) + super() + @config = config + @storage_config = storage_config + end + + private + + # @return [Storage::Config] + attr_reader :storage_config + + # @see Base#conversions + def conversions + { + vgName: config.name, + extentSize: config.extent_size&.to_i, + targetDevices: convert_target_devices, + logicalVolumes: convert_logical_volumes + } + end + + # Name of the target devices. + # + # @return [Array] + def convert_target_devices + config.physical_volumes_devices + .map { |a| storage_config.drive(a)&.device_name } + .compact + end + + # @return [Array] + def convert_logical_volumes + config.logical_volumes.map do |logical_volume| + ToModelConversions::LogicalVolume.new(logical_volume).convert + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb index d13152bb1e..f7d09c6eb0 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -36,28 +36,7 @@ def convert_partitions # @return [Array] def valid_partitions - config.partitions.select { |p| valid_partition?(p) } - end - - # @param partition_config [Configs::Partition] - # @return [Boolean] - def valid_partition?(partition_config) - valid_new_partition(partition_config) || valid_existing_partition(partition_config) - end - - # @param partition_config [Configs::Partition] - # @return [Boolean] - def valid_new_partition(partition_config) - delete = partition_config.delete? || partition_config.delete_if_needed? - return false if delete - - partition_config.search.nil? || partition_config.search.create_device? - end - - # @param partition_config [Configs::Partition] - # @return [Boolean] - def valid_existing_partition(partition_config) - !partition_config.found_device.nil? + config.partitions.reject { |p| p.search&.skip_device? } end end end diff --git a/service/lib/agama/storage/config_solvers/boot.rb b/service/lib/agama/storage/config_solvers/boot.rb index ad17840237..9a6d83b81e 100644 --- a/service/lib/agama/storage/config_solvers/boot.rb +++ b/service/lib/agama/storage/config_solvers/boot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -39,80 +39,80 @@ def solve(config) private # Finds a device for booting and sets its alias, if needed. + # + # A boot device cannot be automatically inferred in the following scenarios: + # * The root partition or logical volume is missing. + # * A disk is directly formated and mounted as root. + # * The volume group allocating the root logical volume uses whole drives as physical + # volumes. def solve_device_alias return unless config.boot.configure? && config.boot.device.default? - drive_config = root_drive_config - return unless drive_config + device = root_device + return unless device - drive_config.ensure_alias - config.boot.device.device_alias = drive_config.alias + device.ensure_alias + config.boot.device.device_alias = device.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 + def root_device + root_drive || root_lvm_device end - # Config of the first drive used to allocate the root volume group config, if any. + # Config of the drive used for allocating the root partition. # # @return [Configs::Drive, nil] - def root_lvm_device_config - volume_group_config = root_volume_group_config - return unless volume_group_config + def root_drive + drive = config.root_drive + return unless drive&.partitions&.any? - config.drives - .select { |d| candidate_for_physical_volumes?(d, volume_group_config) } - .first + drive end - # Config of the volume group containing the root logical volume, if any. + # Config of the first drive used for allocating the physical volumes of the root volume + # group. # - # @return [Configs::VolumeGroup, nil] - def root_volume_group_config - config.volume_groups.find { |v| root_volume_group_config?(v) } - end + # @return [Configs::Drive, nil] + def root_lvm_device + volume_group = config.root_volume_group + return unless volume_group - # 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) } + first_target_lvm_device(volume_group) || first_physical_volume_device(volume_group) end - # Whether the given volume group config contains a root logical volume config. + # Config of the first target device for creating physical volumes. # # @param config [Configs::VolumeGroup] - # @return [Boolean] - def root_volume_group_config?(config) - config.logical_volumes.any? { |l| root_config?(l) } + # @return [Configs::Drive, nil] + def first_target_lvm_device(config) + device_alias = config.physical_volumes_devices.first + return unless device_alias + + self.config.drives.find { |d| d.alias?(device_alias) } end - # Whether the given config if for the root filesystem. + # Config of the device of the first partition used as physical volume. # - # @param config [#filesystem] - # @return [Boolean] - def root_config?(config) - config.filesystem&.root? + # @param config [Configs::VolumeGroup] + # @return [Configs::Drive, nil] + def first_physical_volume_device(config) + device_alias = config.physical_volumes.find { |p| partition_alias?(p) } + return unless device_alias + + self.config.drives.find do |drive| + drive.partitions.any? { |p| p.alias?(device_alias) } + end end - # Whether the given drive config can be used to allocate physcial volumes. - # - # @param drive [Configs::Drive] - # @param volume_group [Configs::VolumeGroup] + # Whether there is a partition with the given alias. # + # @param device_alias [String] # @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 + def partition_alias?(device_alias) + config.partitions.any? { |p| p.alias?(device_alias) } end end end diff --git a/service/lib/agama/storage/configs/drive.rb b/service/lib/agama/storage/configs/drive.rb index 8c6c3a7bcf..edb3d2f265 100644 --- a/service/lib/agama/storage/configs/drive.rb +++ b/service/lib/agama/storage/configs/drive.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/configs/search" require "agama/storage/configs/with_alias" +require "agama/storage/configs/with_filesystem" require "agama/storage/configs/with_search" module Agama @@ -30,14 +31,12 @@ module Configs # system and that can be used as a regular disk. class Drive include WithAlias + include WithFilesystem include WithSearch # @return [Encryption, nil] attr_accessor :encryption - # @return [Filesystem, nil] - attr_accessor :filesystem - # @return [Y2Storage::PartitionTables::Type, nil] attr_accessor :ptable_type diff --git a/service/lib/agama/storage/configs/logical_volume.rb b/service/lib/agama/storage/configs/logical_volume.rb index 1f1696a5a1..65120bf269 100644 --- a/service/lib/agama/storage/configs/logical_volume.rb +++ b/service/lib/agama/storage/configs/logical_volume.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/configs/size" require "agama/storage/configs/with_alias" +require "agama/storage/configs/with_filesystem" module Agama module Storage @@ -28,6 +29,7 @@ module Configs # Section of the configuration representing a LVM logical volume. class LogicalVolume include WithAlias + include WithFilesystem # @return [String, nil] attr_accessor :name @@ -51,9 +53,6 @@ class LogicalVolume # @return [Encryption, nil] attr_accessor :encryption - # @return [Filesystem, nil] - attr_accessor :filesystem - def initialize @size = Size.new @pool = false diff --git a/service/lib/agama/storage/configs/partition.rb b/service/lib/agama/storage/configs/partition.rb index d615ade999..babbfd0bf7 100644 --- a/service/lib/agama/storage/configs/partition.rb +++ b/service/lib/agama/storage/configs/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,8 +20,9 @@ # find current contact information at www.suse.com. require "agama/storage/configs/size" -require "agama/storage/configs/with_search" require "agama/storage/configs/with_alias" +require "agama/storage/configs/with_filesystem" +require "agama/storage/configs/with_search" module Agama module Storage @@ -49,6 +50,7 @@ def self.new_for_shrink_any_if_needed end include WithAlias + include WithFilesystem include WithSearch # @return [Boolean] @@ -68,9 +70,6 @@ def self.new_for_shrink_any_if_needed # @return [Encryption, nil] attr_accessor :encryption - # @return [Filesystem, nil] - attr_accessor :filesystem - def initialize @size = Size.new @delete = false diff --git a/service/lib/agama/storage/configs/with_alias.rb b/service/lib/agama/storage/configs/with_alias.rb index 250d8d93e0..51b81be95f 100644 --- a/service/lib/agama/storage/configs/with_alias.rb +++ b/service/lib/agama/storage/configs/with_alias.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -37,6 +37,8 @@ def alias?(value) end # Ensures the config has a value for alias. + # + # @return [String] def ensure_alias self.alias ||= SecureRandom.alphanumeric(10) end diff --git a/service/lib/agama/storage/configs/with_filesystem.rb b/service/lib/agama/storage/configs/with_filesystem.rb new file mode 100644 index 0000000000..2f48158a0a --- /dev/null +++ b/service/lib/agama/storage/configs/with_filesystem.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module Configs + # Mixin for configs with filesystem. + module WithFilesystem + # @return [Filesystem, nil] + attr_accessor :filesystem + + # Whether the config is for the root filesystem. + # + # @return [Boolean] + def root? + filesystem&.root? + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/with_search.rb b/service/lib/agama/storage/configs/with_search.rb index fcf3f3cea1..d1c15da5b5 100644 --- a/service/lib/agama/storage/configs/with_search.rb +++ b/service/lib/agama/storage/configs/with_search.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -41,6 +41,21 @@ module WithSearch def found_device search&.device end + + # Name of the device. + # + # If the config is not solved, then it returns the searched name (if any). + # If the config is solved, then it returns either the name of the found device or the + # searched name. But the searched name is returned only if the device is not going to be + # created. + # + # @return [String, nil] + def device_name + device = found_device + return device.name if device + + search&.name unless search&.create_device? + end end end end diff --git a/service/lib/agama/storage/model_support_checker.rb b/service/lib/agama/storage/model_support_checker.rb new file mode 100644 index 0000000000..4eefe3cc31 --- /dev/null +++ b/service/lib/agama/storage/model_support_checker.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + # Class for checking whether a config is supported by the config model. + # + # Features will be added to the config model little by little. Ideally, this class will + # dissapear once the model supports all the features provided by the config. + class ModelSupportChecker + # @note A solved config is expected. Otherwise some checks cannot be done reliably. + # + # @param config [Storage::Config] + def initialize(config) + @config = config + end + + # Whether the config is completely supported by the config model. + # + # @return [Booelan] + def supported? + return @supported unless @supported.nil? + + @supported = !unsupported_config? + end + + private + + # @return [Storage::Config] + attr_reader :config + + # Whether the config is not supported by the config model. + # + # @return [Boolean] + def unsupported_config? # rubocop:disable Metrics/CyclomaticComplexity + any_unsupported_device? || + any_drive_without_name? || + any_drive_with_encryption? || + any_volume_group_without_name? || + any_volume_group_with_pvs? || + any_partition_without_mount_path? || + any_logical_volume_without_mount_path? || + any_logical_volume_with_encryption? + end + + # Whether there is any device that is not supported by the model. + # + # @return [Boolean] + def any_unsupported_device? + thin_pools = config.logical_volumes.select(&:pool?) + thin_volumes = config.logical_volumes.select(&:thin_volume?) + + [ + config.md_raids, + config.btrfs_raids, + config.nfs_mounts, + thin_pools, + thin_volumes + ].flatten.any? + end + + # Whether there is any mandatory drive without a name. + # + # @return [Boolean] + def any_drive_without_name? + config.drives.any? do |drive| + !drive.found_device && + !drive.search&.skip_device? && + !drive.search&.name + end + end + + # Whether there is any mandatory drive with encryption. + # + # @return [Boolean] + def any_drive_with_encryption? + config.drives.any? { |d| !d.search&.skip_device? && !d.encryption.nil? } + end + + # Whether there is any volume group without a name. + # + # @return [Boolean] + def any_volume_group_without_name? + !config.volume_groups.all?(&:name) + end + + # Only volume groups with automatically generated physical volumes are supported. + # @todo Revisit this check once individual physical volumes are supported by the model. + # + # @return [Boolean] + def any_volume_group_with_pvs? + config.volume_groups.any? { |v| v.physical_volumes.any? } + end + + # Whether there is any logical volume with missing mount path. + # @todo Revisit this check once volume groups can be reused. + # + # @return [Boolean] + def any_logical_volume_without_mount_path? + config.logical_volumes.any? { |p| !p.filesystem&.path } + end + + # Whether there is any logical volume with encryption. + # + # @return [Boolean] + def any_logical_volume_with_encryption? + config.logical_volumes.any?(&:encryption) + end + + # Whether there is any partition with missing mount path. + # @see #need_mount_path? + # + # @return [Boolean] + def any_partition_without_mount_path? + config.partitions.any? { |p| need_mount_path?(p) && !p.filesystem&.path } + end + + # Whether the config represents a partition that requires a mount path. + # + # A mount path is required for all the partitions that are going to be created. For a config + # reusing an existing partition, the mount path is required only if the partition does not + # represent a space policy action (delete or resize). + # + # @todo Revisit this check once individual physical volumes are supported by the model. The + # partitions representing the new physical volumes would not need a mount path. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def need_mount_path?(partition_config) + return true if new_partition?(partition_config) + + reused_partition?(partition_config) && + !delete_action_partition?(partition_config) && + !resize_action_partition?(partition_config) + end + + # Whether the config represents a new partition to be created. + # + # @note The config has to be solved. Otherwise, in some cases it would be impossible to + # determine whether the partition is going to be created or reused. For example, if the + # config has a search and #if_not_found is set to :create. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def new_partition?(partition_config) + partition_config.search.nil? || partition_config.search.create_device? + end + + # Whether the config is reusing an existing partition. + # + # @note The config has to be solved. Otherwise, in some cases it would be impossible to + # determine whether the partition is going to be reused or skipped. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def reused_partition?(partition_config) + !new_partition?(partition_config) && !partition_config.search.skip_device? + end + + # Whether the partition is configured to be deleted or deleted if needed. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def delete_action_partition?(partition_config) + return false unless reused_partition?(partition_config) + + partition_config.delete? || partition_config.delete_if_needed? + end + + # Whether the partition is configured to be resized if needed. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def resize_action_partition?(partition_config) + return false unless reused_partition?(partition_config) + + partition_config.filesystem.nil? && + partition_config.encryption.nil? && + partition_config.size && + !partition_config.size.default? && + partition_config.size.min == Y2Storage::DiskSize.zero + end + end + end +end diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index b0244397e7..96b4433d64 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -24,6 +24,7 @@ require "agama/storage/config_checker" require "agama/storage/config_conversions" require "agama/storage/config_solver" +require "agama/storage/model_support_checker" require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "json" @@ -290,18 +291,7 @@ def config(solved: false) # @param config [Storage::Config] # @return [Boolean] def model_supported?(config) - unsupported_configs = [ - config.volume_groups, - config.md_raids, - config.btrfs_raids, - config.nfs_mounts - ].flatten - - encryptable_configs = [ - config.drives - ].flatten - - unsupported_configs.empty? && encryptable_configs.none?(&:encryption) + ModelSupportChecker.new(config).supported? end # Calculates a proposal from guided JSON settings. diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index 3d0594d2f3..cf80af80df 100644 --- a/service/lib/y2storage/agama_proposal.rb +++ b/service/lib/y2storage/agama_proposal.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -178,7 +178,7 @@ def boot_partitions(devicegraph) checker = BootRequirementsChecker.new( devicegraph, planned_devices: planned_devices.mountable_devices, - boot_disk_name: config.boot_device + boot_disk_name: boot_device_name ) # NOTE: Should we try with :desired first? checker.needed_partitions(:min) @@ -209,7 +209,7 @@ def drives_with_empty_partition_table(devicegraph) # # @return [Array] names of partitionable devices def disks_for_clean - (drives_names + [config.boot_device]).compact.uniq + (drives_names + [boot_device_name]).compact.uniq end # Creates the planned devices on a given devicegraph @@ -220,6 +220,13 @@ def create_devices(devicegraph) result = devices_creator.populated_devicegraph(planned_devices, drives_names, space_maker) end + # Name of the boot device. + # + # @return [String, nil] + def boot_device_name + config.boot_device&.found_device&.name + end + # Names of all the devices that correspond to a drive from the config # # @return [Array] diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index f4a5b99f09..f96a6b02f2 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Mar 27 11:40:23 UTC 2025 - José Iván López González + +- Extend storage model for basic LVM support + (gh#agama-project/agama#2216). + ------------------------------------------------------------------- Tue Mar 25 15:47:13 UTC 2025 - Ladislav Slezák @@ -35,7 +41,7 @@ Wed Mar 12 00:44:33 UTC 2025 - Imobach Gonzalez Sosa - Copy Agama logs to the installed system (gh#agama/agama-project#2148). - Set /var/log/agama-installation permissions to 0700 - (gh#agama/agama-project#2140). + (gh#agama-project/agama#2140). ------------------------------------------------------------------- Wed Mar 5 14:50:04 UTC 2025 - Ladislav Slezák @@ -60,7 +66,7 @@ Wed Mar 5 08:09:28 UTC 2025 - Michal Filka - introduced boot_strategy into storage section of product definition yaml file. It allows to control what boot strategy - will be proposed by storage. Currently works only for BLS. + will be proposed by storage. Currently works only for BLS. ------------------------------------------------------------------- Fri Feb 28 13:03:11 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index f582c396ea..4385e8010a 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -734,17 +734,16 @@ def serialize(value) it "returns the serialized config model" do expect(subject.recover_model).to eq( serialize({ - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - drives: [ + drives: [ { name: "/dev/sda", - alias: "root", spacePolicy: "keep", partitions: [ { @@ -765,7 +764,8 @@ def serialize(value) } ] } - ] + ], + volumeGroups: [] }) ) end @@ -796,7 +796,6 @@ def serialize(value) drives: [ { name: "/dev/sda", - alias: "sda", partitions: [ { mountPath: "/" } ] @@ -810,17 +809,16 @@ def serialize(value) expect(result).to eq( serialize({ - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - drives: [ + drives: [ { name: "/dev/sda", - alias: "sda", spacePolicy: "keep", partitions: [ { @@ -841,7 +839,8 @@ def serialize(value) } ] } - ] + ], + volumeGroups: [] }) ) end diff --git a/service/test/agama/storage/config_checker_test.rb b/service/test/agama/storage/config_checker_test.rb index 106ecacd37..658058df68 100644 --- a/service/test/agama/storage/config_checker_test.rb +++ b/service/test/agama/storage/config_checker_test.rb @@ -295,7 +295,7 @@ issue = issues.first expect(issue.error?).to eq(true) - expect(issue.description).to match(/there is no root \(\/\) file system/) + expect(issue.description).to match(/The boot device cannot be automatically selected/) end end @@ -522,12 +522,27 @@ end end + context "if a volume group has no name" do + let(:config_json) do + { + volumeGroups: [{ name: "test" }, { name: "" }, {}] + } + end + + it "includes the expected issue" do + issues = subject.issues.select { |i| i.description.match?(/without name/) } + expect(issues.size).to eq(2) + expect(issues.map(&:error?)).to all(eq(true)) + end + end + context "if a volume group has logical volumes" do let(:config_json) do { boot: { configure: false }, volumeGroups: [ { + name: "test", logicalVolumes: [ logical_volume, { @@ -598,6 +613,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: ["first-disk", "pv1"] } ] @@ -625,6 +641,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: [ { generate: { @@ -659,6 +676,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: [ { generate: { @@ -752,6 +770,7 @@ ], volumeGroups: [ { + name: "test1", physicalVolumes: [ { generate: { @@ -761,6 +780,7 @@ ] }, { + name: "test2", physicalVolumes: [ { generate: { @@ -770,6 +790,7 @@ ] }, { + name: "test3", physicalVolumes: [ { generate: { @@ -854,6 +875,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: ["pv1"] } ] 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 2810bb72f7..bfc9857368 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -32,13 +32,6 @@ using Y2Storage::Refinements::SizeCasts -shared_examples "without alias" do |config_proc| - it "does not set #alias" do - config = config_proc.call(subject.convert) - expect(config.alias).to be_nil - end -end - shared_examples "without filesystem" do |config_proc| it "does not set #filesystem" do config = config_proc.call(subject.convert) @@ -145,15 +138,6 @@ end end -shared_examples "with alias" do |config_proc| - let(:device_alias) { "test" } - - it "sets #alias to the expected value" do - config = config_proc.call(subject.convert) - expect(config.alias).to eq("test") - end -end - shared_examples "with mountPath" do |config_proc| let(:mountPath) { "/test" } @@ -471,11 +455,6 @@ end end - context "if a partition does not spicify 'alias'" do - let(:partition) { {} } - include_examples "without alias", partition_proc - end - context "if a partition does not spicify 'id'" do let(:partition) { {} } @@ -506,15 +485,12 @@ end context "if a partition specifies 'name'" do - let(:partition) { { name: name } } + # Add mount path in order to use the partition. Otherwise the partition is omitted because it + # is considered a keep action. + let(:partition) { { name: name, mountPath: "/test2" } } include_examples "with name", partition_proc end - context "if a partition specifies 'alias'" do - let(:partition) { { alias: device_alias } } - include_examples "with alias", partition_proc - end - context "if a partition spicifies 'id'" do let(:partition) { { id: "esp" } } @@ -605,49 +581,55 @@ shared_examples "with spacePolicy and partitions" do |config_proc| let(:partitions) do [ - # Partition exists and it is used. + # Reused partition with some usage. { name: "/dev/vda1", mountPath: "/test1", size: { default: true, min: 10.GiB.to_i } }, - # Partition exists and it is used. + # Reused partition with some usage. { name: "/dev/vda2", mountPath: "/test2", resizeIfNeeded: true, size: { default: false, min: 10.GiB.to_i } }, - # Partition exists and it is used. + # Reused partition with some usage. { name: "/dev/vda3", 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). + # Reused partition representing a space action (resize). { name: "/dev/vda4", resizeIfNeeded: true, size: { default: false, min: 10.GiB.to_i } }, - # Partition exists and it is not used (space action). + # Reused partition representing a space action (resize). { 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). + # Reused partition representing a space action (delete). { name: "/dev/vda6", delete: true }, - # Partition exists and it is not used (space action). + # Reused partition representing a space action (delete). { name: "/dev/vda7", deleteIfNeeded: true }, - # Partition does not exist. + # Reused partition representing a space action (keep). + { + name: "/dev/vda8" + }, + # New partition. + {}, + # New partition. { mountPath: "/", resizeIfNeeded: true, @@ -663,11 +645,12 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(4) + expect(partitions.size).to eq(5) 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[3].filesystem.path).to eq("/") + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") end end @@ -677,14 +660,15 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(5) + expect(partitions.size).to eq(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[3].filesystem.path).to eq("/") - expect(partitions[4].search.name).to be_nil - expect(partitions[4].search.max).to be_nil - expect(partitions[4].delete).to eq(true) + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") + expect(partitions[5].search.name).to be_nil + expect(partitions[5].search.max).to be_nil + expect(partitions[5].delete).to eq(true) end end @@ -694,16 +678,17 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(5) + expect(partitions.size).to eq(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[3].filesystem.path).to eq("/") - expect(partitions[4].search.name).to be_nil - expect(partitions[4].search.max).to be_nil - expect(partitions[4].size.default?).to eq(false) - expect(partitions[4].size.min).to eq(Y2Storage::DiskSize.zero) - expect(partitions[4].size.max).to be_nil + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") + expect(partitions[5].search.name).to be_nil + expect(partitions[5].search.max).to be_nil + expect(partitions[5].size.default?).to eq(false) + expect(partitions[5].size.min).to eq(Y2Storage::DiskSize.zero) + expect(partitions[5].size.max).to be_nil end end @@ -713,15 +698,16 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(8) + expect(partitions.size).to eq(9) 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[3].search.name).to eq("/dev/vda4") - expect(partitions[4].search.name).to eq("/dev/vda5") - expect(partitions[5].search.name).to eq("/dev/vda6") - expect(partitions[6].search.name).to eq("/dev/vda7") - expect(partitions[7].filesystem.path).to eq("/") + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") + expect(partitions[5].search.name).to eq("/dev/vda4") + expect(partitions[6].search.name).to eq("/dev/vda5") + expect(partitions[7].search.name).to eq("/dev/vda6") + expect(partitions[8].search.name).to eq("/dev/vda7") end context "if a partition spicifies 'resizeIfNeeded'" do @@ -917,6 +903,11 @@ config = subject.convert expect(config.drives).to be_empty end + + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups).to be_empty + end end context "with a JSON specifying 'boot'" do @@ -971,59 +962,36 @@ 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 + context "and there is a drive config for the given boot device name" do let(:drives) do [ - { name: "/dev/vda", alias: device_alias } + { name: "/dev/vda" } ] 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 "does not add more drives" do + config = subject.convert + expect(config.drives.size).to eq(1) + expect(config.drives.first.search.name).to eq("/dev/vda") + 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 + it "sets an alias to the drive config" do + config = subject.convert + drive = config.drives.first + expect(drive.alias).to_not be_nil 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 + 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 - context "and there is no drive model for the given boot device name" do + context "and there is not a drive config for the given boot device name" do let(:drives) do [ { name: "/dev/vdb" } @@ -1070,18 +1038,27 @@ context "with a JSON specifying 'encryption'" do let(:model_json) do { - encryption: { + encryption: { method: "luks1", password: "12345" }, - drives: [ + drives: [ { name: "/dev/vda", partitions: [ - { name: "/dev/vda1" }, + { + name: "/dev/vda1", + mountPath: "/test" + }, {} ] } + ], + volumeGroups: [ + { + vgName: "system", + targetDevices: ["/dev/vda"] + } ] } end @@ -1096,6 +1073,15 @@ expect(new_partition.encryption.password).to eq("12345") expect(reused_partition.encryption).to be_nil end + + it "sets #encryption for the automatically created physical volumes" do + config = subject.convert + volume_group = config.volume_groups.first + target_encryption = volume_group.physical_volumes_encryption + + expect(target_encryption.method.id).to eq(:luks1) + expect(target_encryption.password).to eq("12345") + end end context "with a JSON specifying 'drives'" do @@ -1150,11 +1136,6 @@ end end - context "if a drive does not spicify 'alias'" do - let(:drive) { {} } - include_examples "without alias", drive_proc - end - context "if a drive does not spicify neither 'mountPath' nor 'filesystem'" do let(:drive) { {} } include_examples "without filesystem", drive_proc @@ -1175,11 +1156,6 @@ include_examples "with name", drive_proc end - context "if a drive specifies 'alias'" do - let(:drive) { { alias: device_alias } } - include_examples "with alias", drive_proc - end - context "if a drive specifies 'mountPath'" do let(:drive) { { mountPath: mountPath } } include_examples "with mountPath", drive_proc @@ -1215,5 +1191,263 @@ include_examples "with spacePolicy and partitions", drive_proc end end + + context "with a JSON specifying 'volumeGroups'" do + let(:model_json) do + { + drives: drives, + volumeGroups: volume_groups + } + end + + let(:drives) { [] } + + let(:volume_groups) do + [ + volume_group, + { vgName: "vg2" } + ] + end + + let(:volume_group) do + { vgName: "vg1" } + end + + context "with an empty list" do + let(:volume_groups) { [] } + + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups).to eq([]) + end + end + + context "with a list of volume groups" do + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups.size).to eq(2) + expect(config.volume_groups).to all(be_a(Agama::Storage::Configs::VolumeGroup)) + + vg1, vg2 = config.volume_groups + expect(vg1.name).to eq("vg1") + expect(vg1.logical_volumes).to eq([]) + expect(vg2.name).to eq("vg2") + expect(vg2.logical_volumes).to eq([]) + end + end + + volume_group_proc = proc { |c| c.volume_groups.first } + + context "if a volume group does not specify 'vgName'" do + let(:volume_group) { {} } + + it "does not set #name" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.name).to be_nil + end + end + + context "if a volume group does not specify 'extentSize'" do + let(:volume_group) { {} } + + it "does not set #extent_size" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.extent_size).to be_nil + end + end + + context "if a volume group does not specify 'targetDevices'" do + let(:volume_group) { {} } + + it "sets #physical_volumes_devices to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.physical_volumes_devices).to eq([]) + end + end + + context "if a volume group does not specify 'logicalVolumes'" do + let(:volume_group) { {} } + + it "sets #logical_volumes to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.logical_volumes).to eq([]) + end + end + + context "if a volume group specifies 'vgName'" do + let(:volume_group) { { vgName: "vg1" } } + + it "sets #name to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.name).to eq("vg1") + end + end + + context "if a volume group specifies 'extentSize'" do + let(:volume_group) { { extentSize: 1.KiB.to_i } } + + it "sets #extent_size to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.extent_size).to eq(1.KiB) + end + end + + context "if a volume group specifies 'targetDevices'" do + let(:volume_group) { { targetDevices: ["/dev/vda", "/dev/vdc"] } } + + let(:drives) do + [ + { name: "/dev/vda" }, + { name: "/dev/vdb" } + ] + end + + it "adds the missing drives" do + config = subject.convert + expect(config.drives.size).to eq(3) + expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive)) + expect(config.drives).to include(an_object_having_attributes({ device_name: "/dev/vdc" })) + end + + it "sets an alias to the target drives" do + config = subject.convert + vda = config.drives.find { |d| d.device_name == "/dev/vda" } + vdb = config.drives.find { |d| d.device_name == "/dev/vdb" } + vdc = config.drives.find { |d| d.device_name == "/dev/vdc" } + expect(vda.alias).to_not be_nil + expect(vdb.alias).to be_nil + expect(vda.alias).to_not be_nil + end + + it "sets #physical_volumes_devices to the expected value" do + config = subject.convert + volume_group = volume_group_proc.call(config) + vda = config.drives.find { |d| d.device_name == "/dev/vda" } + vdc = config.drives.find { |d| d.device_name == "/dev/vdc" } + expect(volume_group.physical_volumes_devices).to eq([vda.alias, vdc.alias]) + end + end + + context "if a volume group specifies 'logicalVolumes'" do + let(:volume_group) { { logicalVolumes: logical_volumes } } + + let(:logical_volumes) do + [ + logical_volume, + { lvName: "lv2" } + ] + end + + let(:logical_volume) { { lvName: "lv1" } } + + context "with an empty list" do + let(:logical_volumes) { [] } + + it "sets #logical_volumes to empty" do + config = subject.convert + expect(config.logical_volumes).to eq([]) + end + end + + context "with a list of logical volumes" do + it "sets #logical_volumes to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.logical_volumes) + .to all(be_a(Agama::Storage::Configs::LogicalVolume)) + expect(volume_group.logical_volumes.size).to eq(2) + + lv1, lv2 = volume_group.logical_volumes + expect(lv1.name).to eq("lv1") + expect(lv2.name).to eq("lv2") + end + end + + logical_volume_proc = proc { |c| volume_group_proc.call(c).logical_volumes.first } + + context "if a logical volume does not specify 'lvName'" do + let(:logical_volume) { {} } + + it "does not set #name" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.name).to be_nil + end + end + + context "if a logical volume does not spicify neither 'mountPath' nor 'filesystem'" do + let(:logical_volume) { {} } + include_examples "without filesystem", logical_volume_proc + end + + context "if a logical volume does not spicify 'size'" do + let(:logical_volume) { {} } + include_examples "without size", logical_volume_proc + end + + context "if a logical volume does not spicify 'stripes'" do + let(:logical_volume) { {} } + + it "does not set #stripes" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripes).to be_nil + end + end + + context "if a logical volume does not spicify 'stripeSize'" do + let(:logical_volume) { {} } + + it "does not set #stripe_size" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripe_size).to be_nil + end + end + + context "if a logical volume specifies 'lvName'" do + let(:logical_volume) { { lvName: "lv1" } } + + it "sets #name to the expected value" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.name).to eq("lv1") + end + end + + context "if a logical volume specifies 'mountPath'" do + let(:logical_volume) { { mountPath: mountPath } } + include_examples "with mountPath", logical_volume_proc + end + + context "if a logical volume specifies 'filesystem'" do + let(:logical_volume) { { filesystem: filesystem } } + include_examples "with filesystem", logical_volume_proc + end + + context "if a logical volume specifies both 'mountPath' and 'filesystem'" do + let(:logical_volume) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem", logical_volume_proc + end + + context "if a logical volume spicifies 'size'" do + let(:logical_volume) { { size: size } } + include_examples "with size", logical_volume_proc + end + + context "if a logical volume specifies 'stripes'" do + let(:logical_volume) { { stripes: 4 } } + + it "sets #stripes to the expected value" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripes).to eq(4) + end + end + + context "if a logical volume specifies 'stripeSize'" do + let(:logical_volume) { { stripeSize: 2.KiB.to_i } } + + it "sets #stripeSize to the expected value" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripe_size).to eq(2.KiB) + end + end + end + end end end diff --git a/service/test/agama/storage/config_conversions/model_support_checker_test.rb b/service/test/agama/storage/config_conversions/model_support_checker_test.rb new file mode 100644 index 0000000000..75668465e7 --- /dev/null +++ b/service/test/agama/storage/config_conversions/model_support_checker_test.rb @@ -0,0 +1,499 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require_relative "../../../test_helper" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_solver" +require "agama/storage/model_support_checker" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ModelSupportChecker do + include Agama::RSpec::StorageHelpers + + let(:product_data) do + { + "storage" => { + "volumes" => ["/", "swap"], + "volume_templates" => [ + { + "mount_path" => "/", + "filesystem" => "btrfs", + "size" => { + "auto" => true, + "min" => "5 GiB", + "max" => "10 GiB" + }, + "btrfs" => { + "snapshots" => true + }, + "outline" => { + "required" => true, + "snapshots_configurable" => true, + "auto_size" => { + "base_min" => "5 GiB", + "base_max" => "10 GiB" + } + } + }, + { + "mount_path" => "/home", + "filesystem" => "xfs", + "size" => { + "auto" => false, + "min" => "5 GiB" + }, + "outline" => { + "required" => false + } + }, + { + "mount_path" => "swap", + "filesystem" => "swap", + "size" => { + "auto" => true + }, + "outline" => { + "auto_size" => { + "base_min" => "2 GiB", + "base_max" => "4 GiB" + } + } + }, + { + "mount_path" => "", + "filesystem" => "ext4", + "size" => { + "min" => "100 MiB" + } + } + ] + } + } + end + + let(:product_config) { Agama::Config.new(product_data) } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + .tap { |c| Agama::Storage::ConfigSolver.new(product_config, devicegraph).solve(c) } + end + + before do + mock_storage(devicegraph: scenario) + # To speed-up the tests + allow(Y2Storage::EncryptionMethod::TPM_FDE) + .to(receive(:possible?)) + .and_return(true) + end + + subject { described_class.new(config) } + + describe "#supported?" do + let(:scenario) { "disks.yaml" } + + # The drive is not found and it is not searched by name. + context "if there is a drive with unknown name" do + let(:scenario) { "empty-hd-50GiB.yaml" } + + let(:config_json) do + { + drives: [ + {}, + { search: { ifNotFound: if_not_found } } + ] + } + end + + context "and the drive is going to be skipped" do + let(:if_not_found) { "skip" } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the drive is not going to be skipped" do + let(:if_not_found) { "error" } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + end + + context "if there is a drive with encryption" do + let(:config_json) do + { + drives: [ + { + search: { + condition: condition, + ifNotFound: "skip" + }, + encryption: { + luks1: { password: "12345" } + } + } + ] + } + end + + context "and the drive is going to be skipped" do + let(:condition) { { name: "/not/found" } } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the drive is not going to be skipped" do + let(:condition) { nil } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + end + + context "if there is a LVM thin pool" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [ + { pool: true } + ] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM thin volume" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [ + { usedPool: "pool" } + ] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM volume group without name" do + let(:config_json) do + { + volumeGroups: [{}] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM volume group with specific physical volumes" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + physicalVolumes: ["pv1"] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a partition without mount path" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + search: "/dev/vda", + partitions: [ + { + search: search, + delete: delete, + deleteIfNeeded: deleteIfNeeded, + filesystem: filesystem, + encryption: encryption, + size: size + } + ] + } + ] + } + end + + let(:search) { nil } + let(:delete) { nil } + let(:deleteIfNeeded) { nil } + let(:filesystem) { nil } + let(:encryption) { nil } + let(:size) { nil } + + context "and the partition has not a search (new partition)" do + let(:search) { nil } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition has a search" do + let(:search) do + { + condition: condition, + ifNotFound: if_not_found + } + end + + let(:if_not_found) { nil } + + shared_examples "reused partition" do + context "and the partition is set to be deleted" do + let(:delete) { true } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the partition is set to be deleted if needed" do + let(:deleteIfNeeded) { true } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the partition is not set to be deleted" do + let(:delete) { false } + let(:deleteIfNeeded) { false } + + context "and the partition has encryption" do + let(:encryption) do + { luks1: { password: "12345" } } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition has filesystem" do + let(:filesystem) { { type: "xfs" } } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition has a size" do + let(:size) do + { + default: false, + min: 1.GiB, + max: 10.GiB + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition is only set to be resized if needed" do + let(:encryption) { nil } + let(:filesystem) { nil } + let(:size) do + { + default: false, + min: Y2Storage::DiskSize.zero + } + end + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + end + end + + context "and the partition is found" do + let(:condition) { { name: "/dev/vda1" } } + + include_examples "reused partition" + end + + context "and the partition is not found" do + let(:condition) { { name: "/no/found" } } + + context "and the partition can be skipped" do + let(:if_not_found) { "skip" } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the partition cannot be skipped" do + let(:if_not_found) { "error" } + + include_examples "reused partition" + end + + context "and the partition can be created" do + let(:if_not_found) { "create" } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + end + end + end + + context "if there is a LVM logical volume without mount path" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [{}] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM logical volume with encryption" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [ + { + encryption: { + luks1: { password: "12345" } + } + } + ] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if the config is totally supported" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + search: "/dev/vda", + partitions: [ + { search: "/dev/vda1", delete: true }, + { + search: "/dev/vda2", + size: { default: false, min: 0 } + }, + { + encryption: { + luks1: { password: "12345" } + }, + filesystem: { path: "/", type: "btrfs" } + } + ] + }, + { + alias: "pv", + partitions: [ + { search: "*", delete: true } + ] + }, + { + partitions: [ + { search: "*", delete: true }, + { + filesystem: { path: "/home", type: "xfs" } + } + ] + } + ], + volumeGroups: [ + { + name: "data", + physicalVolumes: [{ generate: ["pv"] }], + logicalVolumes: [ + { + filesystem: { path: "/data" }, + size: { min: "10 GiB" } + } + ] + } + ] + } + end + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + end +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 927f60dbc0..93490272e2 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -34,13 +34,6 @@ end end -shared_examples "without alias" do |result_scope| - it "generates the expected JSON" do - model_json = result_scope.call(subject.convert) - expect(model_json.keys).to_not include(:alias) - end -end - shared_examples "without filesystem" do |result_scope| it "generates the expected JSON" do model_json = result_scope.call(subject.convert) @@ -74,15 +67,6 @@ end end -shared_examples "with alias" do |result_scope| - let(:device_alias) { "test" } - - it "generates the expected JSON" do - model_json = result_scope.call(subject.convert) - expect(model_json[:alias]).to eq("test") - end -end - shared_examples "with filesystem" do |result_scope| let(:filesystem) do { @@ -173,6 +157,13 @@ model_json = result_scope.call(subject.convert) expect(model_json[:partitions]).to eq( [ + { + delete: true, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 100.MiB.to_i } + }, default_partition_json ] ) @@ -186,6 +177,13 @@ model_json = result_scope.call(subject.convert) expect(model_json[:partitions]).to eq( [ + { + delete: false, + deleteIfNeeded: true, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 100.MiB.to_i } + }, default_partition_json ] ) @@ -199,6 +197,14 @@ model_json = result_scope.call(subject.convert) expect(model_json[:partitions]).to eq( [ + { + name: "/not/found", + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 0 } + }, default_partition_json ] ) @@ -262,11 +268,6 @@ include_examples "without name", partition_result_scope end - context "if #alias is not configured for a partition" do - let(:partition) { {} } - include_examples "without alias", partition_result_scope - end - context "if #id is not configured for a partition" do let(:partition) { {} } @@ -286,11 +287,6 @@ include_examples "with name", partition_result_scope, partition_scope end - context "if #alias is configured for a partition" do - let(:partition) { { alias: device_alias } } - include_examples "with alias", partition_result_scope - end - context "if #id is configured for a partition" do let(:partition) { { id: "esp" } } @@ -627,11 +623,12 @@ it "generates the expected JSON" do expect(subject.convert).to eq( { - boot: { + boot: { configure: true, device: { default: true } }, - drives: [] + drives: [], + volumeGroups: [] } ) end @@ -654,6 +651,10 @@ { search: "/dev/vdb", alias: "vdb" + }, + { + search: "/not/found", + alias: "not-found" } ] } @@ -693,6 +694,24 @@ } ) end + + context "and the boot device is not found" do + let(:device_alias) { "not-found" } + + it "generates the expected JSON for 'boot'" do + boot_model = subject.convert[:boot] + + expect(boot_model).to eq( + { + configure: true, + device: { + default: false, + name: "/not/found" + } + } + ) + end + end end end @@ -723,87 +742,204 @@ end end - context "if the root partition is encrypted" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { - filesystem: { path: "/" }, - encryption: { - luks1: { password: "12345" } + context "for the global encryption" do + context "if the root partition is encrypted" do + let(:config_json) do + { + drives: [ + { + alias: "vda", + partitions: [ + { + filesystem: { path: "/" }, + encryption: { + luks1: { password: "12345" } + } } - } - ] + ] + } + ], + volumeGroups: [ + { + name: "test", + physicalVolumes: [ + { + generate: { + targetDevices: ["vda"], + encryption: { + luks2: { password: "54321" } + } + } + } + ], + logicalVolumes: [ + { filesystem: { path: "/home" } } + ] + } + ] + } + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks1", + password: "12345" } - ] - } + ) + end end - it "generates the expected JSON for 'encryption'" do - encryption_model = subject.convert[:encryption] - - expect(encryption_model).to eq( + context "if there is a root logical volume" do + let(:config_json) do { - method: "luks1", - password: "12345" + drives: [ + { + alias: "vda", + partitions: [ + { + filesystem: { path: "/home" }, + encryption: { + luks1: { password: "12345" } + } + } + ] + } + ], + volumeGroups: [ + { + name: "test", + physicalVolumes: physicalVolumes, + logicalVolumes: [ + { filesystem: { path: "/" } } + ] + } + ] } - ) - end - end + end - context "if the root partition is not encrypted but other partition is encrypted" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { - filesystem: { path: "/" } - }, - { - encryption: { - luks1: { password: "12345" } + context "and the volume group has automatically generated and encrypted physical volumes" do + let(:physicalVolumes) do + [ + { + generate: { + targetDevices: ["vda"], + encryption: { + luks2: { password: "54321" } } } - ] - } - ] - } - end + } + ] + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] - it "generates the expected JSON for 'encryption'" do - encryption_model = subject.convert[:encryption] + expect(encryption_model).to eq( + { + method: "luks2", + password: "54321" + } + ) + end + end + end - expect(encryption_model).to eq( + context "if there is no encryption for root" do + let(:config_json) do { - method: "luks1", - password: "12345" + drives: [ + { + alias: "vda", + partitions: [ + { + filesystem: { path: "/" } + }, + { + filesystem: { path: "/home" }, + encryption: encryption + } + ] + } + ], + volumeGroups: [ + { + name: "test", + physicalVolumes: physicalVolumes, + logicalVolumes: [ + { filesystem: { path: "swap" } } + ] + } + ] } - ) - end - end + end - context "if there is not an encrypted partition" do - let(:config_json) do - { - drives: [ + let(:physicalVolumes) do + [ { - partitions: [ - { - filesystem: { path: "/" } + generate: { + targetDevices: ["vda"], + encryption: { + luks2: { password: "54321" } } - ] + } } ] - } - end + end - it "generates the expected JSON for 'encryption'" do - encryption_model = subject.convert[:encryption] + context "and there is an encrypted partition" do + let(:encryption) do + { + luks1: { password: "12345" } + } + end - expect(encryption_model).to be_nil + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks1", + password: "12345" + } + ) + end + end + + context "and there is no encrypted partition" do + let(:encryption) { nil } + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks2", + password: "54321" + } + ) + end + + context "if there is no automatically generated and encrypted physical volumes" do + let(:physicalVolumes) do + [ + { + generate: { + targetDevices: ["vda"] + } + } + ] + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to be_nil + end + end + end end end @@ -840,10 +976,32 @@ expect(drives_model).to eq( [ + { name: "/dev/vdd", spacePolicy: "keep", partitions: [] }, { name: "/dev/vda", spacePolicy: "keep", partitions: [] } ] ) end + + context "and the drive is set to be skipped" do + let(:drive) do + { + search: { + condition: { name: "/dev/vdd" }, + ifNotFound: "skip" + } + } + end + + it "generates the expected JSON for 'drives'" do + drives_model = subject.convert[:drives] + + expect(drives_model).to eq( + [ + { name: "/dev/vda", spacePolicy: "keep", partitions: [] } + ] + ) + end + end end context "if a device is found for a drive" do @@ -864,11 +1022,6 @@ drive_result_scope = proc { |c| c[:drives].first } drive_scope = proc { |d| d.find_by_name("/dev/vda") } - context "if #alias is not configured for a drive" do - let(:drive) { {} } - include_examples "without alias", drive_result_scope - end - context "if #filesystem is not configured for a drive" do let(:drive) { {} } include_examples "without filesystem", drive_result_scope @@ -884,11 +1037,6 @@ include_examples "without partitions", drive_result_scope end - context "if #alias is configured for a drive" do - let(:drive) { { alias: device_alias } } - include_examples "with alias", drive_result_scope - end - context "if #filesystem is configured for a drive" do let(:drive) { { filesystem: filesystem } } include_examples "with filesystem", drive_result_scope @@ -909,5 +1057,229 @@ include_examples "#spacePolicy property", drive_result_scope end end + + context "if #volume_groups is configured" do + let(:config_json) do + { + drives: [ + { + search: "/dev/vda", + alias: "disk1" + }, + { + search: "/dev/vdb", + alias: "disk2" + } + ], + volumeGroups: volume_groups + } + end + + let(:volume_groups) do + [ + volume_group, + {} + ] + end + + let(:volume_group) { {} } + + it "generates the expected JSON for 'VolumeGroups'" do + volume_groups_model = subject.convert[:volumeGroups] + + expect(volume_groups_model).to eq( + [ + { targetDevices: [], logicalVolumes: [] }, + { targetDevices: [], logicalVolumes: [] } + ] + ) + end + + vg_result_scope = proc { |c| c[:volumeGroups].first } + + context "if #name is not configured for a volume group" do + let(:volume_group) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:vgName) + end + end + + context "if #extent_size is not configured for a volume group" do + let(:volume_group) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:extentSize) + end + end + + context "if #physical_volumes_devices is not configured for a volume group" do + let(:volume_group) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:targetDevices]).to eq([]) + end + end + + context "if #logical_volumes is not configured for a volume group" do + let(:volume_group) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:logicalVolumes]).to eq([]) + end + end + + context "if #name is configured for a volume group" do + let(:volume_group) { { name: "test" } } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:vgName]).to eq("test") + end + end + + context "if #extent_size is configured for a volume group" do + let(:volume_group) { { extentSize: "1 KiB" } } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:extentSize]).to eq(1.KiB.to_i) + end + end + + context "if #physical_volumes_devices is configured for a volume group" do + let(:volume_group) do + { + physicalVolumes: [{ generate: ["disk1", "disk2"] }] + } + end + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:targetDevices]).to eq(["/dev/vda", "/dev/vdb"]) + end + end + + context "if #logical_volumes is configured for a volume group" do + let(:volume_group) do + { + logicalVolumes: [logical_volume, {}] + } + end + + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:logicalVolumes].size).to eq(2) + end + + lv_result_scope = proc { |c| vg_result_scope.call(c)[:logicalVolumes].first } + + context "if #name is not configured for a logical volume" do + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:lvName) + end + end + + context "if #filesystem is not configured for a logical volume" do + let(:logical_volume) { {} } + include_examples "without filesystem", lv_result_scope + end + + context "if #size is not configured for a logical volume" do + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: true, + min: 100.MiB.to_i + } + ) + end + end + + context "if #stripes is not configured for a logical volume" do + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:stripes) + end + end + + context "if #stripe_size is not configured for a logical volume" do + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:stripeSize) + end + end + + context "if #name is configured for a logical volume" do + let(:logical_volume) { { name: "test" } } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:lvName]).to eq("test") + end + end + + context "if #filesystem is configured for a logical volume" do + let(:logical_volume) { { filesystem: filesystem } } + include_examples "with filesystem", lv_result_scope + end + + context "if #size is not configured for a logical volume" do + let(:logical_volume) do + { + size: { + min: "1 GiB", + max: "5 GiB" + } + } + end + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: false, + min: 1.GiB.to_i, + max: 5.GiB.to_i + } + ) + end + end + + context "if #stripes is configured for a logical volume" do + let(:logical_volume) { { stripes: 8 } } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:stripes]).to eq(8) + end + end + + context "if #stripe_size is configured for a logical volume" do + let(:logical_volume) { { stripeSize: "4 KiB" } } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:stripeSize]).to eq(4.KiB.to_i) + end + end + end + end end end diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index 3592f57e61..3c5094898c 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -118,133 +118,202 @@ 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: "/" } } - ] - } - ] + boot: { configure: configure_boot }, + drives: drives, + volumeGroups: volume_groups } end - let(:device_alias) { "root" } + let(:drives) { [] } + let(:volume_groups) { [] } - context "and the boot device is set to be the default" do - before do - config.boot.device.default = true - end + context "and boot is not set to be configured" do + let(:configure_boot) { false } - it "sets the alias of the root drive as boot device alias" do + it "does not set a boot device alias" do subject.solve(config) - boot = config.boot - expect(boot.device.device_alias).to eq("root") + expect(config.boot.device.device_alias).to be_nil end + end - context "and the root drive has no alias" do - let(:device_alias) { nil } + context "and boot is set to be configured" do + let(:configure_boot) { true } - it "sets an alias to the root drive" do - subject.solve(config) - drive = config.drives.first - expect(drive.alias).to_not be_nil + context "and the boot device is not set to default" do + before do + config.boot.device.default = false end - it "sets the alias of root drive as boot device alias" do + it "does not set a boot device alias" do subject.solve(config) - boot = config.boot - drive = config.drives.first - expect(boot.device.device_alias).to eq(drive.alias) + expect(config.boot.device.device_alias).to be_nil end + end - context "and root is over a logical volume" do - let(:scenario) { "disks.yaml" } + context "and the boot device is set to default" do + before do + config.boot.device.default = true + end - 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: "/" } } - ] - } - ] - } + context "and there is a root partition" do + let(:drives) do + [ + { + alias: device_alias, + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ] end - let(:device_alias) { "disk1" } + let(:device_alias) { "root" } - it "sets the alias of first partitioned pv drive as boot device alias" do + it "sets the alias of the root device as boot device alias" do subject.solve(config) - boot = config.boot - expect(boot.device.device_alias).to eq("disk1") + expect(config.boot.device.device_alias).to eq("root") end - context "and the drive has no alias" do + context "and the root device has no alias" do let(:device_alias) { nil } - it "sets an alias to the drive" do + it "sets an alias to the root device" do subject.solve(config) - drive = config.drives.find { |d| d.search.name == "/dev/vda" } + drive = config.drives.first expect(drive.alias).to_not be_nil end - it "sets the alias of the drive as boot device alias" do + it "sets the alias of the root device 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) + drive = config.drives.first + expect(config.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 + context "and there is a root logical volume" do + let(:volume_groups) do + [ + { + name: "system", + physicalVolumes: physical_volumes, + logicalVolumes: [ + { + 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 + context "and there are target devices for physical volumes" do + let(:drives) do + [ + { alias: "disk1" }, + { alias: "disk2" } + ] + end - context "and boot is not set to be configured" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - alias: "disk1", - partitions: [ - { filesystem: { path: "/" } } + let(:physical_volumes) { [{ generate: ["disk2", "disk1"] }] } + + it "sets the alias of the first target device as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk2") + end + end + + context "and there is no target device for physical volumes" do + let(:drives) do + [ + { + alias: "disk1", + partitions: [ + { alias: "p1" } + ] + }, + { + alias: device_alias, + partitions: [ + { alias: "p2" } + ] + }, + { alias: "disk3" } ] - } - ] - } - end + end - it "does not set a boot device alias" do - subject.solve(config) - boot = config.boot - expect(boot.device.device_alias).to be_nil + let(:device_alias) { "disk2" } + + context "and there is any partition as physical volume" do + let(:physical_volumes) { ["disk3", "p2", "p1"] } + + it "sets the alias of the device of the first partition as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk2") + end + + context "and the device of the first partition has no alias" do + let(:device_alias) { nil } + + it "sets an alias to the device" do + subject.solve(config) + drive = config.drives[1] + expect(drive.alias).to_not be_nil + end + + it "sets the alias of the device as boot device alias" do + subject.solve(config) + drive = config.drives[1] + expect(config.boot.device.device_alias).to eq(drive.alias) + end + end + end + + context "and there is no partition as physical volume" do + let(:physical_volumes) { ["disk1", "disk2"] } + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + end + end + + context "and there is neither a partition nor a logical volume for root" do + let(:drives) do + [ + { + alias: "disk1", + partitions: [ + { + filesystem: { path: "/test1" } + } + ] + } + ] + end + + let(:volume_groups) do + [ + { + name: "system", + physicalVolumes: [{ generate: ["disk1"] }], + logicalVolumes: [ + { + filesystem: { path: "/test2" } + } + ] + } + ] + end + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end end end end diff --git a/service/test/agama/storage/config_test.rb b/service/test/agama/storage/config_test.rb new file mode 100644 index 0000000000..b83b140c0b --- /dev/null +++ b/service/test/agama/storage/config_test.rb @@ -0,0 +1,342 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "./storage_helpers" +require "agama/config" +require "agama/storage/config_conversions" + +describe Agama::Storage::Config do + include Agama::RSpec::StorageHelpers + + subject do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + describe "#boot_device" do + context "if boot config is not set to be configured" do + let(:config_json) do + { + boot: { + configure: false, + device: "boot" + }, + drives: [ + { alias: "boot" } + ] + } + end + + it "returns nil" do + expect(subject.boot_device).to be_nil + end + end + + context "if boot config is set to be configured" do + let(:config_json) do + { + boot: { + configure: true, + device: device_alias + }, + drives: [ + { + alias: "disk1", + partitions: [ + { alias: "part1" } + ] + } + ] + } + end + + context "and boot config has not a device alias" do + let(:device_alias) { nil } + + it "returns nil" do + expect(subject.boot_device).to be_nil + end + end + + context "and boot config has a device alias" do + context "and there is not a drive config with the boot device alias" do + let(:device_alias) { "part1" } + + it "returns nil" do + expect(subject.boot_device).to be_nil + end + end + + context "and there is a drive config with the boot device alias" do + let(:device_alias) { "disk1" } + + it "returns the drive config" do + expect(subject.boot_device).to be_a(Agama::Storage::Configs::Drive) + expect(subject.boot_device.alias).to eq("disk1") + end + end + end + end + end + + describe "#root_device" do + let(:config_json) do + { + drives: [ + { alias: "disk1" }, + drive + ], + volumeGroups: [ + { name: "vg1" }, + volume_group + ] + } + end + + let(:root_volume_group) do + { + name: "vg2", + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + end + + context "if there is a drive used for root" do + let(:drive) do + { + alias: "disk2", + filesystem: { path: "/" } + } + end + + let(:volume_group) { root_volume_group } + + it "returns the drive" do + expect(subject.root_device).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_device.alias).to eq("disk2") + end + end + + context "if there is a drive containing a partition used for root" do + let(:drive) do + { + alias: "disk2", + partitions: [ + { + alias: "part1", + filesystem: { path: "/" } + } + ] + } + end + + let(:volume_group) { root_volume_group } + + it "returns the drive" do + expect(subject.root_device).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_device.alias).to eq("disk2") + end + end + + context "if there is neither root drive nor root partition" do + let(:drive) { {} } + + context "and there is not a volume group containing a logical volume used for root" do + let(:volume_group) { {} } + + it "returns nil" do + expect(subject.root_device).to be_nil + end + end + + context "and there is a volume group containing a logical volume used for root" do + let(:volume_group) { root_volume_group } + + it "returns the volume group" do + expect(subject.root_device).to be_a(Agama::Storage::Configs::VolumeGroup) + expect(subject.root_device.name).to eq("vg2") + end + end + end + end + + describe "#root_drive" do + let(:config_json) do + { + drives: [ + { alias: "disk1" }, + drive + ], + volumeGroups: [ + { + name: "vg1", + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + ] + } + end + + context "if there is a drive used for root" do + let(:drive) do + { + alias: "disk2", + filesystem: { path: "/" } + } + end + + it "returns the drive" do + expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_drive.alias).to eq("disk2") + end + end + + context "if there is a drive containing a partition used for root" do + let(:drive) do + { + alias: "disk2", + partitions: [ + { + alias: "part1", + filesystem: { path: "/" } + } + ] + } + end + + it "returns the drive" do + expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_drive.alias).to eq("disk2") + end + end + + context "if there is neither root drive nor root partition" do + let(:drive) { {} } + + it "returns nil" do + expect(subject.root_drive).to be_nil + end + end + end + + describe "#root_volume_group" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + alias: "part1", + filesystem: { path: "/" } + } + ] + } + ], + volumeGroups: [ + { + name: "vg1", + logicalVolumes: [ + { + filesystem: { path: "/home" } + } + ] + }, + volume_group + ] + } + end + + context "if there is a volume group containing a logical volume used for root" do + let(:volume_group) do + { + name: "vg2", + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + end + + it "returns the volume group" do + expect(subject.root_volume_group).to be_a(Agama::Storage::Configs::VolumeGroup) + expect(subject.root_volume_group.name).to eq("vg2") + end + end + + context "if there is not a volume group containing a logical volume used for root" do + let(:volume_group) { { name: "vg2" } } + + it "returns nil" do + expect(subject.root_volume_group).to be_nil + end + end + end + + describe "#drive" do + let(:config_json) do + { + drives: [ + { + alias: "disk1", + partitions: [ + { alias: "part1" } + ] + }, + { + alias: "disk2", + partitions: [ + { alias: "disk1" } + ] + } + ] + } + end + + context "if there is a drive with the given alias" do + let(:device_alias) { "disk1" } + + it "returns the drive" do + drive = subject.drive(device_alias) + + expect(drive).to be_a(Agama::Storage::Configs::Drive) + expect(drive.alias).to eq(device_alias) + end + end + + context "if there is not a drive with the given alias" do + let(:device_alias) { "part1" } + + it "returns nil" do + drive = subject.drive(device_alias) + + expect(drive).to be_nil + end + end + end +end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index cb40631d90..4fc8b6a850 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -358,7 +358,7 @@ def drive(partitions) subject.calculate_from_json(config_json) end - context "and the config contains an encrypted drive" do + context "and the model does not support the config" do let(:config_json) do { storage: { @@ -378,32 +378,12 @@ def drive(partitions) end end - context "and the config contains volume groups" do - let(:config_json) do - { - storage: { - volumeGroups: [ - { - name: "vg0" - } - ] - } - } - end - - it "returns nil" do - expect(subject.model_json).to be_nil - end - end - context "and the config has errors" do let(:config_json) do { storage: { drives: [ - { - search: "unknown" - } + { search: "unknown" } ] } } @@ -412,13 +392,20 @@ def drive(partitions) it "returns the config model" do expect(subject.model_json).to eq( { - boot: { + boot: { configure: true, device: { default: true } }, - drives: [] + drives: [ + { + name: "unknown", + spacePolicy: "keep", + partitions: [] + } + ], + volumeGroups: [] } ) end @@ -448,21 +435,20 @@ def drive(partitions) it "returns the config model" do expect(subject.model_json).to eq( { - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - encryption: { + encryption: { method: "luks1", password: "12345" }, - drives: [ + drives: [ { name: "/dev/sda", - alias: "root", spacePolicy: "keep", partitions: [ { @@ -483,7 +469,8 @@ def drive(partitions) } ] } - ] + ], + volumeGroups: [] } ) end @@ -510,17 +497,16 @@ def drive(partitions) result = subject.solve_model(model) expect(result).to eq({ - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - drives: [ + drives: [ { name: "/dev/sda", - alias: "sda", spacePolicy: "keep", partitions: [ { @@ -541,7 +527,8 @@ def drive(partitions) } ] } - ] + ], + volumeGroups: [] }) end diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index e3cd032e56..34ec9812c0 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Mar 27 11:42:51 UTC 2025 - José Iván López González + +- Add basic support for creating LVM volume groups and logical + volumes (gh#agama-project/agama#2216). + ------------------------------------------------------------------- Wed Mar 26 08:25:34 UTC 2025 - David Diaz diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 22d2556576..b41ac621df 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -22,7 +22,7 @@ import { get, post, put } from "~/api/http"; import { Job } from "~/types/job"; -import { Action, config, configModel, ProductParams, Volume } from "~/api/storage/types"; +import { Action, config, apiModel, ProductParams, Volume } from "~/api/storage/types"; /** * Starts the storage probing process. @@ -36,16 +36,16 @@ const reprobe = (): Promise => post("/api/storage/reprobe"); const fetchConfig = (): Promise => get("/api/storage/config").then((config) => config.storage ?? null); -const fetchConfigModel = (): Promise => +const fetchConfigModel = (): Promise => get("/api/storage/config_model"); const setConfig = (config: config.Config) => put("/api/storage/config", { storage: config }); const resetConfig = () => put("/api/storage/config/reset", {}); -const setConfigModel = (model: configModel.Config) => put("/api/storage/config_model", model); +const setConfigModel = (model: apiModel.Config) => put("/api/storage/config_model", model); -const solveConfigModel = (model: configModel.Config): Promise => { +const solveConfigModel = (model: apiModel.Config): Promise => { const serializedModel = encodeURIComponent(JSON.stringify(model)); return get(`/api/storage/config_model/solve?model=${serializedModel}`); }; @@ -86,6 +86,7 @@ export { solveConfigModel, fetchUsableDevices, fetchProductParams, + fetchVolume, fetchVolumes, fetchActions, fetchStorageJobs, diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index 216d0eda10..09ad88d304 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -import * as config from "./types/config"; -import * as configModel from "./types/config-model"; - export * from "./types/openapi"; -export { config, configModel }; +export * as config from "./types/config"; +export * as apiModel from "./types/model"; diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/model.ts similarity index 83% rename from web/src/api/storage/types/config-model.ts rename to web/src/api/storage/types/model.ts index 8a109fb952..89504d14c0 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/model.ts @@ -5,10 +5,6 @@ */ export type EncryptionMethod = "luks1" | "luks2" | "tpmFde"; -/** - * Alias used to reference a device. - */ -export type Alias = string; export type FilesystemType = | "bcachefs" | "btrfs" @@ -37,6 +33,7 @@ export interface Config { boot?: Boot; encryption?: Encryption; drives?: Drive[]; + volumeGroups?: VolumeGroup[]; } export interface Boot { configure: boolean; @@ -52,7 +49,6 @@ export interface Encryption { } export interface Drive { name: string; - alias?: Alias; mountPath?: string; filesystem?: Filesystem; spacePolicy?: SpacePolicy; @@ -68,7 +64,6 @@ export interface Filesystem { } export interface Partition { name?: string; - alias?: Alias; id?: PartitionId; mountPath?: string; filesystem?: Filesystem; @@ -83,3 +78,17 @@ export interface Size { min: number; max?: number; } +export interface VolumeGroup { + vgName: string; + extentSize?: number; + targetDevices?: string[]; + logicalVolumes?: LogicalVolume[]; +} +export interface LogicalVolume { + lvName?: string; + mountPath?: string; + filesystem?: Filesystem; + size?: Size; + stripes?: number; + stripeSize?: number; +} diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index ac9a8d8292..f85fcf5f05 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -296,10 +296,32 @@ --pf-v6-c-alert--PaddingBlockEnd: var(--pf-t--global--spacer--sm); --pf-v6-c-alert--PaddingInlineStart: var(--pf-t--global--spacer--md); --pf-v6-c-alert--PaddingInlineEnd: var(--pf-t--global--spacer--md); + border-width: 0; + border-radius: 0; + border-inline-start-width: var(--pf-t--global--border--width--box--status--default); &:has(.pf-v6-c-alert__description) { row-gap: var(--pf-t--global--spacer--xs); } + + $pf-alert-modifiers: "custom", "info", "success", "warning", "danger"; + + @each $modifier in $pf-alert-modifiers { + &.pf-m-#{$modifier} { + background: linear-gradient( + to right in srgb, + color-mix(in srgb, var(--pf-v6-c-alert--m-#{$modifier}--BorderColor), transparent 94%), + color-mix(in srgb, var(--pf-v6-c-alert--m-#{$modifier}--BorderColor), transparent 96%), + color-mix(in srgb, var(--pf-v6-c-alert--m-#{$modifier}--BorderColor), transparent 98%), + transparent + ); + } + } + + &.pf-m-plain { + border: 0; + background: none; + } } .pf-v6-c-alert__title { @@ -365,6 +387,20 @@ label.pf-m-disabled + .pf-v6-c-check__description { justify-self: flex-start; } +.pf-v6-c-check { + row-gap: var(--pf-t--global--spacer--xs); + align-content: baseline; + + input[type="checkbox"] { + align-self: center; + } +} + +.pf-v6-c-list.pf-m-inline { + --pf-v6-c-list--m-inline--RowGap: var(--pf-t--global--spacer--sm); + --pf-v6-c-list--m-inline--ColumnGap: var(--pf-t--global--spacer--md); +} + // Nested content inside forms should respect parent grid gap .pf-v6-c-form [class*="pf-v6-u-mx"] { display: grid; diff --git a/web/src/components/core/LoginPage.tsx b/web/src/components/core/LoginPage.tsx index ab9a13bf6b..50e98c3290 100644 --- a/web/src/components/core/LoginPage.tsx +++ b/web/src/components/core/LoginPage.tsx @@ -23,16 +23,19 @@ import React, { useState } from "react"; import { Navigate } from "react-router-dom"; import { + ActionGroup, + Alert, Bullseye, Button, Content, - Divider, Flex, Form, FormGroup, - Stack, + FormHelperText, + HelperText, + HelperTextItem, } from "@patternfly/react-core"; -import { FormValidationError, Page, PasswordInput } from "~/components/core"; +import { Page, PasswordInput } from "~/components/core"; import { AuthErrors, useAuth } from "~/context/auth"; import { _ } from "~/i18n"; import shadowUtils from "@patternfly/react-styles/css/utilities/BoxShadow/box-shadow"; @@ -82,13 +85,8 @@ user privileges.", - + hasHeaderDivider + title={ -
- - {rootExplanationStart} {rootUser} {rootExplanationEnd} - - - {_("Please, provide its password to log in to the system.")} - -
- -
- - setPassword(v)} - /> - + } + pfCardProps={{ + isCompact: false, + isFullHeight: false, + className: shadowUtils.boxShadowMd, + }} + > + + {error && } - {error && } + + setPassword(v)} + /> + + + + {rootExplanationStart} {rootUser} {rootExplanationEnd} + + + {_("Please, provide its password to log in to the system.")} + + + + + - -
+ +
diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 98b1739eb0..6929f194b5 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -54,7 +54,7 @@ import { _ } from "~/i18n"; */ type SectionProps = { /** The section title */ - title?: string; + title?: React.ReactNode; /** The value used for accessible label */ "aria-label"?: string; /** Elements to be rendered in the section footer */ diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index 242defe744..ab2a6e3353 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -42,10 +42,6 @@ export type PopupProps = { title?: ModalHeaderProps["title"]; /** Extra content to be placed in the header after the title */ titleAddon?: React.ReactNode; - /** The block/height size for the dialog. Default is "auto". */ - blockSize?: "auto" | "small" | "medium" | "large"; - /** The inline/width size for the dialog. Default is "medium". */ - inlineSize?: "auto" | "small" | "medium" | "large"; /** Whether it should display a loading indicator instead of the requested content. */ isLoading?: boolean; /** Text displayed when `isLoading` is set to `true` */ @@ -213,10 +209,6 @@ const Popup = ({ isLoading = false, // TRANSLATORS: progress message loadingText = _("Loading data..."), - inlineSize = "medium", - blockSize = "auto", - variant = "medium", - className = "", children, ...props }: PopupProps) => { @@ -230,10 +222,9 @@ const Popup = ({ return ( /** @ts-ignore */ diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index 1ee9c1515e..91fc00026a 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -24,16 +24,16 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { StorageSection } from "~/components/overview"; -import * as ConfigModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { Issue } from "~/types/issues"; -const sdaDrive: ConfigModel.Drive = { +const sdaDrive: apiModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [], }; -const sdbDrive: ConfigModel.Drive = { +const sdbDrive: apiModel.Drive = { name: "/dev/sdb", spacePolicy: "delete", partitions: [], diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx index b3c8eb5246..aed82411af 100644 --- a/web/src/components/overview/StorageSection.tsx +++ b/web/src/components/overview/StorageSection.tsx @@ -27,15 +27,15 @@ import { useDevices, useAvailableDevices } from "~/queries/storage"; import { useConfigModel } from "~/queries/storage/config-model"; import { useSystemErrors } from "~/queries/issues"; import { StorageDevice } from "~/types/storage"; -import * as ConfigModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { _ } from "~/i18n"; -const findDriveDevice = (drive: ConfigModel.Drive, devices: StorageDevice[]) => +const findDriveDevice = (drive: apiModel.Drive, devices: StorageDevice[]) => devices.find((d) => d.name === drive.name); const NoDeviceSummary = () => _("No device selected yet"); -const SingleDiskSummary = ({ drive }: { drive: ConfigModel.Drive }) => { +const SingleDiskSummary = ({ drive }: { drive: apiModel.Drive }) => { const devices = useDevices("system", { suspense: true }); const device = findDriveDevice(drive, devices); const options = { @@ -64,7 +64,7 @@ const SingleDiskSummary = ({ drive }: { drive: ConfigModel.Drive }) => { ); }; -const MultipleDisksSummary = ({ drives }: { drives: ConfigModel.Drive[] }): string => { +const MultipleDisksSummary = ({ drives }: { drives: apiModel.Drive[] }): string => { const options = { resize: _("Install using several devices shrinking existing partitions as needed."), keep: _("Install using several devices without modifying existing partitions."), @@ -79,7 +79,7 @@ const MultipleDisksSummary = ({ drives }: { drives: ConfigModel.Drive[] }): stri return options[drives[0].spacePolicy]; }; -const ModelSummary = ({ model }: { model: ConfigModel.Config }): React.ReactNode => { +const ModelSummary = ({ model }: { model: apiModel.Config }): React.ReactNode => { const devices = useDevices("system", { suspense: true }); const drives = model?.drives || []; const existDevice = (name: string) => devices.some((d) => d.name === name); diff --git a/web/src/components/storage/AddExistingDeviceMenu.test.tsx b/web/src/components/storage/AddExistingDeviceMenu.test.tsx deleted file mode 100644 index b2dcf13f74..0000000000 --- a/web/src/components/storage/AddExistingDeviceMenu.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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. - */ - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import AddExistingDeviceMenu from "~/components/storage/AddExistingDeviceMenu"; -import { StorageDevice } from "~/types/storage"; -import * as ConfigModel from "~/api/storage/types/config-model"; - -const vda: StorageDevice = { - sid: 59, - type: "disk", - isDrive: true, - description: "", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - name: "/dev/vda", - size: 1e12, - systems: ["Windows 11", "openSUSE Leap 15.2"], -}; - -const vdb: StorageDevice = { - sid: 60, - type: "disk", - isDrive: true, - description: "", - vendor: "Seagate", - model: "Unknown", - driver: ["ahci", "mmcblk"], - bus: "IDE", - name: "/dev/vdb", - size: 1e6, - systems: [], -}; - -const vdaDrive: ConfigModel.Drive = { - name: "/dev/vda", - spacePolicy: "delete", - partitions: [], -}; - -const vdbDrive: ConfigModel.Drive = { - name: "/dev/vdb", - spacePolicy: "delete", - partitions: [], -}; - -const mockUseConfigModelFn = jest.fn(); -const mockAddDriveFn = jest.fn(); - -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useAvailableDevices: () => [vda, vdb], -})); - -jest.mock("~/queries/storage/config-model", () => ({ - useModel: () => ({ addDrive: mockAddDriveFn }), - useConfigModel: () => mockUseConfigModelFn(), -})); - -describe("when there are unused disks", () => { - beforeEach(() => { - mockUseConfigModelFn.mockReturnValue({ drives: [] }); - }); - - it("renders the menu", async () => { - plainRender(); - expect(screen.queryByText(/Use additional disk/)).toBeInTheDocument(); - }); - - it("allows users to add a new drive", async () => { - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: /Use additional/ }); - await user.click(button); - const devicesMenu = screen.getByRole("menu"); - const vdaItem = within(devicesMenu).getByRole("menuitem", { name: /vda/ }); - await user.click(vdaItem); - expect(mockAddDriveFn).toHaveBeenCalled(); - }); -}); - -describe("when there are no more unused disks", () => { - beforeEach(() => { - mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive, vdbDrive] }); - }); - - it("renders nothing", async () => { - plainRender(); - expect(screen.queryByText(/Use additional disk/)).toBeNull(); - }); -}); diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/AddExistingDeviceMenu.tsx deleted file mode 100644 index 0729890966..0000000000 --- a/web/src/components/storage/AddExistingDeviceMenu.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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. - */ - -import React, { useState } from "react"; -import { _, n_ } from "~/i18n"; -import { sprintf } from "sprintf-js"; -import { - Dropdown, - DropdownList, - DropdownItem, - DropdownGroup, - MenuToggleElement, - MenuToggle, - Divider, - Split, - Flex, - Label, -} from "@patternfly/react-core"; -import { MenuHeader } from "~/components/core"; -import MenuDeviceDescription from "./MenuDeviceDescription"; -import { useAvailableDevices } from "~/queries/storage"; -import { useConfigModel, useModel } from "~/queries/storage/config-model"; -import { deviceLabel } from "~/components/storage/utils"; - -export default function AddExistingDeviceMenu() { - const [isOpen, setIsOpen] = useState(false); - const toggle = () => setIsOpen(!isOpen); - const allDevices = useAvailableDevices(); - const model = useConfigModel({ suspense: true }); - const modelHook = useModel(); - - const drivesNames = model.drives.map((d) => d.name); - const devices = allDevices.filter((d) => !drivesNames.includes(d.name)); - - const Header = ({ drives }) => { - const desc = sprintf( - n_( - "Extends the installation beyond the currently selected disk", - "Extends the installation beyond the current %d disks", - drives.length, - ), - drives.length, - ); - - return ; - }; - - if (!devices.length) return null; - - return ( - ) => ( - - {_("Use additional disk")} - - )} - > - - {/* @ts-expect-error See https://github.com/patternfly/patternfly/issues/7327 */} - }> - - {devices.map((device) => ( - } - onClick={() => modelHook.addDrive(device.name)} - > - - {deviceLabel(device)} - - {device.systems.map((s, i) => ( - - ))} - - - - ))} - - - - ); -} diff --git a/web/src/components/storage/AutoSizeText.tsx b/web/src/components/storage/AutoSizeText.tsx new file mode 100644 index 0000000000..6676790706 --- /dev/null +++ b/web/src/components/storage/AutoSizeText.tsx @@ -0,0 +1,334 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { SubtleContent } from "~/components/core/"; +import { deviceSize } from "~/components/storage/utils"; +import { _, formatList } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { apiModel, Volume } from "~/api/storage/types"; + +type DeviceType = "partition" | "logicalVolume"; + +function deviceTypeLabel(deviceType: DeviceType): string { + return deviceType === "partition" ? _("partition") : _("logical volume"); +} + +type AutoSizeTextFallbackProps = { + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextFallback({ size, deviceType }: AutoSizeTextFallbackProps): React.ReactNode { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1s$ is a size with units (eg. 3 GiB) and %1$s is a device type (eg. partition) + _("A generic size of %s will be used for the new %2$s"), + deviceSize(size.min), + deviceTypeLabel(deviceType), + ); + } + + return sprintf( + // TRANSLATORS: %1s$ and %2s$ are sizes with units (eg. 3 GiB) and %3$s is a device type (eg. partition) + _("A generic size range between %1$s and %2$s will be used for the new %3$s"), + deviceSize(size.min), + deviceSize(size.max), + deviceTypeLabel(deviceType), + ); + } + + return sprintf( + // TRANSLATORS: %1s$ is a size with units (eg. 3 GiB) and %1$s is a device type (eg. partition) + _("A generic minimum size of %1$s will be used for the new %2$s"), + deviceSize(size.min), + deviceTypeLabel(deviceType), + ); +} + +type AutoSizeTextFixedProps = { + path: string; + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextFixed({ path, size, deviceType }: AutoSizeTextFixedProps): React.ReactNode { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("A %1$s of %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s and %3$s are sizes with units (10 GiB), and %4$s is a mount path (/home) + _("A %1$s with a size between %2$s and %3$s will be created for %4$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + deviceSize(size.max), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("A %1$s of at least %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); +} + +type AutoSizeTextRamProps = { + path: string; + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextRam({ path, size, deviceType }: AutoSizeTextRamProps): React.ReactNode { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("Based on the amount of RAM in the system, a %1s$ of %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s and %3$s are sizes with units (10 GiB), and %4$s is a mount path (/home) + _( + "Based on the amount of RAM in the system, a %1s$ with a size between %2$s and %3$s will be created for %4$s", + ), + deviceTypeLabel(deviceType), + deviceSize(size.min), + deviceSize(size.max), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("Based on the amount of RAM in the system, a %1s$ of at least %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); +} + +type AutoSizeTextDynamicProps = { + volume: Volume; + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextDynamic({ + volume, + size, + deviceType, +}: AutoSizeTextDynamicProps): React.ReactNode { + const introText = (volume) => { + const path = volume.mountPath; + const otherPaths = volume.outline.sizeRelevantVolumes || []; + const snapshots = !!volume.outline.snapshotsAffectSizes; + const ram = !!volume.outline.adjustByRam; + + if (ram && snapshots) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + if (otherPaths.length > 1) { + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + return sprintf( + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + return sprintf( + // TRANSLATORS: %s is a mount point (eg. /) + _( + "The size range for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.", + ), + path, + ); + } + + if (ram) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + if (snapshots) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + if (otherPaths.length > 1) { + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + return sprintf( + _( + "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + return sprintf( + // TRANSLATORS: %s is a mount point (eg. /) + _( + "The size range for %s will be dynamically adjusted based on the usage of Btrfs snapshots.", + ), + path, + ); + } + + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + _( + "The size range for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + }; + + const limitsText = (size) => { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition) and %2s is a size with units (eg. 10 GiB) + _("The current configuration will result in a %1$s of %2$s."), + deviceTypeLabel(deviceType), + deviceSize(size.min), + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition) %2$s is a min size, %3$s is the max size + _("The current configuration will result in a %1$s with a size between %2$s and %3$s."), + deviceTypeLabel(deviceType), + deviceSize(size.min), + deviceSize(size.max), + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition) and %2$s is a size with units (eg. 10 GiB) + _("The current configuration will result in a %1$s of at least %2$s."), + deviceTypeLabel(deviceType), + deviceSize(size.min), + ); + }; + + return ( + <> + {introText(volume)} + {limitsText(size)} + + ); +} + +export type AutoSizeTextProps = { + volume: Volume; + size: apiModel.Size; + deviceType: DeviceType; +}; + +export default function AutoSizeText({ volume, size, deviceType }: AutoSizeTextProps) { + const path = volume.mountPath; + + if (path) { + if (volume.autoSize) { + const otherPaths = volume.outline.sizeRelevantVolumes || []; + + if (otherPaths.length || volume.outline.snapshotsAffectSizes) { + return ; + } + + // This assumes volume.autoSize is correctly set. Ie. if it is set to true then at least one + // of the relevant outline fields (snapshots, RAM and sizeRelevantVolumes) is used. + return ; + } + + return ; + } + + // Fallback volume + // This assumes the fallback volume never uses automatic sizes (ie. re-calculated based on + // other aspects of the configuration). It would be VERY surprising if that's the case. + return ; +} diff --git a/web/src/components/storage/ConfigEditor.test.tsx b/web/src/components/storage/ConfigEditor.test.tsx new file mode 100644 index 0000000000..66c472bd29 --- /dev/null +++ b/web/src/components/storage/ConfigEditor.test.tsx @@ -0,0 +1,148 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import ConfigEditor from "~/components/storage/ConfigEditor"; +import { StorageDevice } from "~/types/storage"; +import { apiModel } from "~/api/storage/types"; + +const disk: StorageDevice = { + sid: 60, + type: "disk", + isDrive: true, + description: "", + vendor: "Seagate", + model: "Unknown", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vda", + size: 1e6, +}; + +const mockUseDevices = jest.fn(); +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useDevices: () => mockUseDevices(), +})); + +const mockUseConfigModel = jest.fn(); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => mockUseConfigModel(), +})); + +jest.mock("./DriveEditor", () => () =>
drive editor
); +jest.mock("./VolumeGroupEditor", () => () =>
volume group editor
); + +const hasDrives: apiModel.Config = { + drives: [{ name: "/dev/vda" }], +}; + +const hasVolumeGroups: apiModel.Config = { + volumeGroups: [{ vgName: "/dev/system" }], +}; + +const hasBoth: apiModel.Config = { + drives: [{ name: "/dev/vda" }], + volumeGroups: [{ vgName: "/dev/system" }], +}; + +const hasNothing: apiModel.Config = {}; + +beforeEach(() => { + mockUseDevices.mockReturnValue([disk]); +}); + +describe("when no drive is used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasVolumeGroups); + }); + + it("does not render the drive editor", () => { + plainRender(); + expect(screen.queryByText("drive editor")).not.toBeInTheDocument(); + }); +}); + +describe("when a drive is used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasDrives); + }); + + it("renders the drive editor", () => { + plainRender(); + expect(screen.queryByText("drive editor")).toBeInTheDocument(); + }); +}); + +describe("when no volume group is used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasDrives); + }); + + it("does not render the volume group editor", () => { + plainRender(); + expect(screen.queryByText("volume group editor")).not.toBeInTheDocument(); + }); +}); + +describe("when a volume group is used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasVolumeGroups); + }); + + it("renders the volume group editor", () => { + plainRender(); + expect(screen.queryByText("volume group editor")).toBeInTheDocument(); + }); +}); + +describe("when both a drive and volume group are used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasBoth); + }); + + it("renders a volume group editor followed by drive editor", () => { + plainRender(); + const volumeGroupEditor = screen.getByText("volume group editor"); + const driveEditor = screen.getByText("drive editor"); + + expect(volumeGroupEditor.compareDocumentPosition(driveEditor)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); +}); + +describe("when neither a drive nor volume group are used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasNothing); + }); + + it("renders a no configuration alert with a button for resetting to default", () => { + plainRender(); + screen.getByText("Custom alert:"); + screen.getByText("No devices configured yet"); + screen.getByRole("button", { name: "reset to defaults" }); + }); +}); diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index 1e2238f1a9..a2ed75ac07 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -21,18 +21,58 @@ */ import React from "react"; -import { useDevices } from "~/queries/storage"; +import { _ } from "~/i18n"; +import { useDevices, useResetConfigMutation } from "~/queries/storage"; import { useConfigModel } from "~/queries/storage/config-model"; import DriveEditor from "~/components/storage/DriveEditor"; -import { List, ListItem } from "@patternfly/react-core"; +import VolumeGroupEditor from "~/components/storage/VolumeGroupEditor"; +import { Alert, Button, List, ListItem } from "@patternfly/react-core"; + +const NoDevicesConfiguredAlert = () => { + const { mutate: reset } = useResetConfigMutation(); + const title = _("No devices configured yet"); + // TRANSLATORS: %s will be replaced by a "reset to default" button + const body = _( + "Use actions below to set up your devices or click %s to start from scratch with the default configuration.", + ); + const [bodyStart, bodyEnd] = body.split("%s"); + + return ( + + {bodyStart}{" "} + {" "} + {bodyEnd} + + ); +}; export default function ConfigEditor() { const model = useConfigModel({ suspense: true }); const devices = useDevices("system", { suspense: true }); + const drives = model.drives || []; + const volumeGroups = model.volumeGroups || []; + + if (!drives.length && !volumeGroups.length) { + return ; + } return ( - {model.drives.map((drive, i) => { + {model.volumeGroups?.map((vg, i) => { + return ( + + + + ); + })} + {model.drives?.map((drive, i) => { const device = devices.find((d) => d.name === drive.name); /** @@ -42,7 +82,7 @@ export default function ConfigEditor() { if (device === undefined) return null; return ( - + ); diff --git a/web/src/components/storage/ConfigEditorMenu.test.tsx b/web/src/components/storage/ConfigEditorMenu.test.tsx index 8fd8124006..c5df5080c3 100644 --- a/web/src/components/storage/ConfigEditorMenu.test.tsx +++ b/web/src/components/storage/ConfigEditorMenu.test.tsx @@ -53,7 +53,7 @@ beforeEach(() => { async function openMenu() { const { user } = plainRender(); - const button = screen.getByRole("button", { name: "More options toggle" }); + const button = screen.getByRole("button", { name: "Other options toggle" }); await user.click(button); const menu = screen.getByRole("menu"); return { user, menu }; @@ -61,14 +61,14 @@ async function openMenu() { it("renders the menu", () => { plainRender(); - expect(screen.queryByText("More options")).toBeInTheDocument(); + expect(screen.queryByText("Other options")).toBeInTheDocument(); }); it("allows users to change the boot options", async () => { const { user, menu } = await openMenu(); const bootItem = within(menu).getByRole("menuitem", { name: /boot options/ }); await user.click(bootItem); - expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.bootDevice); + expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.editBootDevice); }); it("allows users to reset the config", async () => { diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 03e1449b0e..91c3e8c4b3 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -54,17 +54,17 @@ export default function ConfigEditorMenu() { - {_("More options")} + {_("Other options")} )} > navigate(PATHS.bootDevice)} + onClick={() => navigate(PATHS.editBootDevice)} description={_("Select the disk to configure partitions for booting")} > {_("Change boot options")} diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx new file mode 100644 index 0000000000..ec9d3e4a64 --- /dev/null +++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx @@ -0,0 +1,152 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { mockNavigateFn, plainRender } from "~/test-utils"; +import ConfigureDeviceMenu from "./ConfigureDeviceMenu"; +import { StorageDevice } from "~/types/storage"; +import { apiModel } from "~/api/storage/types"; + +const vda: StorageDevice = { + sid: 59, + type: "disk", + isDrive: true, + description: "", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vda", + size: 1e12, + systems: ["Windows 11", "openSUSE Leap 15.2"], +}; + +const vdb: StorageDevice = { + sid: 60, + type: "disk", + isDrive: true, + description: "", + vendor: "Seagate", + model: "Unknown", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vdb", + size: 1e6, + systems: [], +}; + +const vdaDrive: apiModel.Drive = { + name: "/dev/vda", + spacePolicy: "delete", + partitions: [], +}; + +const vdbDrive: apiModel.Drive = { + name: "/dev/vdb", + spacePolicy: "delete", + partitions: [], +}; + +const mockUseConfigModelFn = jest.fn(); +const mockAddDriveFn = jest.fn(); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useAvailableDevices: () => [vda, vdb], +})); + +jest.mock("~/queries/storage/config-model", () => ({ + useModel: () => ({ addDrive: mockAddDriveFn }), + useConfigModel: () => mockUseConfigModelFn(), +})); + +describe("ConfigureDeviceMenu", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [] }); + }); + + it("renders an initially closed menu ", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: "More devices", expanded: false }); + expect(screen.queryAllByRole("menu").length).toBe(0); + await user.click(toggler); + expect(toggler).toHaveAttribute("aria-expanded", "true"); + expect(screen.queryAllByRole("menu").length).not.toBe(0); + }); + + it("allows users to add a new LVM volume group", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: "More devices", expanded: false }); + await user.click(toggler); + const lvmMenuItem = screen.getByRole("menuitem", { name: /LVM/ }); + await user.click(lvmMenuItem); + expect(mockNavigateFn).toHaveBeenCalledWith("/storage/volume-groups/add"); + }); + + describe("when there are unused disks", () => { + describe("and no disks have been configured yet", () => { + it("allows users to add a new drive", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); + await user.click(disksMenuItem); + const vdaItem = screen.getByRole("menuitem", { name: /vda/ }); + await user.click(vdaItem); + expect(mockAddDriveFn).toHaveBeenCalled(); + }); + }); + + describe("but some disks are already configured", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive] }); + }); + + it("allows users to add a new drive to an unused disk", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); + await user.click(disksMenuItem); + expect(screen.queryByRole("menuitem", { name: /vda/ })).toBeNull(); + const vdbItem = screen.getByRole("menuitem", { name: /vdb/ }); + await user.click(vdbItem); + expect(mockAddDriveFn).toHaveBeenCalled(); + }); + }); + }); + + describe("when there are no more unused disks", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive, vdbDrive] }); + }); + + it("renders the disks menu as disabled with an informative label", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); + expect(disksMenuItem).toBeDisabled(); + }); + }); +}); diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx new file mode 100644 index 0000000000..17b43e77d9 --- /dev/null +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -0,0 +1,244 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React, { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + MenuToggle, + Split, + Flex, + Label, + DrilldownMenu, + MenuContent, + Divider, + MenuContainer, + Menu, + MenuList, + MenuItem, +} from "@patternfly/react-core"; +import MenuDeviceDescription from "./MenuDeviceDescription"; +import { useAvailableDevices } from "~/queries/storage"; +import { useConfigModel, useModel } from "~/queries/storage/config-model"; +import { deviceLabel } from "~/components/storage/utils"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { sprintf } from "sprintf-js"; +import { _, n_ } from "~/i18n"; +import { StorageDevice } from "~/types/storage"; + +type DisksDrillDownMenuItemProps = { + /** Available devices to be chosen */ + devices: StorageDevice[]; + /** The amount of drives already configured */ + drivesCount: number; + /** Callback function to be triggered when a device is selected */ + onDeviceClick: (deviceName: StorageDevice["name"]) => void; +}; + +/** + * Internal component holding the logic for rendering the disks drilldown menu + */ +const DisksDrillDownMenuItem = ({ + drivesCount, + devices, + onDeviceClick, +}: DisksDrillDownMenuItemProps) => { + const isDisabled = !devices.length; + + const disabledDescription = _("Already using all available disks"); + const enabledDescription = drivesCount + ? sprintf( + n_( + "Extend the installation beyond the currently selected disk", + "Extend the installation beyond the current %d disks", + drivesCount, + ), + drivesCount, + ) + : _("Start configuring a basic installation"); + const title = drivesCount + ? _("Select another disk to define partitions") + : _("Select a disk to define partitions"); + + return ( + + + {_("Back")} + + + {devices.map((device) => ( + } + onClick={() => onDeviceClick(device.name)} + > + + {deviceLabel(device)} + + {device.systems.map((s, i) => ( + + ))} + + + + ))} + + } + > + {title} + + ); +}; + +/** + * Menu that provides options for users to configure storage drives + * + * It uses a drilled-down menu approach for disks, making the available options less + * overwhelming by presenting them in a more organized manner. + * + * TODO: Refactor and test the component after extracting a basic DrillDown menu to + * share the internal logic with other potential menus that could benefit from a similar + * approach. + */ +export default function ConfigureDeviceMenu() { + const navigate = useNavigate(); + const model = useConfigModel({ suspense: true }); + const { addDrive } = useModel(); + const allDevices = useAvailableDevices(); + const menuRef = useRef(); + const toggleRef = useRef(); + const [isOpen, setIsOpen] = useState(false); + const [menuDrilledIn, setMenuDrilledIn] = React.useState([]); + const [drilldownPath, setDrilldownPath] = React.useState([]); + const [activeMenu, setActiveMenu] = React.useState("root"); + const [menuHeights, setMenuHeights] = React.useState({}); + + const resetState = () => { + setMenuDrilledIn([]); + setDrilldownPath([]); + setActiveMenu("root"); + }; + + const toggle = () => { + setIsOpen(!isOpen); + resetState(); + }; + + const addDriveAndClose = (driveName) => { + addDrive(driveName); + setIsOpen(false); + resetState(); + }; + + const drivesNames = model.drives.map((d) => d.name); + const drivesCount = drivesNames.length; + const devices = allDevices.filter((d) => !drivesNames.includes(d.name)); + + const drillIn = ( + _: React.KeyboardEvent | React.MouseEvent, + fromMenuId: string, + toMenuId: string, + pathId: string, + ) => { + setMenuDrilledIn([...menuDrilledIn, fromMenuId]); + setDrilldownPath([...drilldownPath, pathId]); + setActiveMenu(toMenuId); + }; + + const drillOut = (_: React.KeyboardEvent | React.MouseEvent, toMenuId: string) => { + const menuDrilledInSansLast = menuDrilledIn.slice(0, menuDrilledIn.length - 1); + const pathSansLast = drilldownPath.slice(0, drilldownPath.length - 1); + setMenuDrilledIn(menuDrilledInSansLast); + setDrilldownPath(pathSansLast); + setActiveMenu(toMenuId); + }; + + const setHeight = (menuId: string, height: number) => { + // FIXME: look for a better way to avoid test crashing because of this + // method + if (process.env.NODE_ENV === "test") return; + + if ( + menuHeights[menuId] === undefined || + (menuId !== "root" && menuHeights[menuId] !== height) + ) { + setMenuHeights({ ...menuHeights, [menuId]: height }); + } + }; + + const lvmDescription = allDevices.length + ? _("Define a new LVM on top of one or several disks") + : _("Define a new LVM on the disk"); + + return ( + + {_("More devices")} + + } + menuRef={menuRef} + menu={ + + + + + + navigate(PATHS.volumeGroup.add)} + description={lvmDescription} + > + {_("Add LVM volume group")} + + + + + } + /> + ); +} diff --git a/web/src/components/storage/DeviceHeader.tsx b/web/src/components/storage/DeviceHeader.tsx new file mode 100644 index 0000000000..f0a9a78bf6 --- /dev/null +++ b/web/src/components/storage/DeviceHeader.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { Content } from "@patternfly/react-core"; + +export type DeviceHeaderProps = { + title: string; + children: React.ReactNode; +}; + +export default function DeviceHeader({ title, children }: DeviceHeaderProps) { + const [txt1, txt2] = title.split("%s"); + + return ( + + {txt1} {children} {txt2} + + ); +} diff --git a/web/src/components/storage/DeviceMenu.tsx b/web/src/components/storage/DeviceMenu.tsx new file mode 100644 index 0000000000..6492b9ddbf --- /dev/null +++ b/web/src/components/storage/DeviceMenu.tsx @@ -0,0 +1,98 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React, { useId, useRef, useState } from "react"; +import { Icon } from "~/components/layout"; +import { + Menu, + MenuProps, + MenuContainer, + MenuContent, + MenuToggle, + MenuToggleProps, + MenuToggleElement, +} from "@patternfly/react-core"; + +const InlineMenuToggle = React.forwardRef( + (props: MenuToggleProps, ref: React.Ref) => ( + } + innerRef={ref} + variant="plain" + className="agm-inline-menu-toggle" + {...props} + /> + ), +); + +export type DeviceMenuProps = { + title: string | React.ReactNode; + ariaLabel?: string; + activeItemId?: MenuProps["activeItemId"]; + children: React.ReactNode; +}; + +export default function DeviceMenu({ + title, + ariaLabel = undefined, + activeItemId = undefined, + children, +}: DeviceMenuProps) { + const menuId = useId(); + const menuRef = useRef(); + const toggleMenuRef = useRef(); + const [isOpen, setIsOpen] = useState(false); + const onToggle = () => setIsOpen(!isOpen); + + return ( + + {title} + + } + menuRef={menuRef} + menu={ + setIsOpen(false)} + > + {children} + + } + // @ts-expect-error + popperProps={{ appendTo: document.body }} + /> + ); +} diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index c7fa4a04db..320d6c19a4 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -24,9 +24,8 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender, mockNavigateFn } from "~/test-utils"; import DriveEditor from "~/components/storage/DriveEditor"; -import * as ConfigModel from "~/api/storage/types/config-model"; import { StorageDevice } from "~/types/storage"; -import { Volume } from "~/api/storage/types"; +import { apiModel, Volume } from "~/api/storage/types"; const volume1: Volume = { mountPath: "/", @@ -122,7 +121,7 @@ const sdb: StorageDevice = { description: "", }; -const drive1: ConfigModel.Drive = { +const drive1: apiModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [ @@ -145,7 +144,7 @@ const drive1: ConfigModel.Drive = { ], }; -const drive2: ConfigModel.Drive = { +const drive2: apiModel.Drive = { name: "/dev/sdb", spacePolicy: "delete", partitions: [ @@ -161,7 +160,6 @@ const drive2: ConfigModel.Drive = { }; const mockDeleteDrive = jest.fn(); -const mockGetPartition = jest.fn(); const mockDeletePartition = jest.fn(); let additionalDrives = true; @@ -179,7 +177,7 @@ jest.mock("~/queries/storage/config-model", () => ({ useDrive: (name) => ({ isExplicitBoot: name === "/dev/sda", delete: mockDeleteDrive, - getPartition: mockGetPartition, + getPartition: (path) => drive1.partitions.find((p) => p.mountPath === path), deletePartition: mockDeletePartition, }), useModel: () => ({ @@ -224,7 +222,7 @@ describe("PartitionMenuItem", () => { name: "Edit swap", }); await user.click(editSwapButton); - expect(mockNavigateFn).toHaveBeenCalledWith("/storage/sda/edit-partition/swap"); + expect(mockNavigateFn).toHaveBeenCalledWith("/storage/drives/sda/partitions/swap/edit"); }); }); diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index a8aab5daa1..20d526fbe1 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -20,20 +20,22 @@ * find current contact information at www.suse.com. */ -import React, { useId, useRef, useState } from "react"; +import React from "react"; import { useNavigate, generatePath } from "react-router-dom"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; import { baseName, deviceLabel, formattedPath, SPACE_POLICIES } from "~/components/storage/utils"; import { useAvailableDevices } from "~/queries/storage"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDrive, useModel } from "~/queries/storage/config-model"; +import { useDrive as useDriveModel } from "~/hooks/storage/drive"; import * as driveUtils from "~/components/storage/utils/drive"; -import * as partitionUtils from "~/components/storage/utils/partition"; import { contentDescription } from "~/components/storage/utils/device"; -import { Icon } from "../layout"; +import DeviceMenu from "~/components/storage/DeviceMenu"; +import DeviceHeader from "~/components/storage/DeviceHeader"; +import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; import { MenuHeader } from "~/components/core"; import MenuDeviceDescription from "./MenuDeviceDescription"; import { @@ -45,33 +47,14 @@ import { Flex, Label, Split, - Menu, - MenuContainer, - MenuContent, MenuItem, - MenuItemAction, MenuList, - MenuToggle, - MenuToggleProps, - MenuToggleElement, MenuGroup, } from "@patternfly/react-core"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; -export type DriveEditorProps = { drive: configModel.Drive; driveDevice: StorageDevice }; - -export const InlineMenuToggle = React.forwardRef( - (props: MenuToggleProps, ref: React.Ref) => ( - } - innerRef={ref} - variant="plain" - className="agm-inline-menu-toggle" - {...props} - /> - ), -); +export type DriveEditorProps = { drive: apiModel.Drive; driveDevice: StorageDevice }; // FIXME: Presentation is quite poor const SpacePolicySelectorIntro = ({ device }) => { @@ -97,18 +80,13 @@ const SpacePolicySelectorIntro = ({ device }) => { }; const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); const { setSpacePolicy } = useDrive(drive.name); - const onToggle = () => setIsOpen(!isOpen); - const onSpacePolicyChange = (spacePolicy: configModel.SpacePolicy) => { + const onSpacePolicyChange = (spacePolicy: apiModel.SpacePolicy) => { if (spacePolicy === "custom") { - return navigate(generatePath(PATHS.findSpace, { id: baseName(drive.name) })); + return navigate(generatePath(PATHS.drive.editSpacePolicy, { id: baseName(drive.name) })); } else { setSpacePolicy(spacePolicy); - setIsOpen(false); } }; @@ -132,41 +110,30 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { }; return ( - - {driveUtils.contentActionsDescription(drive)} - - } - menuRef={menuRef} - menu={ - - - }> - - - {SPACE_POLICIES.map((policy) => ( - - ))} - - - - - } - /> + {driveUtils.contentActionsDescription(drive)}} + activeItemId={currentPolicy.id} + > + }> + + + {SPACE_POLICIES.map((policy) => ( + + ))} + + + ); }; -const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { +const SearchSelectorIntro = ({ drive }: { drive: apiModel.Drive }) => { + /** @todo Replace the useDrive hook from /queries by the hook from /hooks. */ + const volumeGroups = useDriveModel(drive.name)?.getVolumeGroups() || []; const driveModel = useDrive(drive.name); if (!driveModel) return; - const { isBoot, isExplicitBoot } = driveModel; - // TODO: Get volume groups associated to the drive. - const volumeGroups = []; + const { isBoot, isExplicitBoot, hasPv } = driveModel; + const vgName = volumeGroups[0]?.vgName; const mainText = (): string => { if (driveUtils.hasReuse(drive)) { @@ -176,7 +143,7 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { if (!driveUtils.hasFilesystem(drive)) { // The current device will be the only option to choose from - if (driveUtils.hasPv(drive)) { + if (hasPv) { if (volumeGroups.length > 1) { if (isExplicitBoot) { return _( @@ -189,12 +156,12 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { return sprintf( // TRANSLATORS: %s is the name of the LVM _("This device will contain the LVM group '%s' and any partition needed to boot"), - volumeGroups[0], + vgName, ); } // TRANSLATORS: %s is the name of the LVM - return sprintf(_("This device will contain the LVM group '%s'"), volumeGroups[0]); + return sprintf(_("This device will contain the LVM group '%s'"), vgName); } // The current device will be the only option to choose from @@ -229,7 +196,7 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { const name = baseName(drive.name); - if (driveUtils.hasPv(drive)) { + if (hasPv) { if (volumeGroups.length > 1) { if (isExplicitBoot) { return sprintf( @@ -248,15 +215,15 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { // 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, - volumeGroups[0], + vgName, ); } return sprintf( // 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"), + vgName, name, - volumeGroups[0], ); } @@ -352,13 +319,13 @@ const SearchSelectorOptions = ({ drive, selected, onChange }) => { const driveModel = useDrive(drive.name); if (!driveModel) return; - const { isExplicitBoot } = driveModel; + const { isExplicitBoot, hasPv } = driveModel; // const boot = isExplicitBoot(drive.name); if (driveUtils.hasReuse(drive)) return ; if (!driveUtils.hasFilesystem(drive)) { - if (driveUtils.hasPv(drive) || isExplicitBoot) { + if (hasPv || isExplicitBoot) { return ; } @@ -384,7 +351,7 @@ const RemoveDriveOption = ({ drive }) => { if (!driveModel) return; - const { isExplicitBoot, delete: deleteDrive } = driveModel; + const { isExplicitBoot, hasPv, delete: deleteDrive } = driveModel; // When no additional drives has been added, the "Do not use" button can be confusing so it is // omitted for all drives. @@ -393,7 +360,7 @@ const RemoveDriveOption = ({ drive }) => { // FIXME: in these two cases the button should likely be present, but disabled and with an // explanation of why those particular drive definitions cannot be removed. if (isExplicitBoot) return; - if (driveUtils.hasPv(drive)) return; + if (hasPv) return; return ( <> @@ -410,56 +377,31 @@ const RemoveDriveOption = ({ drive }) => { }; const DriveSelector = ({ drive, selected, toggleAriaLabel }) => { - const menuId = useId(); - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const driveHandler = useDrive(drive.name); const onDriveChange = (newDriveName: string) => { driveHandler.switch(newDriveName); - setIsOpen(false); }; - const onToggle = () => setIsOpen(!isOpen); return ( - - {deviceLabel(selected)} - - } - menuRef={menuRef} - menu={ - - - - - - - - - } - // @ts-expect-error - popperProps={{ appendTo: document.body }} - /> + {deviceLabel(selected)}} + ariaLabel={toggleAriaLabel} + activeItemId={selected.sid} + > + + + + + ); }; const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { - const { isBoot } = useDrive(drive.name); + const { isBoot, hasPv } = useDrive(drive.name); - const text = (drive: configModel.Drive): string => { + const text = (drive: apiModel.Drive): string => { if (driveUtils.hasRoot(drive)) { - if (driveUtils.hasPv(drive)) { + if (hasPv) { 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"); @@ -477,7 +419,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { } if (driveUtils.hasFilesystem(drive)) { - if (driveUtils.hasPv(drive)) { + if (hasPv) { 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"); @@ -494,7 +436,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { return _("Use %s for additional partitions"); } - if (driveUtils.hasPv(drive)) { + if (hasPv) { 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"); @@ -505,175 +447,95 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" - return _("Use %s to boot"); + return _("Use %s to configure boot partitions"); } // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s"); }; - - const [txt1, txt2] = text(drive).split("%s"); // TRANSLATORS: a disk drive const toggleAriaLabel = _("Drive"); return ( -

- {txt1} + - {txt2} -

+ ); }; const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { - const menuId = useId(); - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); - const onToggle = () => setIsOpen(!isOpen); return ( - {_("No additional partitions will be created")}} + ariaLabel={toggleAriaLabel} + > + + + navigate(generatePath(PATHS.drive.partition.add, { id: baseName(drive.name) })) + } > - {_("No additional partitions will be created")} - - } - menuRef={menuRef} - menu={ - - - - - navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) })) - } - > - - {_("Add or use partition")} - - - - - - } - /> + + {_("Add or use partition")} + + + + ); }; const PartitionMenuItem = ({ driveName, mountPath }) => { - const navigate = useNavigate(); const drive = useDrive(driveName); const partition = drive.getPartition(mountPath); - const description = partition ? partitionUtils.typeWithSize(partition) : null; + const editPath = generatePath(PATHS.drive.partition.edit, { + id: baseName(driveName), + partitionId: encodeURIComponent(mountPath), + }); + const deletePartition = () => drive.deletePartition(mountPath); - return ( - - } - actionId={`edit-${mountPath}`} - aria-label={`Edit ${mountPath}`} - onClick={() => - navigate( - generatePath(PATHS.editPartition, { - id: baseName(driveName), - partitionId: encodeURIComponent(mountPath), - }), - ) - } - /> - } - actionId={`delete-${mountPath}`} - aria-label={`Delete ${mountPath}`} - onClick={() => drive.deletePartition(mountPath)} - /> - - } - > - {mountPath} - - ); + return ; }; const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { - const menuId = useId(); - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); - const onToggle = () => setIsOpen(!isOpen); return ( - {driveUtils.contentDescription(drive)}} + ariaLabel={toggleAriaLabel} + > + + {drive.partitions + .filter((p) => p.mountPath) + .map((partition) => { + return ( + + ); + })} + + + navigate(generatePath(PATHS.drive.partition.add, { id: baseName(drive.name) })) + } > - {driveUtils.contentDescription(drive)} - - } - menuRef={menuRef} - menu={ - - - - {drive.partitions - .filter((p) => p.mountPath) - .map((partition) => { - return ( - - ); - })} - - - navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) })) - } - > - - {_("Add or use partition")} - - - - - - } - /> + + {_("Add or use partition")} + + + + ); }; diff --git a/web/src/components/storage/EncryptionSection.test.tsx b/web/src/components/storage/EncryptionSection.test.tsx index a56a6c6c03..f2f70243db 100644 --- a/web/src/components/storage/EncryptionSection.test.tsx +++ b/web/src/components/storage/EncryptionSection.test.tsx @@ -79,6 +79,6 @@ describe("EncryptionSection", () => { it("renders a link for navigating to encryption settings", () => { plainRender(); const editLink = screen.getByRole("link", { name: "Edit" }); - expect(editLink).toHaveAttribute("href", STORAGE.encryption); + expect(editLink).toHaveAttribute("href", STORAGE.editEncryption); }); }); diff --git a/web/src/components/storage/EncryptionSection.tsx b/web/src/components/storage/EncryptionSection.tsx index b4aa77f20e..7f4c9dddd7 100644 --- a/web/src/components/storage/EncryptionSection.tsx +++ b/web/src/components/storage/EncryptionSection.tsx @@ -24,11 +24,11 @@ import React from "react"; import { Card, CardBody, Content } from "@patternfly/react-core"; import { Link, Page } from "~/components/core"; import { useEncryption } from "~/queries/storage/config-model"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; -function encryptionLabel(method?: EncryptionMethod) { +function encryptionLabel(method?: apiModel.EncryptionMethod) { if (!method) return _("Encryption is disabled"); if (method === "tpmFde") return _("Encryption is enabled using TPM unlocking"); @@ -47,7 +47,7 @@ export default function EncryptionSection() { the new file systems, including data, programs, and system files.", )} pfCardBodyProps={{ isFilled: true }} - actions={{_("Edit")}} + actions={{_("Edit")}} > diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx index 31bbe551eb..2d3e41659e 100644 --- a/web/src/components/storage/EncryptionSettingsPage.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -26,7 +26,7 @@ import { ActionGroup, Alert, Checkbox, Content, Form } from "@patternfly/react-c import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core"; import { useEncryptionMethods } from "~/queries/storage"; import { useEncryption } from "~/queries/storage/config-model"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { isEmpty } from "~/utils"; import { _ } from "~/i18n"; @@ -41,7 +41,7 @@ export default function EncryptionSettingsPage() { const [errors, setErrors] = useState([]); const [isEnabled, setIsEnabled] = useState(false); const [password, setPassword] = useState(""); - const [method, setMethod] = useState("luks2"); + const [method, setMethod] = useState("luks2"); const passwordRef = useRef(); const formId = "encryptionSettingsForm"; diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx new file mode 100644 index 0000000000..a540238be9 --- /dev/null +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -0,0 +1,962 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +/** + * @fixme This code was done in a hurry for including LVM managent in SLE16 beta3. It must be + * completely refactored. There are a lot of duplications with PartitionPage. Both PartitionPage + * and LogicalVolumePage should be adapted to share as much functionality as possible. + */ + +import React, { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + ActionGroup, + Content, + Flex, + FlexItem, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + SelectGroup, + SelectList, + SelectOption, + SelectOptionProps, + Split, + Stack, + StackItem, + TextInput, +} from "@patternfly/react-core"; +import { NestedContent, Page, SelectWrapper as Select, SubtleContent } from "~/components/core/"; +import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; +import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; +import AutoSizeText from "~/components/storage/AutoSizeText"; +import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils"; +import { compact, uniq } from "~/utils"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { useApiModel, useSolvedApiModel } from "~/hooks/storage/api-model"; +import { useModel } from "~/hooks/storage/model"; +import { useMissingMountPaths, useVolume } from "~/hooks/storage/product"; +import { useVolumeGroup } from "~/hooks/storage/volume-group"; +import { useAddLogicalVolume, useEditLogicalVolume } from "~/hooks/storage/logical-volume"; +import { addLogicalVolume, editLogicalVolume } from "~/helpers/storage/logical-volume"; +import { buildLogicalVolumeName } from "~/helpers/storage/api-model"; +import { apiModel } from "~/api/storage/types"; +import { data } from "~/types/storage"; +import { STORAGE as PATHS } from "~/routes/paths"; + +const NO_VALUE = ""; +const BTRFS_SNAPSHOTS = "btrfsSnapshots"; + +type SizeOptionValue = "" | "auto" | "custom"; +type CustomSizeValue = "fixed" | "unlimited" | "range"; +type FormValue = { + mountPoint: string; + name: string; + filesystem: string; + filesystemLabel: string; + sizeOption: SizeOptionValue; + minSize: string; + maxSize: string; +}; +type SizeRange = { + min: string; + max: string; +}; +type Error = { + id: string; + message?: string; + isVisible: boolean; +}; + +type ErrorsHandler = { + errors: Error[]; + getError: (id: string) => Error | undefined; + getVisibleError: (id: string) => Error | undefined; +}; + +function toData(value: FormValue): data.LogicalVolume { + const filesystemType = (): apiModel.FilesystemType | undefined => { + if (value.filesystem === NO_VALUE) return undefined; + if (value.filesystem === BTRFS_SNAPSHOTS) return "btrfs"; + + /** + * @note This type cast is needed because the list of filesystems coming from a volume is not + * enumerated (the volume simply contains a list of strings). This implies we have to rely on + * whatever value coming from such a list as a filesystem type accepted by the config model. + * This will be fixed in the future by directly exporting the volumes as a JSON, similar to the + * config model. The schema for the volumes will define the explicit list of filesystem types. + */ + return value.filesystem as apiModel.FilesystemType; + }; + + const filesystem = (): data.Filesystem | undefined => { + const type = filesystemType(); + if (type === undefined) return undefined; + + return { + type, + snapshots: value.filesystem === BTRFS_SNAPSHOTS, + label: value.filesystemLabel, + }; + }; + + const size = (): apiModel.Size | undefined => { + if (value.sizeOption === "auto") return undefined; + if (value.minSize === NO_VALUE) return undefined; + + return { + default: false, + min: parseToBytes(value.minSize), + max: value.maxSize === NO_VALUE ? undefined : parseToBytes(value.maxSize), + }; + }; + + return { + mountPath: value.mountPoint, + lvName: value.name, + filesystem: filesystem(), + size: size(), + }; +} + +function toFormValue(logicalVolume: apiModel.LogicalVolume): FormValue { + const mountPoint = (): string => logicalVolume.mountPath || NO_VALUE; + + const filesystem = (): string => { + const fs = logicalVolume.filesystem; + if (!fs.type) return NO_VALUE; + if (fs.type === "btrfs" && fs.snapshots) return BTRFS_SNAPSHOTS; + + return fs.type; + }; + + const filesystemLabel = (): string => logicalVolume.filesystem?.label || NO_VALUE; + + const sizeOption = (): SizeOptionValue => { + const size = logicalVolume.size; + if (!size || size.default) return "auto"; + + return "custom"; + }; + + const size = (value: number | undefined): string => (value ? deviceSize(value) : NO_VALUE); + + return { + mountPoint: mountPoint(), + name: logicalVolume.lvName, + filesystem: filesystem(), + filesystemLabel: filesystemLabel(), + sizeOption: sizeOption(), + minSize: size(logicalVolume.size?.min), + maxSize: size(logicalVolume.size?.max), + }; +} + +function useDefaultFilesystem(mountPoint: string): string { + const volume = useVolume(mountPoint, { suspense: true }); + return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; +} + +function useInitialLogicalVolume(): apiModel.LogicalVolume | null { + const { id: vgName, logicalVolumeId: mountPath } = useParams(); + const volumeGroup = useVolumeGroup(vgName); + + if (!volumeGroup || !mountPath) return null; + + const logicalVolume = volumeGroup.logicalVolumes.find((l) => l.mountPath === mountPath); + return logicalVolume || null; +} + +function useInitialFormValue(): FormValue | null { + const logicalVolume = useInitialLogicalVolume(); + const value = useMemo(() => (logicalVolume ? toFormValue(logicalVolume) : null), [logicalVolume]); + return value; +} + +/** Unused predefined mount points. Includes the currently used mount point when editing. */ +function useUnusedMountPoints(): string[] { + const missingMountPaths = useMissingMountPaths(); + const initialLogicalVolume = useInitialLogicalVolume(); + return compact([initialLogicalVolume?.mountPath, ...missingMountPaths]); +} + +function useUsableFilesystems(mountPoint: string): string[] { + const volume = useVolume(mountPoint); + const defaultFilesystem = useDefaultFilesystem(mountPoint); + + const usableFilesystems = useMemo(() => { + const volumeFilesystems = (): string[] => { + const allValues = volume.outline.fsTypes; + + if (volume.mountPath !== "/") return allValues; + + // Btrfs without snapshots is not an option. + if (!volume.outline.snapshotsConfigurable && volume.snapshots) { + return [BTRFS_SNAPSHOTS, ...allValues].filter((v) => v !== "btrfs"); + } + + // Btrfs with snapshots is not an option + if (!volume.outline.snapshotsConfigurable && !volume.snapshots) { + return allValues; + } + + return [BTRFS_SNAPSHOTS, ...allValues]; + }; + + return uniq([defaultFilesystem, ...volumeFilesystems()]); + }, [volume, defaultFilesystem]); + + return usableFilesystems; +} + +function useMountPointError(value: FormValue): Error | undefined { + const model = useModel(); + const mountPoints = model?.getMountPaths() || []; + const initialLogicalVolume = useInitialLogicalVolume(); + const mountPoint = value.mountPoint; + + if (mountPoint === NO_VALUE) { + return { + id: "mountPoint", + isVisible: false, + }; + } + + const regex = /^swap$|^\/$|^(\/[^/\s]+)+$/; + if (!regex.test(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a valid mount point"), + isVisible: true, + }; + } + + // Exclude itself when editing + const initialMountPoint = initialLogicalVolume?.mountPath; + if (mountPoint !== initialMountPoint && mountPoints.includes(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a mount point that is not already assigned to another device"), + isVisible: true, + }; + } +} + +function checkLogicalVolumeName(value: FormValue): Error | undefined { + if (value.name?.length) return; + + return { + id: "logicalVolumeName", + message: _("Enter a name"), + isVisible: true, + }; +} + +function checkSize(value: FormValue): Error | undefined { + if (value.sizeOption !== "custom") return; + + const min = value.minSize; + const max = value.maxSize; + + if (!min) { + return { + id: "customSize", + isVisible: false, + }; + } + + const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$/; + const validMin = regexp.test(min); + const validMax = max ? regexp.test(max) : true; + + if (validMin && validMax) { + if (!max || parseToBytes(min) <= parseToBytes(max)) return; + + return { + id: "customSize", + message: _("The minimum cannot be greater than the maximum"), + isVisible: true, + }; + } + + if (validMin) { + return { + id: "customSize", + message: _("The maximum must be a number optionally followed by a unit like GiB or GB"), + isVisible: true, + }; + } + + if (validMax) { + return { + id: "customSize", + message: _("The minimum must be a number optionally followed by a unit like GiB or GB"), + isVisible: true, + }; + } + + return { + id: "customSize", + message: _("Size limits must be numbers optionally followed by a unit like GiB or GB"), + isVisible: true, + }; +} + +function useErrors(value: FormValue): ErrorsHandler { + const mountPointError = useMountPointError(value); + const nameError = checkLogicalVolumeName(value); + const sizeError = checkSize(value); + const errors = compact([mountPointError, nameError, sizeError]); + + const getError = (id: string): Error | undefined => errors.find((e) => e.id === id); + + const getVisibleError = (id: string): Error | undefined => { + const error = getError(id); + return error?.isVisible ? error : undefined; + }; + + return { errors, getError, getVisibleError }; +} + +function useSolvedModel(value: FormValue): apiModel.Config | null { + const { id: vgName, logicalVolumeId: mountPath } = useParams(); + const apiModel = useApiModel(); + const { getError } = useErrors(value); + const mountPointError = getError("mountPoint"); + const data = toData(value); + // Avoid recalculating the solved model because changes in label. + if (data.filesystem) data.filesystem.label = undefined; + // Avoid recalculating the solved model because changes in name. + data.lvName = undefined; + + let sparseModel: apiModel.Config | undefined; + + if (data.filesystem && !mountPointError) { + if (mountPath) { + sparseModel = editLogicalVolume(apiModel, vgName, mountPath, data); + } else { + sparseModel = addLogicalVolume(apiModel, vgName, data); + } + } + + const solvedModel = useSolvedApiModel(sparseModel); + return solvedModel; +} + +function useSolvedLogicalVolume(value: FormValue): apiModel.LogicalVolume | undefined { + const { id: vgName } = useParams(); + const apiModel = useSolvedModel(value); + const volumeGroup = apiModel?.volumeGroups?.find((v) => v.vgName === vgName); + return volumeGroup?.logicalVolumes?.find((l) => l.mountPath === value.mountPoint); +} + +function useSolvedSizes(value: FormValue): SizeRange { + // Remove size values in order to get a solved size. + const valueWithoutSizes: FormValue = { + ...value, + sizeOption: NO_VALUE, + minSize: NO_VALUE, + maxSize: NO_VALUE, + }; + + const logicalVolume = useSolvedLogicalVolume(valueWithoutSizes); + + const solvedSizes = useMemo(() => { + const min = logicalVolume?.size?.min; + const max = logicalVolume?.size?.max; + + return { + min: min ? deviceSize(min) : NO_VALUE, + max: max ? deviceSize(max) : NO_VALUE, + }; + }, [logicalVolume]); + + return solvedSizes; +} + +function useAutoRefreshFilesystem(handler, value: FormValue) { + const { mountPoint } = value; + const defaultFilesystem = useDefaultFilesystem(mountPoint); + + useEffect(() => { + // Reset filesystem if there is no mount point yet. + if (mountPoint === NO_VALUE) handler(NO_VALUE); + // Select default filesystem for the mount point. + if (mountPoint !== NO_VALUE) handler(defaultFilesystem); + }, [handler, mountPoint, defaultFilesystem]); +} + +function useAutoRefreshSize(handler, value: FormValue) { + const solvedSizes = useSolvedSizes(value); + + useEffect(() => { + handler("auto", solvedSizes.min, solvedSizes.max); + }, [handler, solvedSizes]); +} + +function mountPointSelectOptions(mountPoints: string[]): SelectOptionProps[] { + return mountPoints.map((p) => ({ value: p, children: p })); +} + +type LogicalVolumeNameProps = { + id?: string; + value: FormValue; + mountPoint: string; + onChange: (v: string) => void; +}; + +function LogicalVolumeName({ + id, + value, + mountPoint, + onChange, +}: LogicalVolumeNameProps): React.ReactNode { + const { getVisibleError } = useErrors(value); + const error = getVisibleError("logicalVolumeName"); + const isDisabled = mountPoint === NO_VALUE; + + return ( + + onChange(v)} + /> + {error && !isDisabled && ( + + + {error && {error.message}} + + + )} + + ); +} + +type FilesystemOptionLabelProps = { + value: string; +}; + +function FilesystemOptionLabel({ value }: FilesystemOptionLabelProps): React.ReactNode { + if (value === NO_VALUE) return _("Waiting for a mount point"); + if (value === BTRFS_SNAPSHOTS) return _("Btrfs with snapshots"); + + return filesystemLabel(value); +} + +type FilesystemOptionsProps = { + mountPoint: string; +}; + +function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { + const defaultFilesystem = useDefaultFilesystem(mountPoint); + const usableFilesystems = useUsableFilesystems(mountPoint); + + const defaultOptText = + mountPoint !== NO_VALUE + ? sprintf(_("Default file system for %s"), mountPoint) + : _("Default file system for generic logical volumes"); + + const formatText = _("Format logical volume as"); + + return ( + + {mountPoint === NO_VALUE && ( + + + + )} + {mountPoint !== NO_VALUE && ( + + {usableFilesystems.map((fsType, index) => ( + + + + ))} + + )} + + ); +} + +type FilesystemSelectProps = { + id?: string; + value: string; + mountPoint: string; + onChange: SelectProps["onChange"]; +}; + +function FilesystemSelect({ + id, + value, + mountPoint, + onChange, +}: FilesystemSelectProps): React.ReactNode { + const usedValue = mountPoint === NO_VALUE ? NO_VALUE : value; + + return ( + + ); +} + +type FilesystemLabelProps = { + id?: string; + value: string; + onChange: (v: string) => void; +}; + +function FilesystemLabel({ id, value, onChange }: FilesystemLabelProps): React.ReactNode { + const isValid = (v: string) => /^[\w-_.]*$/.test(v); + + return ( + isValid(v) && onChange(v)} + /> + ); +} + +type SizeOptionLabelProps = { + value: SizeOptionValue; + mountPoint: string; +}; + +function SizeOptionLabel({ value, mountPoint }: SizeOptionLabelProps): React.ReactNode { + if (mountPoint === NO_VALUE) return _("Waiting for a mount point"); + if (value === "auto") return _("Calculated automatically"); + if (value === "custom") return _("Custom"); + + return value; +} + +type SizeOptionsProps = { + mountPoint: string; +}; + +function SizeOptions({ mountPoint }: SizeOptionsProps): React.ReactNode { + return ( + + {mountPoint === NO_VALUE && ( + + + + )} + {mountPoint !== NO_VALUE && ( + <> + + + + + + + + )} + + ); +} + +type AutoSizeInfoProps = { + value: FormValue; +}; + +function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { + const volume = useVolume(value.mountPoint); + const logicalVolume = useSolvedLogicalVolume(value); + const size = logicalVolume?.size; + + if (!size) return; + + return ( + + + + ); +} + +type CustomSizeOptionLabelProps = { + value: CustomSizeValue; +}; + +function CustomSizeOptionLabel({ value }: CustomSizeOptionLabelProps): React.ReactNode { + const labels = { + fixed: _("Same as minimum"), + unlimited: _("None"), + range: _("Limited"), + }; + + return labels[value]; +} + +function CustomSizeOptions(): React.ReactNode { + return ( + + + + + + + + + + + + ); +} + +type CustomSizeProps = { + value: FormValue; + onChange: (size: SizeRange) => void; +}; + +function CustomSize({ value, onChange }: CustomSizeProps) { + const initialOption = (): CustomSizeValue => { + if (value.minSize === NO_VALUE) return "fixed"; + if (value.minSize === value.maxSize) return "fixed"; + if (value.maxSize === NO_VALUE) return "unlimited"; + return "range"; + }; + + const [option, setOption] = React.useState(initialOption()); + const { max: solvedMaxSize } = useSolvedSizes(value); + const { getVisibleError } = useErrors(value); + + const error = getVisibleError("customSize"); + + const changeMinSize = (min: string) => { + const max = option === "fixed" ? min : value.maxSize; + onChange({ min, max }); + }; + + const changeMaxSize = (max: string) => { + onChange({ min: value.minSize, max }); + }; + + const changeOption = (v: CustomSizeValue) => { + setOption(v); + + const min = value.minSize; + if (v === "fixed") onChange({ min, max: min }); + if (v === "unlimited") onChange({ min, max: NO_VALUE }); + if (v === "range") { + const max = solvedMaxSize || NO_VALUE; + onChange({ min, max }); + } + }; + + return ( + + + + {_("Sizes must be entered as a numbers optionally followed by a unit.")} + + + {_( + "If the unit is omitted, bytes (B) will be used. Greater units can be of \ + the form GiB (power of 2) or GB (power of 10).", + )} + + + + + + + changeMinSize(v)} + /> + + + + + + + {option === "range" && ( + changeMaxSize(v)} + /> + )} + + + + + + + {error && {error.message}} + + + + + ); +} + +export default function LogicalVolumePage() { + const navigate = useNavigate(); + const headingId = useId(); + const { id: vgName } = useParams(); + const addLogicalVolume = useAddLogicalVolume(); + const editLogicalVolume = useEditLogicalVolume(); + const [mountPoint, setMountPoint] = useState(NO_VALUE); + const [name, setName] = useState(NO_VALUE); + const [filesystem, setFilesystem] = useState(NO_VALUE); + const [filesystemLabel, setFilesystemLabel] = useState(NO_VALUE); + const [sizeOption, setSizeOption] = useState(NO_VALUE); + const [minSize, setMinSize] = useState(NO_VALUE); + const [maxSize, setMaxSize] = useState(NO_VALUE); + // Filesystem and size selectors should not be auto refreshed before the user interacts with the + // mount point selector. + const [autoRefreshFilesystem, setAutoRefreshFilesystem] = useState(false); + const [autoRefreshSize, setAutoRefreshSize] = useState(false); + + const initialValue = useInitialFormValue(); + const value = { mountPoint, name, filesystem, filesystemLabel, sizeOption, minSize, maxSize }; + const { errors, getVisibleError } = useErrors(value); + const unusedMountPoints = useUnusedMountPoints(); + + // Initializes the form values if there is an initial value (i.e., when editing a logical volume). + React.useEffect(() => { + if (initialValue) { + setMountPoint(initialValue.mountPoint); + setName(initialValue.name); + setFilesystem(initialValue.filesystem); + setFilesystemLabel(initialValue.filesystemLabel); + setSizeOption(initialValue.sizeOption); + setMinSize(initialValue.minSize); + setMaxSize(initialValue.maxSize); + } + }, [ + initialValue, + setMountPoint, + setFilesystem, + setFilesystemLabel, + setSizeOption, + setMinSize, + setMaxSize, + ]); + + const refreshFilesystemHandler = useCallback( + (filesystem: string) => autoRefreshFilesystem && setFilesystem(filesystem), + [autoRefreshFilesystem, setFilesystem], + ); + + useAutoRefreshFilesystem(refreshFilesystemHandler, value); + + const refreshSizeHandler = useCallback( + (sizeOption: SizeOptionValue, minSize: string, maxSize: string) => { + if (autoRefreshSize) { + setSizeOption(sizeOption); + setMinSize(minSize); + setMaxSize(maxSize); + } + }, + [autoRefreshSize, setSizeOption, setMinSize, setMaxSize], + ); + + useAutoRefreshSize(refreshSizeHandler, value); + + const changeMountPoint = (value: string) => { + if (value !== mountPoint) { + setAutoRefreshFilesystem(true); + setAutoRefreshSize(true); + setMountPoint(value); + setName(buildLogicalVolumeName(value)); + } + }; + + const changeFilesystem = (value: string) => { + setAutoRefreshFilesystem(false); + setAutoRefreshSize(false); + setFilesystem(value); + }; + + const changeSize = ({ min, max }) => { + if (min !== undefined) setMinSize(min); + if (max !== undefined) setMaxSize(max); + }; + + const onSubmit = () => { + const data = toData(value); + + if (initialValue) editLogicalVolume(vgName, initialValue.mountPoint, data); + else addLogicalVolume(vgName, data); + + navigate(PATHS.root); + }; + + const isFormValid = errors.length === 0; + const mountPointError = getVisibleError("mountPoint"); + const usedMountPt = mountPointError ? NO_VALUE : mountPoint; + const showLabel = filesystem !== NO_VALUE && usedMountPt !== NO_VALUE; + + return ( + + + + {sprintf(_("Configure LVM logical volume at %s volume group"), vgName)} + + + + +
+ + + + + + + + + + {!mountPointError && _("Select or enter a mount point")} + {mountPointError?.message} + + + + + + + + + + + + + + + + + + + + + + + {showLabel && ( + + + + + + )} + + + + + + + + {sizeOption === "auto" && } + {sizeOption === "custom" && } + + + + + + + + +
+
+
+ ); +} diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx new file mode 100644 index 0000000000..90b2728adb --- /dev/null +++ b/web/src/components/storage/LvmPage.test.tsx @@ -0,0 +1,331 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender, mockParams } from "~/test-utils"; +import { model, StorageDevice } from "~/types/storage"; +import { gib } from "./utils"; +import LvmPage from "./LvmPage"; + +const sda1: StorageDevice = { + sid: 69, + name: "/dev/sda1", + description: "Swap partition", + isDrive: false, + type: "partition", + size: gib(2), + shrinking: { unsupported: ["Resizing is not supported"] }, + start: 1, +}; + +const sda: StorageDevice = { + sid: 59, + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + shrinking: { unsupported: ["Resizing is not supported"] }, + systems: [], + partitionTable: { + type: "gpt", + partitions: [sda1], + unpartitionedSize: 0, + unusedSlots: [{ start: 3, size: gib(2) }], + }, + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], + description: "", +}; + +const sdb: StorageDevice = { + sid: 60, + isDrive: true, + type: "disk", + name: "/dev/sdb", + size: 1024, + systems: [], + description: "", +}; + +const mockSdaDrive: model.Drive = { + name: "/dev/sda", + spacePolicy: "delete", + partitions: [ + { + mountPath: "swap", + size: { + min: gib(2), + default: false, // false: user provided, true: calculated + }, + filesystem: { default: false, type: "swap" }, + }, + { + mountPath: "/home", + size: { + min: gib(16), + default: true, + }, + filesystem: { default: false, type: "xfs" }, + }, + ], + isUsed: true, + isAddingPartitions: true, + getVolumeGroups: () => [], + getMountPaths: () => ["/home", "swap"], +}; + +const mockRootVolumeGroup: model.VolumeGroup = { + vgName: "fakeRootVg", + logicalVolumes: [], + getTargetDevices: () => [mockSdaDrive], + getMountPaths: () => [], +}; + +const mockHomeVolumeGroup: model.VolumeGroup = { + vgName: "fakeHomeVg", + logicalVolumes: [], + getTargetDevices: () => [mockSdaDrive], + getMountPaths: () => [], +}; + +const mockAddVolumeGroup = jest.fn(); +const mockEditVolumeGroup = jest.fn(); + +let mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [], +}; + +const mockUseAllDevices = [sda, sdb]; + +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssuesChanges: jest.fn(), + useIssues: () => [], +})); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useAvailableDevices: () => mockUseAllDevices, + useDevices: () => [sda, sdb], +})); + +jest.mock("~/hooks/storage/model", () => ({ + ...jest.requireActual("~/hooks/storage/model"), + __esModule: true, + useModel: () => mockUseModel, +})); + +jest.mock("~/hooks/storage/volume-group", () => ({ + ...jest.requireActual("~/hooks/storage/volume-group"), + __esModule: true, + useAddVolumeGroup: () => mockAddVolumeGroup, + useEditVolumeGroup: () => mockEditVolumeGroup, +})); + +describe("LvmPage", () => { + describe("when creating a new volume group", () => { + it("allows configuring a new LVM volume group (without moving mount points)", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const sdbCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sdb, 1 KiB" }); + const moveMountPointsCheckbox = screen.getByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Clear default value for name + await user.clear(name); + await user.type(name, "root-vg"); + await user.click(sdbCheckbox); + + // sda is selected by default because it is adding partitions. + expect(sdaCheckbox).toBeChecked(); + // By default move mount points should be checked + expect(moveMountPointsCheckbox).toBeChecked(); + await user.click(moveMountPointsCheckbox); + expect(moveMountPointsCheckbox).not.toBeChecked(); + await user.click(acceptButton); + expect(mockAddVolumeGroup).toHaveBeenCalledWith( + { vgName: "root-vg", targetDevices: ["/dev/sda", "/dev/sdb"] }, + false, + ); + }); + + it("allows configuring a new LVM volume group (moving mount points)", async () => { + const { user } = installerRender(); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdbCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sdb, 1 KiB" }); + const moveMountPointsCheckbox = screen.getByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + await user.click(sdbCheckbox); + expect(moveMountPointsCheckbox).toBeChecked(); + await user.click(acceptButton); + expect(mockAddVolumeGroup).toHaveBeenCalledWith( + { vgName: "system", targetDevices: ["/dev/sda", "/dev/sdb"] }, + true, + ); + }); + + it("performs basic validations", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Unselect sda + await user.click(sdaCheckbox); + + // Let's clean the default given name + await user.clear(name); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText(/Enter a name/); + screen.getByText(/Select at least one disk/); + + // Type a name + await user.type(name, "root-vg"); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + screen.getByText(/Select at least one disk/); + + // Select sda again + expect(sdaCheckbox).not.toBeChecked(); + await user.click(sdaCheckbox); + expect(sdaCheckbox).toBeChecked(); + await user.click(acceptButton); + expect(screen.queryByText("Warning alert:")).toBeNull(); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + expect(screen.queryByText(/Select at least one disk/)).toBeNull(); + }); + + describe("when there are LVM volume groups", () => { + beforeEach(() => { + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [mockRootVolumeGroup], + }; + }); + + it("does not pre-fill the name input", () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + expect(name).toHaveValue(""); + }); + }); + + describe("when there are no LVM volume groups yet", () => { + beforeEach(() => { + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [], + }; + }); + + it("pre-fills the name input with 'system'", () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + expect(name).toHaveValue("system"); + }); + }); + }); + + describe("when editing", () => { + beforeEach(() => { + mockParams({ id: "fakeRootVg" }); + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [mockRootVolumeGroup, mockHomeVolumeGroup], + }; + }); + + it("performs basic validations", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Let's clean the default given name + await user.clear(name); + await user.click(sdaCheckbox); + expect(name).toHaveValue(""); + expect(sdaCheckbox).not.toBeChecked(); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText(/Enter a name/); + screen.getByText(/Select at least one disk/); + // Enter a name already in use + await user.type(name, "fakeHomeVg"); + await user.click(acceptButton); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + screen.getByText(/Enter a different name/); + }); + + it("pre-fills form with the current volume group configuration", async () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const sdaCheckbox = screen.getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + expect(name).toHaveValue("fakeRootVg"); + expect(sdaCheckbox).toBeChecked(); + }); + + it("does not offer option for moving mount points", () => { + installerRender(); + expect( + screen.queryByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }), + ).toBeNull(); + }); + + it("triggers the hook for updating the volume group when user accepts changes", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.clear(name); + await user.type(name, "updatedRootVg"); + await user.click(acceptButton); + expect(mockEditVolumeGroup).toHaveBeenCalledWith("fakeRootVg", { + vgName: "updatedRootVg", + targetDevices: ["/dev/sda"], + }); + }); + }); +}); diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx new file mode 100644 index 0000000000..2fb746e362 --- /dev/null +++ b/web/src/components/storage/LvmPage.tsx @@ -0,0 +1,215 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React, { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + ActionGroup, + Alert, + Checkbox, + Content, + Flex, + Form, + FormGroup, + Gallery, + Label, + TextInput, +} from "@patternfly/react-core"; +import { Page, SubtleContent } from "~/components/core"; +import { useAvailableDevices } from "~/queries/storage"; +import { StorageDevice, model, data } from "~/types/storage"; +import { useModel } from "~/hooks/storage/model"; +import { + useVolumeGroup, + useAddVolumeGroup, + useEditVolumeGroup, +} from "~/hooks/storage/volume-group"; +import { deviceLabel } from "./utils"; +import { contentDescription, filesystemLabels, typeDescription } from "./utils/device"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; + +function vgNameError( + vgName: string, + model: model.Model, + volumeGroup?: model.VolumeGroup, +): string | undefined { + if (!vgName.length) return _("Enter a name for the volume group."); + + const exist = model.volumeGroups.some((v) => v.vgName === vgName); + if (exist && vgName !== volumeGroup?.vgName) + return sprintf(_("Volume group '%s' already exists. Enter a different name."), vgName); +} + +function targetDevicesError(targetDevices: StorageDevice[]): string | undefined { + if (!targetDevices.length) return _("Select at least one disk."); +} + +/** + * Form for configuring a LVM volume group. + * + * @todo Adapt states to use a data.VolumeGroup type and initializes its value from a + * model.VolumeGroup (build data.VolumeGroup from model.VolumeGroup). + */ +export default function LvmPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const model = useModel(); + const volumeGroup = useVolumeGroup(id); + const addVolumeGroup = useAddVolumeGroup(); + const editVolumeGroup = useEditVolumeGroup(); + const allDevices = useAvailableDevices(); + const [name, setName] = useState(""); + const [selectedDevices, setSelectedDevices] = useState([]); + const [moveMountPoints, setMoveMountPoints] = useState(true); + const [errors, setErrors] = useState([]); + + useEffect(() => { + if (volumeGroup) { + setName(volumeGroup.vgName); + const targetNames = volumeGroup.getTargetDevices().map((d) => d.name); + const targetDevices = allDevices.filter((d) => targetNames.includes(d.name)); + setSelectedDevices(targetDevices); + } else if (model && !model.volumeGroups.length) { + setName("system"); + const targetNames = model.drives.filter((d) => d.isAddingPartitions).map((d) => d.name); + const targetDevices = allDevices.filter((d) => targetNames.includes(d.name)); + setSelectedDevices(targetDevices); + } + }, [model, volumeGroup, allDevices]); + + const updateName = (_, value) => setName(value); + + const updateSelectedDevices = (value) => { + setSelectedDevices( + selectedDevices.includes(value) + ? selectedDevices.filter((d) => d !== value) + : [...selectedDevices, value], + ); + }; + + const checkErrors = (): string[] => { + return [vgNameError(name, model, volumeGroup), targetDevicesError(selectedDevices)].filter( + (e) => e, + ); + }; + + const onSubmit = (e) => { + e.preventDefault(); + + const errors = checkErrors(); + setErrors(errors); + + if (errors.length) return; + + const data: data.VolumeGroup = { + vgName: name, + targetDevices: selectedDevices.map((d) => d.name), + }; + + if (!volumeGroup) { + addVolumeGroup(data, moveMountPoints); + } else { + editVolumeGroup(volumeGroup.vgName, data); + } + + navigate(PATHS.root); + }; + + return ( + + + {_("Configure LVM Volume Group")} + + + +
+ {errors.length > 0 && ( + + {errors.map((e, i) => ( +

{e}

+ ))} +
+ )} + + + + + + {_( + "The needed LVM physical volumes will be added as partitions on the chosen disks, \ + based on the sizes of the logical volumes. If you select more than one disk, the \ + physical volumes may be distributed along several disks.", + )} + + + {allDevices.map((device) => ( + {deviceLabel(device)}} + description={ + + + {typeDescription(device)} {contentDescription(device)} + + {filesystemLabels(device).map((s, i) => ( + + ))} + {device.systems.map((s, i) => ( + + ))} + + } + isChecked={selectedDevices.includes(device)} + onChange={() => updateSelectedDevices(device)} + /> + ))} + + + {!volumeGroup && ( + + setMoveMountPoints(v)} + /> + + )} + + + + +
+
+
+ ); +} diff --git a/web/src/components/storage/MountPathMenuItem.tsx b/web/src/components/storage/MountPathMenuItem.tsx new file mode 100644 index 0000000000..4a667652d2 --- /dev/null +++ b/web/src/components/storage/MountPathMenuItem.tsx @@ -0,0 +1,72 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { useNavigate } from "react-router-dom"; +import * as partitionUtils from "~/components/storage/utils/partition"; +import { Icon } from "~/components/layout"; +import { MenuItem, MenuItemAction } from "@patternfly/react-core"; +import { apiModel } from "~/api/storage/types"; + +export type MountPathMenuItemProps = { + device: apiModel.Partition | apiModel.LogicalVolume; + editPath?: string; + deleteFn?: () => void; +}; + +export default function MountPathMenuItem({ + device, + editPath = undefined, + deleteFn = undefined, +}: MountPathMenuItemProps) { + const navigate = useNavigate(); + const mountPath = device.mountPath; + const description = device ? partitionUtils.typeWithSize(device) : null; + + return ( + + } + actionId={`edit-${mountPath}`} + aria-label={`Edit ${mountPath}`} + onClick={() => editPath && navigate(editPath)} + /> + } + actionId={`delete-${mountPath}`} + aria-label={`Delete ${mountPath}`} + onClick={deleteFn} + /> + + } + > + {mountPath} + + ); +} diff --git a/web/src/components/storage/PartitionPage.test.tsx b/web/src/components/storage/PartitionPage.test.tsx index a9a940f9b4..a520f89bf5 100644 --- a/web/src/components/storage/PartitionPage.test.tsx +++ b/web/src/components/storage/PartitionPage.test.tsx @@ -25,7 +25,7 @@ import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import PartitionPage from "./PartitionPage"; import { StorageDevice } from "~/types/storage"; -import { configModel, Volume } from "~/api/storage/types"; +import { apiModel, Volume } from "~/api/storage/types"; import { gib } from "./utils"; jest.mock("~/queries/issues", () => ({ @@ -76,7 +76,7 @@ const sda: StorageDevice = { description: "", }; -const mockDrive: configModel.Drive = { +const mockDrive: apiModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [ @@ -99,7 +99,7 @@ const mockDrive: configModel.Drive = { ], }; -const mockSolvedConfigModel: configModel.Config = { +const mockSolvedConfigModel: apiModel.Config = { drives: [mockDrive], }; @@ -151,7 +151,7 @@ beforeEach(() => { describe("PartitionPage", () => { it("renders a form for defining a partition", async () => { const { user } = installerRender(); - screen.getByRole("form", { name: "Define partition at /dev/sda" }); + screen.getByRole("form", { name: "Configure partition at /dev/sda" }); const mountPoint = screen.getByRole("button", { name: "Mount point toggle" }); const mountPointMode = screen.getByRole("button", { name: "Mount point mode" }); const filesystem = screen.getByRole("button", { name: "File system" }); diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 135bfe2705..fe569848d2 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -46,6 +46,7 @@ import { import { NestedContent, Page, SelectWrapper as Select, SubtleContent } from "~/components/core/"; import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; +import AutoSizeText from "~/components/storage/AutoSizeText"; import { useDevices, useVolume } from "~/queries/storage"; import { useModel, @@ -63,9 +64,9 @@ import { filesystemLabel, parseToBytes, } from "~/components/storage/utils"; -import { _, formatList } from "~/i18n"; +import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { STORAGE as PATHS } from "~/routes/paths"; import { compact, uniq } from "~/utils"; @@ -101,14 +102,14 @@ type ErrorsHandler = { getVisibleError: (id: string) => Error | undefined; }; -function toPartitionConfig(value: FormValue): configModel.Partition { +function toPartitionConfig(value: FormValue): apiModel.Partition { const name = (): string | undefined => { if (value.target === NO_VALUE || value.target === NEW_PARTITION) return undefined; return value.target; }; - const filesystemType = (): configModel.FilesystemType | undefined => { + const filesystemType = (): apiModel.FilesystemType | undefined => { if (value.filesystem === NO_VALUE) return undefined; if (value.filesystem === BTRFS_SNAPSHOTS) return "btrfs"; @@ -119,10 +120,10 @@ function toPartitionConfig(value: FormValue): configModel.Partition { * This will be fixed in the future by directly exporting the volumes as a JSON, similar to the * config model. The schema for the volumes will define the explicit list of filesystem types. */ - return value.filesystem as configModel.FilesystemType; + return value.filesystem as apiModel.FilesystemType; }; - const filesystem = (): configModel.Filesystem | undefined => { + const filesystem = (): apiModel.Filesystem | undefined => { if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true, default: true }; const type = filesystemType(); @@ -136,7 +137,7 @@ function toPartitionConfig(value: FormValue): configModel.Partition { }; }; - const size = (): configModel.Size | undefined => { + const size = (): apiModel.Size | undefined => { if (value.sizeOption === "auto") return undefined; if (value.minSize === NO_VALUE) return undefined; @@ -155,7 +156,7 @@ function toPartitionConfig(value: FormValue): configModel.Partition { }; } -function toFormValue(partitionConfig: configModel.Partition): FormValue { +function toFormValue(partitionConfig: apiModel.Partition): FormValue { const mountPoint = (): string => partitionConfig.mountPath || NO_VALUE; const target = (): string => partitionConfig.name || NEW_PARTITION; @@ -219,7 +220,7 @@ function useDefaultFilesystem(mountPoint: string): string { return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; } -function useInitialPartitionConfig(): configModel.Partition | null { +function useInitialPartitionConfig(): apiModel.Partition | null { const { partitionId: mountPath } = useParams(); const device = useDevice(); const drive = useDrive(device?.name); @@ -298,7 +299,7 @@ function useMountPointError(value: FormValue): Error | undefined { }; } - const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; + const regex = /^swap$|^\/$|^(\/[^/\s]+)+$/; if (!regex.test(mountPoint)) { return { id: "mountPoint", @@ -383,7 +384,7 @@ function useErrors(value: FormValue): ErrorsHandler { return { errors, getError, getVisibleError }; } -function useSolvedModel(value: FormValue): configModel.Config | null { +function useSolvedModel(value: FormValue): apiModel.Config | null { const device = useDevice(); const model = useConfigModel(); const { errors } = useErrors(value); @@ -392,7 +393,7 @@ function useSolvedModel(value: FormValue): configModel.Config | null { partitionConfig.size = undefined; if (partitionConfig.filesystem) partitionConfig.filesystem.label = undefined; - let sparseModel: configModel.Config | undefined; + let sparseModel: apiModel.Config | undefined; if (device && !errors.length && value.target === NEW_PARTITION && value.filesystem !== NO_VALUE) { /** @@ -417,7 +418,7 @@ function useSolvedModel(value: FormValue): configModel.Config | null { return solvedModel; } -function useSolvedPartitionConfig(value: FormValue): configModel.Partition | undefined { +function useSolvedPartitionConfig(value: FormValue): apiModel.Partition | undefined { const model = useSolvedModel(value); const device = useDevice(); const drive = model?.drives?.find((d) => d.name === device.name); @@ -719,265 +720,6 @@ function SizeOptions({ mountPoint, target }: SizeOptionsProps): React.ReactNode ); } -function AutoSizeTextFallback({ size }) { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %s is a size with units, like "3 GiB" - _("A generic size of %s will be used for the new partition"), - deviceSize(size.min), - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size and %2$s is the max, both with units like "3 GiB" - _("A generic size range between %1$s and %2$s will be used for the new partition"), - deviceSize(size.min), - deviceSize(size.max), - ); - } - - return sprintf( - // TRANSLATORS: %s is a size with units, like "3 GiB" - _("A generic minimum size of %s will be used for the new partition"), - deviceSize(size.min), - ); -} - -function AutoSizeTextFixed({ path, size }) { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _("A partition of %1$s will be created for %2$s"), - deviceSize(size.min), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size, %2$s is the max size and %3$s is a mount path - _("A partition with a size between %1$s and %2$s will be created for %3$s"), - deviceSize(size.min), - deviceSize(size.max), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _("A partition of at least %1$s will be created for %2$s"), - deviceSize(size.min), - path, - ); -} - -function AutoSizeTextRam({ path, size }) { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _("Based on the amount of RAM in the system, a partition of %1$s will be created for %2$s"), - deviceSize(size.min), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size, %2$s is the max size and %3$s is a mount path - _( - "Based on the amount of RAM in the system, a partition with a size between %1$s and %2$s will be created for %3$s", - ), - deviceSize(size.min), - deviceSize(size.max), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _( - "Based on the amount of RAM in the system a partition of at least %1$s will be created for %2$s", - ), - deviceSize(size.min), - path, - ); -} - -function AutoSizeTextDynamic({ volume, size }) { - const introText = (volume) => { - const path = volume.mountPath; - const otherPaths = volume.outline.sizeRelevantVolumes || []; - const snapshots = !!volume.outline.snapshotsAffectSizes; - const ram = !!volume.outline.adjustByRam; - - if (ram && snapshots) { - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - if (otherPaths.length > 1) { - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - return sprintf( - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - } - - return sprintf( - // TRANSLATORS: %s is a mount point (eg. /) - _( - "The size range for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.", - ), - path, - ); - } - - if (ram) { - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - } - - if (snapshots) { - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - if (otherPaths.length > 1) { - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - return sprintf( - _( - "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - } - - return sprintf( - // TRANSLATORS: %s is a mount point (eg. /) - _( - "The size range for %s will be dynamically adjusted based on the usage of Btrfs snapshots.", - ), - path, - ); - } - - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - _( - "The size range for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - }; - - const limitsText = (size) => { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %s is a size with units (eg. 10 GiB) - _("The current configuration will result in a partition of %s."), - deviceSize(size.min), - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size, %2$s is the max size - _( - "The current configuration will result in a partition with a size between %1$s and %2$s.", - ), - deviceSize(size.min), - deviceSize(size.max), - ); - } - - return sprintf( - // TRANSLATORS: %s is a size with units (eg. 10 GiB) - _("The current configuration will result in a partition of at least %s."), - deviceSize(size.min), - ); - }; - - return ( - <> - {introText(volume)} - {limitsText(size)} - - ); -} - -function AutoSizeText({ volume, size }) { - const path = volume.mountPath; - - if (path) { - if (volume.autoSize) { - const otherPaths = volume.outline.sizeRelevantVolumes || []; - - if (otherPaths.length || volume.outline.snapshotsAffectSizes) { - return ; - } - - // This assumes volume.autoSize is correctly set. Ie. if it is set to true then at least one - // of the relevant outline fields (snapshots, RAM and sizeRelevantVolumes) is used. - return ; - } - - return ; - } - - // Fallback volume - // This assumes the fallback volume never uses automatic sizes (ie. re-calculated based on - // other aspects of the configuration). It would be VERY surprising if that's the case. - return ; -} - type AutoSizeInfoProps = { value: FormValue; }; @@ -991,7 +733,7 @@ function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { return ( - + ); } @@ -1136,6 +878,10 @@ function CustomSize({ value, onChange }: CustomSizeProps) { ); } +/** + * @fixme This component has to be adapted to use the new hooks from ~/hooks/storage/ instead of the + * deprecated hooks from ~/queries/storage/config-model. + */ export default function PartitionPage() { const navigate = useNavigate(); const headingId = useId(); @@ -1244,7 +990,7 @@ export default function PartitionPage() { - {sprintf(_("Define partition at %s"), device.name)} + {sprintf(_("Configure partition at %s"), device.name)} diff --git a/web/src/components/storage/ProposalFailedInfo.tsx b/web/src/components/storage/ProposalFailedInfo.tsx index b5af24c369..41b8c1e37e 100644 --- a/web/src/components/storage/ProposalFailedInfo.tsx +++ b/web/src/components/storage/ProposalFailedInfo.tsx @@ -29,7 +29,7 @@ import { IssueSeverity } from "~/types/issues"; import * as partitionUtils from "~/components/storage/utils/partition"; import { sprintf } from "sprintf-js"; -function Description({ partitions }) { +function Description({ partitions, booting }) { const newPartitions = partitions.filter((p) => !p.name); if (!newPartitions.length) { @@ -43,17 +43,29 @@ function Description({ partitions }) { } const mountPaths = newPartitions.map((p) => partitionUtils.pathWithSize(p)); - const msg1 = sprintf( - // TRANSLATORS: %s is a list of formatted mount points with a partition size like - // '"/" (at least 10 GiB), "/var" (20 GiB) and "swap" (2 GiB)' - // (or a single mount point in the singular case). - n_( - "It is not possible to allocate the requested partition for %s.", - "It is not possible to allocate the requested partitions for %s.", - mountPaths.length, - ), - formatList(mountPaths), - ); + const msg1 = booting + ? sprintf( + // TRANSLATORS: %s is a list of formatted mount points with a partition size like + // '"/" (at least 10 GiB), "/var" (20 GiB) and "swap" (2 GiB)' + // (or a single mount point in the singular case). + n_( + "It is not possible to allocate the requested partitions for booting and for %s.", + "It is not possible to allocate the requested partitions for booting, %s.", + mountPaths.length, + ), + formatList(mountPaths), + ) + : sprintf( + // TRANSLATORS: %s is a list of formatted mount points with a partition size like + // '"/" (at least 10 GiB), "/var" (20 GiB) and "swap" (2 GiB)' + // (or a single mount point in the singular case). + n_( + "It is not possible to allocate the requested partition for %s.", + "It is not possible to allocate the requested partitions for %s.", + mountPaths.length, + ), + formatList(mountPaths), + ); return ( <> @@ -78,10 +90,11 @@ export default function ProposalFailedInfo() { if (!errors.length) return; const modelPartitions = model.drives.flatMap((d) => d.partitions || []); + const booting = !!model.boot?.configure; return ( - + ); } diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index ce0b3dc426..a089cf5416 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -106,7 +106,7 @@ jest.mock("./ProposalFailedInfo", () => () =>
failed info
); jest.mock("./UnsupportedModelInfo", () => () =>
unsupported info
); jest.mock("./ProposalResultSection", () => () =>
result
); jest.mock("./ConfigEditor", () => () =>
installation devices
); -jest.mock("./AddExistingDeviceMenu", () => () =>
add device menu
); +jest.mock("./ConfigureDeviceMenu", () => () =>
add device menu
); jest.mock("./ConfigEditorMenu", () => () =>
config editor menu
); jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
registration alert
diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 9849307c67..353abc0a2f 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -36,14 +36,15 @@ import { } from "@patternfly/react-core"; import { Page, Link } from "~/components/core/"; import { Icon, Loading } from "~/components/layout"; +import ConfigEditor from "./ConfigEditor"; +import ConfigEditorMenu from "./ConfigEditorMenu"; +import ConfigureDeviceMenu from "./ConfigureDeviceMenu"; +import EncryptionSection from "./EncryptionSection"; +import FixableConfigInfo from "./FixableConfigInfo"; +import ProposalFailedInfo from "./ProposalFailedInfo"; import ProposalResultSection from "./ProposalResultSection"; import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; -import ProposalFailedInfo from "./ProposalFailedInfo"; -import FixableConfigInfo from "./FixableConfigInfo"; import UnsupportedModelInfo from "./UnsupportedModelInfo"; -import ConfigEditor from "./ConfigEditor"; -import ConfigEditorMenu from "./ConfigEditorMenu"; -import AddExistingDeviceMenu from "./AddExistingDeviceMenu"; import { useAvailableDevices, useResetConfigMutation, @@ -57,7 +58,6 @@ import { useDASDSupported } from "~/queries/storage/dasd"; import { useSystemErrors, useConfigErrors } from "~/queries/issues"; import { STORAGE as PATHS } from "~/routes/paths"; import { _, n_ } from "~/i18n"; -import EncryptionSection from "./EncryptionSection"; function InvalidConfigEmptyState(): React.ReactNode { const errors = useConfigErrors("storage"); @@ -198,10 +198,10 @@ function ProposalSections(): React.ReactNode { actions={ <> - + - + } @@ -237,7 +237,7 @@ export default function ProposalPage(): React.ReactNode { if (isDeprecated) reprobe().catch(console.log); }, [isDeprecated, reprobe]); - const fixable = ["no_root", "required_filesystems"]; + const fixable = ["no_root", "required_filesystems", "vg_target_devices"]; const unfixableErrors = configErrors.filter((e) => !fixable.includes(e.kind)); const isModelEditable = model && !unfixableErrors.length; const hasDevices = !!availableDevices.length; diff --git a/web/src/components/storage/SpaceActionsTable.test.tsx b/web/src/components/storage/SpaceActionsTable.test.tsx index 1de9f9d53b..71c6823789 100644 --- a/web/src/components/storage/SpaceActionsTable.test.tsx +++ b/web/src/components/storage/SpaceActionsTable.test.tsx @@ -28,7 +28,7 @@ import { deviceChildren, gib } from "~/components/storage/utils"; import { plainRender } from "~/test-utils"; import SpaceActionsTable, { SpaceActionsTableProps } from "~/components/storage/SpaceActionsTable"; import { StorageDevice } from "~/types/storage"; -import * as configModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; const sda: StorageDevice = { sid: 59, @@ -81,7 +81,7 @@ sda.partitionTable = { unusedSlots: [{ start: 3, size: gib(2) }], }; -const mockDrive: configModel.Drive = { +const mockDrive: apiModel.Drive = { name: "/dev/sda", partitions: [ { diff --git a/web/src/components/storage/SpaceActionsTable.tsx b/web/src/components/storage/SpaceActionsTable.tsx index 7dd5221255..d4b97c3f26 100644 --- a/web/src/components/storage/SpaceActionsTable.tsx +++ b/web/src/components/storage/SpaceActionsTable.tsx @@ -43,18 +43,18 @@ import { } from "~/components/storage/device-utils"; import { Icon } from "~/components/layout"; import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { TreeTableColumn } from "~/components/core/TreeTable"; import { Table, Td, Th, Tr, Thead, Tbody } from "@patternfly/react-table"; import { useConfigModel } from "~/queries/storage/config-model"; -const isUsedPartition = (partition: configModel.Partition): boolean => { - return partition.filesystem !== undefined || partition.alias !== undefined; +const isUsedPartition = (partition: apiModel.Partition): boolean => { + return partition.filesystem !== undefined; }; // FIXME: there is too much logic here. This is one of those cases that should be considered // when restructuring the hooks and queries. -const useReusedPartition = (name: string): configModel.Partition | undefined => { +const useReusedPartition = (name: string): apiModel.Partition | undefined => { const model = useConfigModel(); if (!model || !name) return; diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index d43d607484..b9960b2750 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -28,14 +28,14 @@ import { SpaceActionsTable } from "~/components/storage"; import { baseName, deviceChildren } from "~/components/storage/utils"; import { _ } from "~/i18n"; import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; 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"; -const partitionAction = (partition: configModel.Partition) => { +const partitionAction = (partition: apiModel.Partition) => { if (partition.delete) return "delete"; if (partition.resizeIfNeeded) return "resizeIfNeeded"; diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx new file mode 100644 index 0000000000..f13487f829 --- /dev/null +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -0,0 +1,166 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { useNavigate, generatePath } from "react-router-dom"; +import { _ } from "~/i18n"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { apiModel } from "~/api/storage/types"; +import { model } from "~/types/storage"; +import { contentDescription } from "~/components/storage/utils/volume-group"; +import { useVolumeGroup, useDeleteVolumeGroup } from "~/hooks/storage/volume-group"; +import { useDeleteLogicalVolume } from "~/hooks/storage/logical-volume"; +import DeviceMenu from "~/components/storage/DeviceMenu"; +import DeviceHeader from "~/components/storage/DeviceHeader"; +import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; +import { + Card, + CardBody, + CardHeader, + CardTitle, + Divider, + Flex, + MenuItem, + MenuList, +} from "@patternfly/react-core"; + +import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +const DeleteVgOption = ({ vg }: { vg: model.VolumeGroup }) => { + const deleteVolumeGroup = useDeleteVolumeGroup(); + + return ( + deleteVolumeGroup(vg.vgName)} + > + {_("Delete volume group")} + + ); +}; + +const EditVgOption = ({ vg }: { vg: model.VolumeGroup }) => { + const navigate = useNavigate(); + + return ( + navigate(generatePath(PATHS.volumeGroup.edit, { id: vg.vgName }))} + > + {_("Edit volume group")} + + ); +}; + +const VgMenu = ({ vg }: { vg: model.VolumeGroup }) => { + return ( + {vg.vgName}}> + + + + + + ); +}; + +const VgHeader = ({ vg }: { vg: model.VolumeGroup }) => { + const title = vg.logicalVolumes.length + ? _("Create LVM volume group %s") + : _("Empty LVM volume group %s"); + + return ( + + + + ); +}; + +const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { + const navigate = useNavigate(); + const deleteLogicalVolume = useDeleteLogicalVolume(); + + const editPath = (lv: model.LogicalVolume): string => { + return generatePath(PATHS.volumeGroup.logicalVolume.edit, { + id: vg.vgName, + logicalVolumeId: encodeURIComponent(lv.mountPath), + }); + }; + const deleteLv = (lv: model.LogicalVolume) => deleteLogicalVolume(vg.vgName, lv.mountPath); + + return ( + {contentDescription(vg)}} + ariaLabel={_("Logical volumes")} + > + + {vg.logicalVolumes.map((lv) => { + return ( + deleteLv(lv)} + /> + ); + })} + {vg.logicalVolumes.length > 0 && } + + navigate(generatePath(PATHS.volumeGroup.logicalVolume.add, { id: vg.vgName })) + } + > + {_("Add logical volume")} + + + + ); +}; + +export type VolumeGroupEditorProps = { vg: apiModel.VolumeGroup }; + +export default function VolumeGroupEditor({ vg }: VolumeGroupEditorProps) { + const volumeGroup = useVolumeGroup(vg.vgName); + + return ( + + + + + + + + + + + + + ); +} diff --git a/web/src/components/storage/utils.ts b/web/src/components/storage/utils.ts index f2c37b76fb..39b31bc7a0 100644 --- a/web/src/components/storage/utils.ts +++ b/web/src/components/storage/utils.ts @@ -30,7 +30,7 @@ import xbytes from "xbytes"; import { _, N_ } from "~/i18n"; import { PartitionSlot, StorageDevice } from "~/types/storage"; -import { configModel, Volume } from "~/api/storage/types"; +import { apiModel, Volume } from "~/api/storage/types"; import { sprintf } from "sprintf-js"; /** @@ -293,7 +293,7 @@ const filesystemLabel = (fstype: string): string => { * * @returns undefined if there is not enough information */ -const filesystemType = (filesystem: configModel.Filesystem): string | undefined => { +const filesystemType = (filesystem: apiModel.Filesystem): string | undefined => { if (filesystem.type) { if (filesystem.snapshots) return _("Btrfs with snapshots"); @@ -321,7 +321,7 @@ const formattedPath = (path: string): string => { /** * Representation of the given size limits. */ -const sizeDescription = (size: configModel.Size): string => { +const sizeDescription = (size: apiModel.Size): string => { const minSize = deviceSize(size.min); const maxSize = size.max ? deviceSize(size.max) : undefined; diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index a4c3342444..b4f90e98d1 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,23 +20,19 @@ * find current contact information at www.suse.com. */ -// @ts-check - import { _, n_, formatList } from "~/i18n"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { SpacePolicy, SPACE_POLICIES, baseName, formattedPath } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; /** * String to identify the drive. */ -const label = (drive: configModel.Drive): string => { - if (drive.alias) return drive.alias; - +const label = (drive: apiModel.Drive): string => { return baseName(drive.name); }; -const spacePolicyEntry = (drive: configModel.Drive): SpacePolicy => { +const spacePolicyEntry = (drive: apiModel.Drive): SpacePolicy => { return SPACE_POLICIES.find((p) => p.id === drive.spacePolicy); }; @@ -74,7 +70,7 @@ const resizeTextFor = (partitions) => { * FIXME: the case with two sentences looks a bit weird. But trying to summarize everything in one * sentence was too hard. */ -const contentActionsDescription = (drive: configModel.Drive): string => { +const contentActionsDescription = (drive: apiModel.Drive): string => { const policyLabel = spacePolicyEntry(drive).summaryLabel; // eslint-disable-next-line agama-i18n/string-literals @@ -102,7 +98,7 @@ const contentActionsDescription = (drive: configModel.Drive): string => { * FIXME: right now, this considers only the case in which the drive is going to host some formatted * partitions. */ -const contentDescription = (drive: configModel.Drive): string => { +const contentDescription = (drive: apiModel.Drive): string => { const newPartitions = drive.partitions.filter((p) => !p.name); const reusedPartitions = drive.partitions.filter((p) => p.name && p.mountPath); @@ -145,26 +141,19 @@ const contentDescription = (drive: configModel.Drive): string => { return sprintf(_("Partitions will be used and created for %s"), formatList(mountPaths)); }; -const hasFilesystem = (drive: configModel.Drive): boolean => { +const hasFilesystem = (drive: apiModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath); }; -const hasRoot = (drive: configModel.Drive): boolean => { +const hasRoot = (drive: apiModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath && p.mountPath === "/"); }; -const hasReuse = (drive: configModel.Drive): boolean => { +const hasReuse = (drive: apiModel.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 false; -}; - export { - hasPv, hasReuse, hasFilesystem, hasRoot, diff --git a/web/src/components/storage/utils/partition.tsx b/web/src/components/storage/utils/partition.tsx index 8dff68878b..fc936d726c 100644 --- a/web/src/components/storage/utils/partition.tsx +++ b/web/src/components/storage/utils/partition.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,17 +20,21 @@ * find current contact information at www.suse.com. */ -// @ts-check +/** + * @fixme This utils file smells. It accepts both partition and logical volume, but the file is + * called partition.tsx. Moreover, some logic (e.g., checking whether a file system is reused) can + * be moved to the model hook. + */ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { filesystemType, formattedPath, sizeDescription } from "~/components/storage/utils"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; /** - * String to identify the drive. + * String to identify the partition. */ -const pathWithSize = (partition: configModel.Partition): string => { +const pathWithSize = (partition: apiModel.Partition): string => { return sprintf( // TRANSLATORS: %1$s is an already formatted mount path (eg. "/"), // %2$s is a size description (eg. at least 10 GiB) @@ -41,12 +45,22 @@ const pathWithSize = (partition: configModel.Partition): string => { }; /** - * String to identify the type of partition to be created (or used). + * @fixme Workaround to make possible to distinguish between partition and logical volume. Note that + * a logical volume has not the property 'name' yet, see {@link typeDescription}. + */ +function isPartition( + device: apiModel.Partition | apiModel.LogicalVolume, +): device is apiModel.Partition { + return Object.hasOwn(device, "name"); +} + +/** + * String to identify the type of device to be created (or used). */ -const typeDescription = (partition: configModel.Partition): string => { +const typeDescription = (partition: apiModel.Partition | apiModel.LogicalVolume): string => { const fs = filesystemType(partition.filesystem); - if (partition.name) { + if (isPartition(partition) && partition.name) { if (partition.filesystem.reuse) { // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a device name (eg. /dev/sda3). if (fs) return sprintf(_("Current %1$s at %2$s"), fs, partition.name); @@ -64,7 +78,7 @@ const typeDescription = (partition: configModel.Partition): string => { /** * Combination of {@link typeDescription} and the size of the target partition. */ -const typeWithSize = (partition: configModel.Partition): string => { +const typeWithSize = (partition: apiModel.Partition | apiModel.LogicalVolume): string => { return sprintf( // TRANSLATORS: %1$s is a filesystem type description (eg. "Btrfs with snapshots"), // %2$s is a description of the size or the size limits (eg. "at least 10 GiB") @@ -74,4 +88,4 @@ const typeWithSize = (partition: configModel.Partition): string => { ); }; -export { pathWithSize, typeDescription, typeWithSize }; +export { pathWithSize, typeWithSize }; diff --git a/web/src/components/storage/utils/volume-group.tsx b/web/src/components/storage/utils/volume-group.tsx new file mode 100644 index 0000000000..91b1dd2766 --- /dev/null +++ b/web/src/components/storage/utils/volume-group.tsx @@ -0,0 +1,44 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { _, n_, formatList } from "~/i18n"; +import { model } from "~/types/storage"; +import { formattedPath } from "~/components/storage/utils"; +import { sprintf } from "sprintf-js"; + +const contentDescription = (vg: model.VolumeGroup): string => { + if (vg.logicalVolumes.length === 0) return _("No logical volumes are defined yet"); + + const mountPaths = vg.logicalVolumes.map((v) => formattedPath(v.mountPath)); + return sprintf( + // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a + // single mount point in the singular case). + n_( + "A new logical volume will be created for %s", + "New logical volumes will be created for %s", + mountPaths.length, + ), + formatList(mountPaths), + ); +}; + +export { contentDescription }; diff --git a/web/src/helpers/storage/api-model.ts b/web/src/helpers/storage/api-model.ts new file mode 100644 index 0000000000..491d550772 --- /dev/null +++ b/web/src/helpers/storage/api-model.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { apiModel } from "~/api/storage/types"; +import { data } from "~/types/storage"; + +function copyApiModel(apiModel: apiModel.Config): apiModel.Config { + return JSON.parse(JSON.stringify(apiModel)); +} + +function buildFilesystem(data?: data.Filesystem): apiModel.Filesystem | undefined { + if (!data) return; + + return { + ...data, + default: false, + }; +} + +function buildSize(data?: data.Size): apiModel.Size | undefined { + if (!data) return; + + return { + ...data, + default: false, + min: data.min || 0, + }; +} + +function buildVolumeGroup(data: data.VolumeGroup): apiModel.VolumeGroup { + const defaultVolumeGroup = { vgName: "system", targetDevices: [] }; + return { ...defaultVolumeGroup, ...data }; +} + +function buildLogicalVolume(data: data.LogicalVolume): apiModel.LogicalVolume { + return { + ...data, + filesystem: buildFilesystem(data.filesystem), + size: buildSize(data.size), + }; +} + +function buildLogicalVolumeName(mountPath?: string): string | undefined { + if (!mountPath) return; + + return mountPath === "/" ? "root" : mountPath.split("/").pop(); +} + +function buildLogicalVolumeFromPartition(partition: apiModel.Partition): apiModel.LogicalVolume { + return { + ...partition, + lvName: buildLogicalVolumeName(partition.mountPath), + }; +} + +export { + copyApiModel, + buildVolumeGroup, + buildLogicalVolume, + buildLogicalVolumeName, + buildLogicalVolumeFromPartition, +}; diff --git a/web/src/helpers/storage/drive.ts b/web/src/helpers/storage/drive.ts new file mode 100644 index 0000000000..e17e546d17 --- /dev/null +++ b/web/src/helpers/storage/drive.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { apiModel } from "~/api/storage/types"; +import { model } from "~/types/storage"; +import { copyApiModel } from "~/helpers/storage/api-model"; +import { buildModel } from "~/helpers/storage/model"; + +function buildDrive(apiModel: apiModel.Config, name: string): model.Drive | undefined { + const model = buildModel(apiModel); + return model.drives.find((d) => d.name === name); +} + +function deleteIfUnused(apiModel: apiModel.Config, name: string): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const index = (apiModel.drives || []).findIndex((d) => d.name === name); + if (index === -1) return apiModel; + + const drive = buildDrive(apiModel, name); + if (!drive || drive.isUsed) return apiModel; + + apiModel.drives.splice(index, 1); + return apiModel; +} + +export { deleteIfUnused }; diff --git a/web/src/helpers/storage/logical-volume.ts b/web/src/helpers/storage/logical-volume.ts new file mode 100644 index 0000000000..2fbd711925 --- /dev/null +++ b/web/src/helpers/storage/logical-volume.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { apiModel } from "~/api/storage/types"; +import { copyApiModel, buildLogicalVolume } from "~/helpers/storage/api-model"; +import { data } from "~/types/storage"; + +function findVolumeGroupIndex(apiModel: apiModel.Config, vgName: string): number { + return (apiModel.volumeGroups || []).findIndex((v) => v.vgName === vgName); +} + +function findLogicalVolumeIndex(volumeGroup: apiModel.VolumeGroup, mountPath: string): number { + return (volumeGroup.logicalVolumes || []).findIndex((l) => l.mountPath === mountPath); +} + +function addLogicalVolume( + apiModel: apiModel.Config, + vgName: string, + data: data.LogicalVolume, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const vgIndex = findVolumeGroupIndex(apiModel, vgName); + if (vgIndex === -1) return apiModel; + + const volumeGroup = apiModel.volumeGroups[vgIndex]; + const logicalVolume = buildLogicalVolume(data); + + volumeGroup.logicalVolumes ||= []; + volumeGroup.logicalVolumes.push(logicalVolume); + + return apiModel; +} + +function editLogicalVolume( + apiModel: apiModel.Config, + vgName: string, + mountPath: string, + data: data.LogicalVolume, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const vgIndex = findVolumeGroupIndex(apiModel, vgName); + if (vgIndex === -1) return apiModel; + + const volumeGroup = apiModel.volumeGroups[vgIndex]; + + const lvIndex = findLogicalVolumeIndex(volumeGroup, mountPath); + if (lvIndex === -1) return apiModel; + + const oldLogicalVolume = volumeGroup.logicalVolumes[lvIndex]; + const newLogicalVolume = { ...oldLogicalVolume, ...buildLogicalVolume(data) }; + + volumeGroup.logicalVolumes.splice(lvIndex, 1, newLogicalVolume); + + return apiModel; +} + +function deleteLogicalVolume( + apiModel: apiModel.Config, + vgName: string, + mountPath: string, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const vgIndex = findVolumeGroupIndex(apiModel, vgName); + if (vgIndex === -1) return apiModel; + + const volumeGroup = apiModel.volumeGroups[vgIndex]; + + const lvIndex = findLogicalVolumeIndex(volumeGroup, mountPath); + if (lvIndex === -1) return apiModel; + + volumeGroup.logicalVolumes.splice(lvIndex, 1); + + return apiModel; +} + +export { addLogicalVolume, editLogicalVolume, deleteLogicalVolume }; diff --git a/web/src/helpers/storage/model.ts b/web/src/helpers/storage/model.ts new file mode 100644 index 0000000000..d5072a60ff --- /dev/null +++ b/web/src/helpers/storage/model.ts @@ -0,0 +1,135 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { apiModel } from "~/api/storage/types"; +import { model } from "~/types/storage"; + +const findDrive = (model: model.Model, name: string): model.Drive | undefined => { + return model.drives.find((d) => d.name === name); +}; + +function buildDrive( + apiDrive: apiModel.Drive, + apiModel: apiModel.Config, + model: model.Model, +): model.Drive { + const getMountPaths = (): string[] => { + const mountPaths = (apiDrive.partitions || []).map((p) => p.mountPath); + return [apiDrive.mountPath, ...mountPaths].filter((p) => p); + }; + + const getVolumeGroups = (): model.VolumeGroup[] => { + return model.volumeGroups.filter((v) => + v.getTargetDevices().some((d) => d.name === apiDrive.name), + ); + }; + + const isExplicitBoot = (): boolean => { + return ( + apiModel.boot?.configure && + !apiModel.boot.device?.default && + apiModel.boot.device?.name === apiDrive.name + ); + }; + + const isTargetDevice = (): boolean => { + const targetDevices = (apiModel.volumeGroups || []).flatMap((v) => v.targetDevices || []); + return targetDevices.includes(apiDrive.name); + }; + + const isUsed = (): boolean => { + return ( + isExplicitBoot() || + isTargetDevice() || + apiDrive.mountPath !== undefined || + apiDrive.partitions?.some((p) => p.mountPath) + ); + }; + + const isAddingPartitions = (): boolean => { + return (apiDrive.partitions || []).some((p) => p.mountPath && !p.name); + }; + + return { + ...apiDrive, + isUsed: isUsed(), + isAddingPartitions: isAddingPartitions(), + getMountPaths, + getVolumeGroups, + }; +} + +function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.LogicalVolume { + return { ...logicalVolumeData }; +} + +function buildVolumeGroup( + apiVolumeGroup: apiModel.VolumeGroup, + model: model.Model, +): model.VolumeGroup { + const getMountPaths = (): string[] => { + return (apiVolumeGroup.logicalVolumes || []).map((l) => l.mountPath).filter((p) => p); + }; + + const buildLogicalVolumes = (): model.LogicalVolume[] => { + return (apiVolumeGroup.logicalVolumes || []).map(buildLogicalVolume); + }; + + const getTargetDevices = (): model.Drive[] => { + return (apiVolumeGroup.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); + }; + + return { + ...apiVolumeGroup, + logicalVolumes: buildLogicalVolumes(), + getMountPaths, + getTargetDevices, + }; +} + +function buildModel(apiModel: apiModel.Config): model.Model { + const model: model.Model = { + drives: [], + volumeGroups: [], + getMountPaths: () => [], + }; + + const buildDrives = (): model.Drive[] => { + return (apiModel.drives || []).map((d) => buildDrive(d, apiModel, model)); + }; + + const buildVolumeGroups = (): model.VolumeGroup[] => { + return (apiModel.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); + }; + + const getMountPaths = (): string[] => { + return [...model.drives, ...model.volumeGroups].flatMap((d) => d.getMountPaths()); + }; + + // Important! Modify the model object instead of assigning a new one. + model.drives = buildDrives(); + model.volumeGroups = buildVolumeGroups(); + model.getMountPaths = getMountPaths; + return model; +} + +export { buildModel }; diff --git a/web/src/helpers/storage/volume-group.ts b/web/src/helpers/storage/volume-group.ts new file mode 100644 index 0000000000..1841f2aaf4 --- /dev/null +++ b/web/src/helpers/storage/volume-group.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { apiModel } from "~/api/storage/types"; +import { deleteIfUnused } from "~/helpers/storage/drive"; +import { + copyApiModel, + buildVolumeGroup, + buildLogicalVolumeFromPartition, +} from "~/helpers/storage/api-model"; +import { data } from "~/types/storage"; + +function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup) { + if (!drive.partitions) return; + + const newPartitions = drive.partitions.filter((p) => !p.name); + const reusedPartitions = drive.partitions.filter((p) => p.name); + drive.partitions = [...reusedPartitions]; + const logicalVolumes = volumeGroup.logicalVolumes || []; + volumeGroup.logicalVolumes = [ + ...logicalVolumes, + ...newPartitions.map(buildLogicalVolumeFromPartition), + ]; +} + +function addVolumeGroup( + apiModel: apiModel.Config, + data: data.VolumeGroup, + moveContent: boolean, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const volumeGroup = buildVolumeGroup(data); + + if (moveContent) { + (apiModel.drives || []) + .filter((d) => data.targetDevices.includes(d.name)) + .forEach((d) => movePartitions(d, volumeGroup)); + } + + apiModel.volumeGroups ||= []; + apiModel.volumeGroups.push(volumeGroup); + + return apiModel; +} + +function editVolumeGroup( + apiModel: apiModel.Config, + vgName: string, + data: data.VolumeGroup, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === vgName); + if (index === -1) return apiModel; + + const oldVolumeGroup = apiModel.volumeGroups[index]; + const newVolumeGroup = { ...oldVolumeGroup, ...buildVolumeGroup(data) }; + + apiModel.volumeGroups.splice(index, 1, newVolumeGroup); + (oldVolumeGroup.targetDevices || []).forEach((d) => { + apiModel = deleteIfUnused(apiModel, d); + }); + + return apiModel; +} + +function deleteVolumeGroup(apiModel: apiModel.Config, vgName: string): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === vgName); + if (index === -1) return apiModel; + + const targetDevices = apiModel.volumeGroups[index].targetDevices || []; + + apiModel.volumeGroups.splice(index, 1); + targetDevices.forEach((d) => { + apiModel = deleteIfUnused(apiModel, d); + }); + + return apiModel; +} + +export { addVolumeGroup, editVolumeGroup, deleteVolumeGroup }; diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts new file mode 100644 index 0000000000..0610caef21 --- /dev/null +++ b/web/src/hooks/storage/api-model.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { useQuery, useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiModel } from "~/api/storage/types"; +import { apiModelQuery, solveApiModelQuery } from "~/queries/storage"; +import { QueryHookOptions } from "~/types/queries"; +import { setConfigModel } from "~/api/storage"; + +function useApiModel(options?: QueryHookOptions): apiModel.Config | null { + const query = apiModelQuery; + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(query); + return data || null; +} + +/** @todo Use a hash key from the model object as id for the query. */ +function useSolvedApiModel( + model?: apiModel.Config, + options?: QueryHookOptions, +): apiModel.Config | null { + const query = solveApiModelQuery(model); + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(query); + return data; +} + +type UpdateApiModelFn = (apiModel: apiModel.Config) => void; + +function useUpdateApiModel(): UpdateApiModelFn { + const queryClient = useQueryClient(); + const query = { + mutationFn: (apiModel: apiModel.Config) => setConfigModel(apiModel), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), + }; + + const { mutate } = useMutation(query); + return mutate; +} + +export { useApiModel, useSolvedApiModel, useUpdateApiModel }; +export type { UpdateApiModelFn }; diff --git a/web/src/hooks/storage/drive.ts b/web/src/hooks/storage/drive.ts new file mode 100644 index 0000000000..f571174c80 --- /dev/null +++ b/web/src/hooks/storage/drive.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { QueryHookOptions } from "~/types/queries"; +import { model } from "~/types/storage"; +import { useModel } from "~/hooks/storage/model"; + +function useDrive(name: string, options?: QueryHookOptions): model.Drive | null { + const model = useModel(options); + const drive = model?.drives?.find((d) => d.name === name); + return drive || null; +} + +export { useDrive }; diff --git a/web/src/hooks/storage/logical-volume.ts b/web/src/hooks/storage/logical-volume.ts new file mode 100644 index 0000000000..f5ce5c9715 --- /dev/null +++ b/web/src/hooks/storage/logical-volume.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { QueryHookOptions } from "~/types/queries"; +import { data } from "~/types/storage"; +import { + addLogicalVolume, + editLogicalVolume, + deleteLogicalVolume, +} from "~/helpers/storage/logical-volume"; + +type AddLogicalVolumeFn = (vgName: string, data: data.LogicalVolume) => void; + +function useAddLogicalVolume(options?: QueryHookOptions): AddLogicalVolumeFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, data: data.LogicalVolume) => { + updateApiModel(addLogicalVolume(apiModel, vgName, data)); + }; +} + +type EditLogicalVolumeFn = (vgName: string, mountPath: string, data: data.LogicalVolume) => void; + +function useEditLogicalVolume(options?: QueryHookOptions): EditLogicalVolumeFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, mountPath: string, data: data.LogicalVolume) => { + updateApiModel(editLogicalVolume(apiModel, vgName, mountPath, data)); + }; +} + +type DeleteLogicalVolumeFn = (vgName: string, mountPath: string) => void; + +function useDeleteLogicalVolume(options?: QueryHookOptions): DeleteLogicalVolumeFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, mountPath: string) => + updateApiModel(deleteLogicalVolume(apiModel, vgName, mountPath)); +} + +export { useAddLogicalVolume, useEditLogicalVolume, useDeleteLogicalVolume }; +export type { DeleteLogicalVolumeFn }; diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts new file mode 100644 index 0000000000..fcf27af15b --- /dev/null +++ b/web/src/hooks/storage/model.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { useMemo } from "react"; +import { useApiModel } from "~/hooks/storage/api-model"; +import { buildModel } from "~/helpers/storage/model"; +import { QueryHookOptions } from "~/types/queries"; +import { model } from "~/types/storage"; + +function useModel(options?: QueryHookOptions): model.Model | null { + const apiModel = useApiModel(options); + + const model = useMemo((): model.Model | null => { + return apiModel ? buildModel(apiModel) : null; + }, [apiModel]); + + return model; +} + +export { useModel }; diff --git a/web/src/hooks/storage/product.ts b/web/src/hooks/storage/product.ts new file mode 100644 index 0000000000..e7261848e3 --- /dev/null +++ b/web/src/hooks/storage/product.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { useMemo } from "react"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { QueryHookOptions } from "~/types/queries"; +import { ProductParams, Volume } from "~/api/storage/types"; +import { productParamsQuery, volumeQuery } from "~/queries/storage"; +import { useModel } from "~/hooks/storage/model"; + +function useProductParams(options?: QueryHookOptions): ProductParams { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(productParamsQuery); + return data; +} + +function useMissingMountPaths(options?: QueryHookOptions): string[] { + const productParams = useProductParams(options); + const model = useModel(); + + const missingMountPaths = useMemo(() => { + const currentMountPaths = model?.getMountPaths() || []; + return (productParams?.mountPoints || []).filter((p) => !currentMountPaths.includes(p)); + }, [productParams, model]); + + return missingMountPaths; +} + +function useVolume(mountPath: string, options?: QueryHookOptions): Volume { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(volumeQuery(mountPath)); + return data; +} + +export { useProductParams, useMissingMountPaths, useVolume }; diff --git a/web/src/hooks/storage/volume-group.ts b/web/src/hooks/storage/volume-group.ts new file mode 100644 index 0000000000..892e269041 --- /dev/null +++ b/web/src/hooks/storage/volume-group.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { addVolumeGroup, editVolumeGroup, deleteVolumeGroup } from "~/helpers/storage/volume-group"; +import { QueryHookOptions } from "~/types/queries"; +import { model, data } from "~/types/storage"; +import { useModel } from "~/hooks/storage/model"; + +function useVolumeGroup(vgName: string, options?: QueryHookOptions): model.VolumeGroup | null { + const model = useModel(options); + const volumeGroup = model?.volumeGroups?.find((v) => v.vgName === vgName); + return volumeGroup || null; +} + +type AddVolumeGroupFn = (data: data.VolumeGroup, moveContent: boolean) => void; + +function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (data: data.VolumeGroup, moveContent: boolean) => { + updateApiModel(addVolumeGroup(apiModel, data, moveContent)); + }; +} + +type EditVolumeGroupFn = (vgName: string, data: data.VolumeGroup) => void; + +function useEditVolumeGroup(options?: QueryHookOptions): EditVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, data: data.VolumeGroup) => { + updateApiModel(editVolumeGroup(apiModel, vgName, data)); + }; +} + +type DeleteVolumeGroupFn = (vgName: string) => void; + +function useDeleteVolumeGroup(options?: QueryHookOptions): DeleteVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string) => updateApiModel(deleteVolumeGroup(apiModel, vgName)); +} + +export { useVolumeGroup, useAddVolumeGroup, useEditVolumeGroup, useDeleteVolumeGroup }; +export type { AddVolumeGroupFn, EditVolumeGroupFn, DeleteVolumeGroupFn }; diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index 59c8ba3094..2c54764a43 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -26,7 +26,10 @@ import { fetchConfig, setConfig, resetConfig, + fetchConfigModel, + solveConfigModel, fetchActions, + fetchVolume, fetchVolumes, fetchProductParams, fetchUsableDevices, @@ -34,8 +37,7 @@ import { } from "~/api/storage"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { useInstallerClient } from "~/context/installer"; -import { config, ProductParams, Volume } from "~/api/storage/types"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { config, apiModel, ProductParams, Volume } from "~/api/storage/types"; import { Action, StorageDevice } from "~/types/storage"; import { QueryHookOptions } from "~/types/queries"; @@ -45,6 +47,52 @@ const configQuery = { staleTime: Infinity, }; +const apiModelQuery = { + queryKey: ["storage", "apiModel"], + queryFn: fetchConfigModel, + staleTime: Infinity, +}; + +const solveApiModelQuery = (apiModel?: apiModel.Config) => ({ + queryKey: ["storage", "solveApiModel", JSON.stringify(apiModel)], + queryFn: () => (apiModel ? solveConfigModel(apiModel) : Promise.resolve(null)), + staleTime: Infinity, +}); + +const devicesQuery = (scope: "result" | "system") => ({ + queryKey: ["storage", "devices", scope], + queryFn: () => fetchDevices(scope), + staleTime: Infinity, +}); + +const productParamsQuery = { + queryKey: ["storage", "productParams"], + queryFn: fetchProductParams, + staleTime: Infinity, +}; + +const volumeQuery = (mountPath: string) => ({ + queryKey: ["storage", "volume", mountPath], + queryFn: () => fetchVolume(mountPath), + staleTime: Infinity, +}); + +const volumesQuery = (mountPaths: string[]) => ({ + queryKey: ["storage", "volumes"], + queryFn: () => fetchVolumes(mountPaths), + staleTime: Infinity, +}); + +const actionsQuery = { + queryKey: ["storage", "devices", "actions"], + queryFn: fetchActions, +}; + +const deprecatedQuery = { + queryKey: ["storage", "dirty"], + queryFn: fetchDevicesDirty, +}; + /** * Hook that returns the unsolved config. */ @@ -81,12 +129,6 @@ const useResetConfigMutation = () => { return useMutation(query); }; -const devicesQuery = (scope: "result" | "system") => ({ - queryKey: ["storage", "devices", scope], - queryFn: () => fetchDevices(scope), - staleTime: Infinity, -}); - /** * Hook that returns the list of storage devices for the given scope. * @@ -133,13 +175,8 @@ const useAvailableDevices = (): StorageDevice[] => { return data; }; -const productParamsQuery = { - queryKey: ["storage", "productParams"], - queryFn: fetchProductParams, - staleTime: Infinity, -}; - /** + * @deprecated Use useProductParams from ~/hooks/storage/product. * Hook that returns the product parameters (e.g., mount points). */ const useProductParams = (options?: QueryHookOptions): ProductParams => { @@ -154,10 +191,10 @@ const useProductParams = (options?: QueryHookOptions): ProductParams => { * @note The ids of the encryption methods reported by product params are different to the * EncryptionMethod values. This should be fixed at the bakcend size. */ -const useEncryptionMethods = (options?: QueryHookOptions): EncryptionMethod[] => { +const useEncryptionMethods = (options?: QueryHookOptions): apiModel.EncryptionMethod[] => { const productParams = useProductParams(options); - const encryptionMethods = React.useMemo((): EncryptionMethod[] => { + const encryptionMethods = React.useMemo((): apiModel.EncryptionMethod[] => { const conversions = { luks1: "luks1", luks2: "luks2", @@ -175,12 +212,6 @@ const useEncryptionMethods = (options?: QueryHookOptions): EncryptionMethod[] => return encryptionMethods; }; -const volumesQuery = (mountPaths: string[]) => ({ - queryKey: ["storage", "volumes"], - queryFn: () => fetchVolumes(mountPaths), - staleTime: Infinity, -}); - /** * Hook that returns the volumes for the current product. */ @@ -191,6 +222,7 @@ const useVolumes = (): Volume[] => { return data; }; +/** @deprecated Use useVolume from ~/hooks/storage/product. */ function useVolume(mountPoint: string): Volume { const volumes = useVolumes(); const volume = volumes.find((v) => v.mountPath === mountPoint); @@ -227,11 +259,6 @@ const useVolumeDevices = (): StorageDevice[] => { return [...availableDevices, ...mds, ...vgs]; }; -const actionsQuery = { - queryKey: ["storage", "devices", "actions"], - queryFn: fetchActions, -}; - /** * Hook that returns the actions to perform in the storage devices. */ @@ -240,11 +267,6 @@ const useActions = (): Action[] => { return data; }; -const deprecatedQuery = { - queryKey: ["storage", "dirty"], - queryFn: fetchDevicesDirty, -}; - /** * Hook that returns whether the storage devices are "dirty". */ @@ -287,6 +309,10 @@ const useReprobeMutation = () => { }; export { + productParamsQuery, + apiModelQuery, + solveApiModelQuery, + volumeQuery, useConfig, useConfigMutation, useResetConfigMutation, diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index a3cdbcfa1b..19d80faba2 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -20,45 +20,46 @@ * find current contact information at www.suse.com. */ +/** @deprecated These hooks will be replaced by new hooks at ~/hooks/storage/ */ + import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { fetchConfigModel, setConfigModel, solveConfigModel } from "~/api/storage"; -import { configModel, Volume } from "~/api/storage/types"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { setConfigModel, solveConfigModel } from "~/api/storage"; +import { apiModel, Volume } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; import { SpacePolicyAction } from "~/types/storage"; -import { useVolumes } from "~/queries/storage"; +import { apiModelQuery, useVolumes } from "~/queries/storage"; -function copyModel(model: configModel.Config): configModel.Config { +function copyModel(model: apiModel.Config): apiModel.Config { return JSON.parse(JSON.stringify(model)); } -function isNewPartition(partition: configModel.Partition): boolean { +function isNewPartition(partition: apiModel.Partition): boolean { return partition.name === undefined; } -function isSpacePartition(partition: configModel.Partition): boolean { +function isSpacePartition(partition: apiModel.Partition): boolean { return partition.resizeIfNeeded || partition.delete || partition.deleteIfNeeded; } -function isUsedPartition(partition: configModel.Partition): boolean { - return partition.filesystem !== undefined || partition.alias !== undefined; +function isUsedPartition(partition: apiModel.Partition): boolean { + return partition.filesystem !== undefined; } -function isReusedPartition(partition: configModel.Partition): boolean { - return !isNewPartition(partition) && isUsedPartition(partition) && !isSpacePartition(partition); +function isReusedPartition(partition: apiModel.Partition): boolean { + return !isNewPartition(partition) && isUsedPartition(partition); } -function findDrive(model: configModel.Config, driveName: string): configModel.Drive | undefined { +function findDrive(model: apiModel.Config, driveName: string): apiModel.Drive | undefined { const drives = model?.drives || []; return drives.find((d) => d.name === driveName); } -function removeDrive(model: configModel.Config, driveName: string): configModel.Config { +function removeDrive(model: apiModel.Config, driveName: string): apiModel.Config { model.drives = model.drives.filter((d) => d.name !== driveName); return model; } -function isUsedDrive(model: configModel.Config, driveName: string) { +function isUsedDrive(model: apiModel.Config, driveName: string) { const drive = findDrive(model, driveName); if (drive === undefined) return false; @@ -66,10 +67,10 @@ function isUsedDrive(model: configModel.Config, driveName: string) { } function findPartition( - model: configModel.Config, + model: apiModel.Config, driveName: string, mountPath: string, -): configModel.Partition | undefined { +): apiModel.Partition | undefined { const drive = findDrive(model, driveName); if (drive === undefined) return undefined; @@ -77,21 +78,27 @@ function findPartition( return partitions.find((p) => p.mountPath === mountPath); } -function isBoot(model: configModel.Config, driveName: string): boolean { +function isBoot(model: apiModel.Config, driveName: string): boolean { return model.boot?.configure && driveName === model.boot?.device?.name; } -function isExplicitBoot(model: configModel.Config, driveName: string): boolean { +function isExplicitBoot(model: apiModel.Config, driveName: string): boolean { return !model.boot?.device?.default && driveName === model.boot?.device?.name; } -function allMountPaths(drive: configModel.Drive): string[] { +function driveHasPv(model: apiModel.Config, name: string): boolean { + if (!name) return false; + + return model.volumeGroups.flatMap((g) => g.targetDevices).includes(name); +} + +function allMountPaths(drive: apiModel.Drive): string[] { if (drive.mountPath) return [drive.mountPath]; return drive.partitions.map((p) => p.mountPath).filter((m) => m); } -function configuredExistingPartitions(drive: configModel.Drive): configModel.Partition[] { +function configuredExistingPartitions(drive: apiModel.Drive): apiModel.Partition[] { const allPartitions = drive.partitions || []; if (drive.spacePolicy === "custom") @@ -102,10 +109,14 @@ function configuredExistingPartitions(drive: configModel.Drive): configModel.Par return allPartitions.filter((p) => isReusedPartition(p)); } -function setBoot(originalModel: configModel.Config, boot: configModel.Boot) { +function setBoot(originalModel: apiModel.Config, boot: apiModel.Boot) { const model = copyModel(originalModel); const name = model.boot?.device?.name; - const remove = name !== undefined && isExplicitBoot(model, name) && !isUsedDrive(model, name); + const remove = + name !== undefined && + isExplicitBoot(model, name) && + !isUsedDrive(model, name) && + !driveHasPv(model, name); if (remove) removeDrive(model, name); @@ -113,7 +124,7 @@ function setBoot(originalModel: configModel.Config, boot: configModel.Boot) { return model; } -function setBootDevice(originalModel: configModel.Config, deviceName: string): configModel.Config { +function setBootDevice(originalModel: apiModel.Config, deviceName: string): apiModel.Config { return setBoot(originalModel, { configure: true, device: { @@ -123,7 +134,7 @@ function setBootDevice(originalModel: configModel.Config, deviceName: string): c }); } -function setDefaultBootDevice(originalModel: configModel.Config): configModel.Config { +function setDefaultBootDevice(originalModel: apiModel.Config): apiModel.Config { return setBoot(originalModel, { configure: true, device: { @@ -132,31 +143,31 @@ function setDefaultBootDevice(originalModel: configModel.Config): configModel.Co }); } -function disableBoot(originalModel: configModel.Config): configModel.Config { +function disableBoot(originalModel: apiModel.Config): apiModel.Config { return setBoot(originalModel, { configure: false }); } function setEncryption( - originalModel: configModel.Config, - method: EncryptionMethod, + originalModel: apiModel.Config, + method: apiModel.EncryptionMethod, password: string, -): configModel.Config { +): apiModel.Config { const model = copyModel(originalModel); model.encryption = { method, password }; return model; } -function disableEncryption(originalModel: configModel.Config): configModel.Config { +function disableEncryption(originalModel: apiModel.Config): apiModel.Config { const model = copyModel(originalModel); model.encryption = null; return model; } function deletePartition( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, mountPath: string, -): configModel.Config { +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); if (drive === undefined) return; @@ -173,10 +184,10 @@ function deletePartition( * the partition is replaced. * */ export function addPartition( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, - partition: configModel.Partition, -): configModel.Config { + partition: apiModel.Partition, +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); if (drive === undefined) return; @@ -191,11 +202,11 @@ export function addPartition( } export function editPartition( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, mountPath: string, - partition: configModel.Partition, -): configModel.Config { + partition: apiModel.Partition, +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); const partitions = drive?.partitions || []; @@ -208,10 +219,10 @@ export function editPartition( } function switchDrive( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, newDriveName: string, -): configModel.Config { +): apiModel.Config { if (driveName === newDriveName) return; const model = copyModel(originalModel); @@ -244,7 +255,7 @@ function switchDrive( return model; } -function addDrive(originalModel: configModel.Config, driveName: string): configModel.Config { +function addDrive(originalModel: apiModel.Config, driveName: string): apiModel.Config { if (findDrive(originalModel, driveName)) return; const model = copyModel(originalModel); @@ -254,10 +265,10 @@ function addDrive(originalModel: configModel.Config, driveName: string): configM } function setCustomSpacePolicy( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, actions: SpacePolicyAction[], -): configModel.Config { +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); if (drive === undefined) return model; @@ -297,11 +308,11 @@ function setCustomSpacePolicy( } function setSpacePolicy( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, - spacePolicy: configModel.SpacePolicy, + spacePolicy: apiModel.SpacePolicy, actions?: SpacePolicyAction[], -): configModel.Config { +): apiModel.Config { if (spacePolicy === "custom") return setCustomSpacePolicy(originalModel, driveName, actions || []); @@ -312,13 +323,16 @@ function setSpacePolicy( return model; } -function usedMountPaths(model: configModel.Config): string[] { - if (!model.drives) return []; +function usedMountPaths(model: apiModel.Config): string[] { + const drives = model.drives || []; + const volumeGroups = model.volumeGroups || []; + const logicalVolumes = volumeGroups.flatMap((v) => v.logicalVolumes || []); - return model.drives.flatMap(allMountPaths); + return [...drives, ...logicalVolumes].flatMap(allMountPaths); } -function unusedMountPaths(model: configModel.Config, volumes: Volume[]): string[] { +/** @depreacted Use useMissingMountPaths from ~/hooks/storage/product. */ +function unusedMountPaths(model: apiModel.Config, volumes: Volume[]): string[] { const volPaths = volumes.filter((v) => v.mountPath.length).map((v) => v.mountPath); const assigned = usedMountPaths(model); return volPaths.filter((p) => !assigned.includes(p)); @@ -331,7 +345,7 @@ function unusedMountPaths(model: configModel.Config, volumes: Volume[]): string[ * * TODO: Revisit when LVM support is added to the UI. */ -function hasAdditionalDrives(model: configModel.Config): boolean { +function hasAdditionalDrives(model: apiModel.Config): boolean { if (model.drives.length <= 1) return false; if (model.drives.length > 2) return true; @@ -345,17 +359,9 @@ function hasAdditionalDrives(model: configModel.Config): boolean { return !onlyToBoot; } -const configModelQuery = { - queryKey: ["storage", "configModel"], - queryFn: fetchConfigModel, - staleTime: Infinity, -}; - -/** - * Hook that returns the config model. - */ -export function useConfigModel(options?: QueryHookOptions): configModel.Config { - const query = configModelQuery; +/** @deprecated Use useApiModel from ~/hooks/storage/api-model. */ +export function useConfigModel(options?: QueryHookOptions): apiModel.Config { + const query = apiModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); return data; @@ -367,18 +373,15 @@ export function useConfigModel(options?: QueryHookOptions): configModel.Config { export function useConfigModelMutation() { const queryClient = useQueryClient(); const query = { - mutationFn: (model: configModel.Config) => setConfigModel(model), + mutationFn: (model: apiModel.Config) => setConfigModel(model), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), }; return useMutation(query); } -/** - * @todo Use a hash key from the model object as id for the query. - * Hook that returns the config model. - */ -export function useSolvedConfigModel(model?: configModel.Config): configModel.Config | null { +/** @deprecated Use useSolvedApiModel from ~/hooks/storage/api-model. */ +export function useSolvedConfigModel(model?: apiModel.Config): apiModel.Config | null { const query = useSuspenseQuery({ queryKey: ["storage", "solvedConfigModel", JSON.stringify(model)], queryFn: () => (model ? solveConfigModel(model) : Promise.resolve(null)), @@ -412,8 +415,8 @@ export function useBoot(): BootHook { } export type EncryptionHook = { - encryption?: configModel.Encryption; - enable: (method: EncryptionMethod, password: string) => void; + encryption?: apiModel.Encryption; + enable: (method: apiModel.EncryptionMethod, password: string) => void; disable: () => void; }; @@ -423,7 +426,7 @@ export function useEncryption(): EncryptionHook { return { encryption: model?.encryption, - enable: (method: EncryptionMethod, password: string) => + enable: (method: apiModel.EncryptionMethod, password: string) => mutate(setEncryption(model, method, password)), disable: () => mutate(disableEncryption(model)), }; @@ -432,14 +435,15 @@ export function useEncryption(): EncryptionHook { export type DriveHook = { isBoot: boolean; isExplicitBoot: boolean; + hasPv: boolean; allMountPaths: string[]; - configuredExistingPartitions: configModel.Partition[]; + configuredExistingPartitions: apiModel.Partition[]; switch: (newName: string) => void; - getPartition: (mountPath: string) => configModel.Partition | undefined; - addPartition: (partition: configModel.Partition) => void; - editPartition: (mountPath: string, partition: configModel.Partition) => void; + getPartition: (mountPath: string) => apiModel.Partition | undefined; + addPartition: (partition: apiModel.Partition) => void; + editPartition: (mountPath: string, partition: apiModel.Partition) => void; deletePartition: (mountPath: string) => void; - setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => void; + setSpacePolicy: (policy: apiModel.SpacePolicy, actions?: SpacePolicyAction[]) => void; delete: () => void; }; @@ -453,23 +457,23 @@ export function useDrive(name: string): DriveHook | null { return { isBoot: isBoot(model, name), isExplicitBoot: isExplicitBoot(model, name), + hasPv: driveHasPv(model, drive.name), allMountPaths: allMountPaths(drive), configuredExistingPartitions: configuredExistingPartitions(drive), switch: (newName) => mutate(switchDrive(model, name, newName)), delete: () => mutate(removeDrive(model, name)), getPartition: (mountPath: string) => findPartition(model, name, mountPath), - addPartition: (partition: configModel.Partition) => - mutate(addPartition(model, name, partition)), - editPartition: (mountPath: string, partition: configModel.Partition) => + addPartition: (partition: apiModel.Partition) => mutate(addPartition(model, name, partition)), + editPartition: (mountPath: string, partition: apiModel.Partition) => mutate(editPartition(model, name, mountPath, partition)), deletePartition: (mountPath: string) => mutate(deletePartition(model, name, mountPath)), - setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => + setSpacePolicy: (policy: apiModel.SpacePolicy, actions?: SpacePolicyAction[]) => mutate(setSpacePolicy(model, name, policy, actions)), }; } export type ModelHook = { - model: configModel.Config; + model: apiModel.Config; usedMountPaths: string[]; unusedMountPaths: string[]; // Hacky solution used to decide whether it makes sense to allow to remove drives diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 4ac2193189..33074f28e0 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -71,11 +71,23 @@ const SOFTWARE = { const STORAGE = { root: "/storage", - bootDevice: "/storage/select-boot-device", - encryption: "/storage/encryption", - addPartition: "/storage/:id/add-partition", - editPartition: "/storage/:id/edit-partition/:partitionId", - findSpace: "/storage/:id/find-space", + editBootDevice: "/storage/boot-device/edit", + editEncryption: "/storage/encryption/edit", + drive: { + editSpacePolicy: "/storage/drives/:id/space-policy/edit", + partition: { + add: "/storage/drives/:id/partitions/add", + edit: "/storage/drives/:id/partitions/:partitionId/edit", + }, + }, + volumeGroup: { + add: "/storage/volume-groups/add", + edit: "/storage/volume-groups/:id/edit", + logicalVolume: { + add: "/storage/volume-groups/:id/logical-volumes/add", + edit: "/storage/volume-groups/:id/logical-volumes/:logicalVolumeId/edit", + }, + }, iscsi: "/storage/iscsi", dasd: "/storage/dasd", zfcp: { diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index e49708c7ba..56214ac957 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -30,6 +30,8 @@ import SpacePolicySelection from "~/components/storage/SpacePolicySelection"; import ProposalPage from "~/components/storage/ProposalPage"; import ISCSIPage from "~/components/storage/ISCSIPage"; import PartitionPage from "~/components/storage/PartitionPage"; +import LvmPage from "~/components/storage/LvmPage"; +import LogicalVolumePage from "~/components/storage/LogicalVolumePage"; import ZFCPPage from "~/components/storage/zfcp/ZFCPPage"; import ZFCPDiskActivationPage from "~/components/storage/zfcp/ZFCPDiskActivationPage"; import DASDPage from "~/components/storage/dasd/DASDPage"; @@ -46,25 +48,41 @@ const routes = (): Route => ({ element: , }, { - path: PATHS.bootDevice, + path: PATHS.editBootDevice, element: , }, { - path: PATHS.encryption, + path: PATHS.editEncryption, element: , }, { - path: PATHS.findSpace, + path: PATHS.drive.editSpacePolicy, element: , }, { - path: PATHS.addPartition, + path: PATHS.drive.partition.add, element: , }, { - path: PATHS.editPartition, + path: PATHS.drive.partition.edit, element: , }, + { + path: PATHS.volumeGroup.add, + element: , + }, + { + path: PATHS.volumeGroup.edit, + element: , + }, + { + path: PATHS.volumeGroup.logicalVolume.add, + element: , + }, + { + path: PATHS.volumeGroup.logicalVolume.edit, + element: , + }, { path: PATHS.iscsi, element: , diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index 2f62aef190..b0e7a400a9 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -126,3 +126,6 @@ export type { SpacePolicyAction, StorageDevice, }; + +export * as model from "~/types/storage/model"; +export * as data from "~/types/storage/data"; diff --git a/web/src/types/storage/data.ts b/web/src/types/storage/data.ts new file mode 100644 index 0000000000..dd74383e59 --- /dev/null +++ b/web/src/types/storage/data.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +/** + * Data types. + * + * Types that represent the data used for managing (add, edit) config devices. These types are + * typically used by forms and mutation hooks. + */ + +import { apiModel } from "~/api/storage/types"; + +type VolumeGroup = Partial>; + +interface LogicalVolume extends Partial> { + filesystem?: Filesystem; + size?: Size; +} + +type Filesystem = Partial>; + +type Size = Partial>; + +export type { VolumeGroup, LogicalVolume, Filesystem, Size }; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts new file mode 100644 index 0000000000..c12eedf980 --- /dev/null +++ b/web/src/types/storage/model.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +/** + * Model types. + * + * Types that extend the apiModel by adding calculated properties and methods. + */ + +import { apiModel } from "~/api/storage/types"; + +type Model = { + drives: Drive[]; + volumeGroups: VolumeGroup[]; + getMountPaths: () => string[]; +}; + +interface Drive extends apiModel.Drive { + isUsed: boolean; + isAddingPartitions: boolean; + getMountPaths: () => string[]; + getVolumeGroups: () => VolumeGroup[]; +} + +interface VolumeGroup extends Omit { + logicalVolumes: LogicalVolume[]; + getTargetDevices: () => Drive[]; + getMountPaths: () => string[]; +} + +type LogicalVolume = apiModel.LogicalVolume; + +export type { Model, Drive, VolumeGroup, LogicalVolume };