diff --git a/service/lib/agama/storage/config_solvers/boot.rb b/service/lib/agama/storage/config_solvers/boot.rb index 5d43b55afd..d29744d290 100644 --- a/service/lib/agama/storage/config_solvers/boot.rb +++ b/service/lib/agama/storage/config_solvers/boot.rb @@ -48,7 +48,12 @@ def solve(config) # @return [Storage::System] attr_reader :storage_system - # Finds a device for booting and sets its alias, if needed. + # Finds a device for booting. + # + # If there is already an entry pointing to that device, it may set an alias for that config + # entry if needed. + # + # If there is no Drive or MdRaid entry, it may add it to the config. # # A boot device cannot be automatically inferred in the following scenarios: # * The root partition or logical volume is missing. @@ -69,12 +74,15 @@ def solve_device_alias # Config of the device used for allocating root, directly or indirectly. # - # The boot device has to be a partitioned drive. If root is not directly created as a - # partition of a drive (e.g., as logical volume, as partition of a MD RAID, etc), then the - # first partitioned drive used for allocating the device (physical volume or MD member - # device) is considered as boot device. + # The boot device has to be a partitioned drive or hardware RAID. If root is not directly + # created as a partition of a drive (e.g., as logical volume, as partition of a MD RAID, + # etc), then the first partitioned drive used for allocating the device (physical volume + # or MD member device) is considered as boot device. + # + # The boot device is recursively searched until reaching a drive or a hardware RAID. # - # The boot device is recursively searched until reaching a drive. + # For reused LVMs or RAIDs, the result may be a new config entry created to point to the + # appropriate boot device. # # @return [Configs::Drive, Configs::MdRaid, nil] nil if the boot device cannot be inferred # from the config. @@ -130,14 +138,14 @@ def partitionable_from_md_raid(md_raid) # Recursively looks for the first partitioned config from the given MD RAID. # + # If no config is found, it may create and return a new Drive or MdRaid config. + # # @param md_raid [Configs::MdRaid] # @return [Configs::Drive, Configs::MdRaid, nil] def partitionable_from_found_md_raid(md_raid) return md_raid if storage_system.candidate?(md_raid.found_device) - # TODO: find the correct underlying disk devices for the MD RAID (note they may lack - # a corresponding drive entry at the configuration) - nil + partitionable_from_found(md_raid.found_device) end # Recursively looks for the first partitioned drive from the given MD RAID. @@ -151,9 +159,20 @@ def partitioned_drive_from_new_md_raid(md_raid) # Recursively looks for the first partitioned config from the given volume group. # + # If no config is found, it may create and return a new Drive or MdRaid config. + # # @param volume_group [Configs::VolumeGroup] # @return [Configs::Drive, Configs::MdRaid, nil] def partitionable_from_volume_group(volume_group) + partitionable_from_volume_group_pvs(volume_group) || + (volume_group.found_device && partitionable_from_found(volume_group.found_device)) + end + + # Recursively looks for the first partitioned config from the given volume group. + # + # @param volume_group [Configs::VolumeGroup] + # @return [Configs::Drive, Configs::MdRaid, nil] + def partitionable_from_volume_group_pvs(volume_group) pv_devices = find_devices(volume_group.physical_volumes_devices, is_target: true) pvs = find_devices(volume_group.physical_volumes) @@ -212,6 +231,94 @@ def find_md_raid(device_alias) def find_volume_group(device_alias) config.volume_groups.find { |v| v.logical_volume?(device_alias) } end + + # Finds or creates a config pointing to the bootable device corresponding to the given + # RAID or volume group. + # + # @param device [Y2Storage::Md, Y2Storage::LvmVg] + # @return [Configs::Drive, Configs::MdRaid, nil] + def partitionable_from_found(device) + disks = bootable_devices(device) + return if disks.empty? + + config_entry(disks) + end + + # Finds all devices that could be used to boot into the given RAID or volume group + # + # @see #partitionable_from_found + # + # @param device [Y2Storage::Md, Y2Storage::LvmVg] + # @return [Array] + def bootable_devices(device) + device.ancestors.select do |dev| + dev.is?(:disk_device) && dev.partition_table? && storage_system.candidate?(dev) + end + end + + # @see #partitionable_from_found + # + # @param devices [Array] list of candidate RAIDs or disk devices + # @return [Configs::Drive, Configs::MdRaid] + def config_entry(devices) + find_config_entry(devices) || create_config_entry(devices) + end + + # Find the first entry in the current configuration that corresponds to any of the given + # devices + # + # @param devices [Array] list of candidate RAIDs or disk devices + # @return [Configs::Drive, Configs::MdRaid, nil] + def find_config_entry(devices) + sids = devices.map(&:sid) + raid = config.md_raids.find { |d| sids.include?(d.found_device&.sid) } + return raid if raid + + config.drives.find { |d| sids.include?(d.found_device.sid) } + end + + # Creates a new entry in the config to point to one of the given devices + # + # @param devices [Array] list of candidate RAIDs or disk devices + # @return [Configs::Drive, Configs::MdRaid] + def create_config_entry(devices) + device = preferred_device_to_create_entry(devices) + device.is?(:raid) ? create_raid_entry(device) : create_drive_entry(device) + end + + # @see #create_config_entry + # + # @param devices [Array] + # @return [Y2Storage::Partitionable] + def preferred_device_to_create_entry(devices) + devices = devices.select { |d| d.is?(:raid) } if devices.any? { |d| d.is?(:raid) } + devices.min_by(&:name) + end + + # @see #create_config_entry + # + # @param device [] disk device + # @return [Configs::Drive] + def create_drive_entry(device) + config.drives << Configs::Drive.new.tap do |drive| + drive.search.name = device.name + drive.search.solve(device) + end + config.drives.last + end + + # @see #create_config_entry + # + # @param device [] RAID device + # @return [Configs::MdRaid] + def create_raid_entry(device) + config.md_raids << Configs::MdRaid.new.tap do |md| + md.search = Configs::Search.new + md.search.name = device.name + md.search.solve(device) + end + config.md_raids.last + end end end end diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index a9a83dc63d..fdd22e3190 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Mar 12 08:35:23 UTC 2026 - Ancor Gonzalez Sosa + +- Infer the boot device, if omitted in the user configuration, when + reusing a pre-existing LVM or RAID (bsc#1248504). + ------------------------------------------------------------------- Tue Mar 10 12:17:59 UTC 2026 - Imobach Gonzalez Sosa diff --git a/service/test/agama/storage/config_solvers/boot_test.rb b/service/test/agama/storage/config_solvers/boot_test.rb index 4dcc52e10b..b019fd0312 100644 --- a/service/test/agama/storage/config_solvers/boot_test.rb +++ b/service/test/agama/storage/config_solvers/boot_test.rb @@ -22,10 +22,12 @@ require_relative "../storage_helpers" require "agama/config" require "agama/storage/config_conversions/from_json" -require "agama/storage/config_solvers/boot" +require "agama/storage/config_solvers" require "agama/storage/system" describe Agama::Storage::ConfigSolvers::Boot do + include Agama::RSpec::StorageHelpers + subject { described_class.new(product_config, storage_system) } let(:product_config) { Agama::Config.new({}) } @@ -195,15 +197,19 @@ end context "and it corresponds to an existing (reused) RAID" do - let(:md_device) { instance_double(Y2Storage::Md) } - before do + mock_storage(devicegraph: scenario) allow(config.md_raids.first).to receive(:found_device).and_return md_device - allow(storage_system).to receive(:candidate?).with(md_device).and_return candidate end + let(:scenario) { "md_raids.yaml" } + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + let(:md_device) { devicegraph.find_by_name("/dev/md0") } + context "if the RAID is considered bootable (candidate)" do - let(:candidate) { true } + before do + allow(storage_system).to receive(:candidate?).with(md_device).and_return true + end it "sets the alias of the mdRaid as boot device alias" do subject.solve(config) @@ -228,11 +234,92 @@ end context "if the RAID is a regular one (not candidate)" do - let(:candidate) { false } + context "directly over full disks" do + let(:scenario) { "md_disks.yaml" } - it "does not set a boot device alias" do - subject.solve(config) - expect(config.boot.device.device_alias).to be_nil + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + + context "defined over partitions of some disks" do + before do + # These complex cases would need a lot of mocking if we do not resolve searches + # first in order to connect the definitions and the devicegraph. + Agama::Storage::ConfigSolvers::DrivesSearch.new(storage_system) + .solve(config) + Agama::Storage::ConfigSolvers::MdRaidsSearch.new(storage_system) + .solve(config) + Agama::Storage::ConfigSolvers::VolumeGroupsSearch.new(storage_system) + .solve(config) + end + + context "with no drive entries for the underlying disks" do + let(:drives) { [] } + + it "creates a drive entry for the first disk of the RAID members" do + subject.solve(config) + drive = config.drives.first + expect(drive.found_device.descendants).to include md_device + # Check the first disk (by name) is consistently choosen + expect(drive.found_device.name).to eq "/dev/vda" + expect(drive.alias).to_not be_nil + end + + it "sets the alias of the drive device as boot device alias" do + subject.solve(config) + drive = config.drives.first + expect(config.boot.device.device_alias).to eq(drive.alias) + end + + context "if none of the disks is a candidate for installation" do + before do + allow(storage_system).to receive(:candidate?).and_return false + 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 + + context "with drive entries for the underlying disks" do + let(:drives) do + [ + { + search: "/dev/vdb", + alias: "vdb-as-first" + }, + { + alias: "data", + partitions: [ + { + filesystem: { path: "/data" }, + size: "50 GiB" + } + ] + } + ] + end + + it "solves the boot device using the first usable drive" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("vdb-as-first") + end + + context "if none of the disks is a candidate for installation" do + before do + allow(storage_system).to receive(:candidate?).and_return false + 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 end @@ -355,6 +442,235 @@ end end + context "and an existing LVM volume group is reused for root" do + before do + mock_storage(devicegraph: scenario) + + # These complex cases would need a lot of mocking if we do not resolve searches + # first in order to connect the definitions and the devicegraph. + Agama::Storage::ConfigSolvers::DrivesSearch.new(storage_system) + .solve(config) + Agama::Storage::ConfigSolvers::MdRaidsSearch.new(storage_system) + .solve(config) + Agama::Storage::ConfigSolvers::VolumeGroupsSearch.new(storage_system) + .solve(config) + end + + let(:volume_groups) do + [ + { + search: vg_name, + logicalVolumes: [ + { filesystem: { path: "/" } } + ] + } + ] + end + + RSpec.shared_examples "no alias" do + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + + RSpec.shared_examples "no alias if no candidate disk" do + context "if none of the disks is a candidate for installation" do + before do + allow(storage_system).to receive(:candidate?).and_return false + end + + include_examples "no alias" + end + end + + RSpec.shared_examples "set alias" do + it "sets the alias of the drive device as boot device alias" do + subject.solve(config) + drive = config.drives.first + expect(config.boot.device.device_alias).to eq(drive.alias) + end + end + + context "and the physical volumes are full disks" do + let(:scenario) { "lvm_with_nested_thin_lvs.xml" } + let(:vg_name) { "/dev/vg_b" } + + context "with no drive entries for the underlying disks" do + let(:drives) { [] } + + include_examples "no alias" + end + + context "with drive entries for the underlying disks" do + let(:drives) do + [ + { + search: "/dev/sdd", + alias: "sdd" + } + ] + end + + include_examples "no alias" + end + end + + context "and the physical volumes are partitions on disks" do + let(:scenario) { "several_vgs.yaml" } + let(:vg_name) { "/dev/data" } + + context "with no drive entries for the underlying disks" do + let(:drives) { [] } + + it "creates a drive entry for the first disk of the RAID members" do + subject.solve(config) + drive = config.drives.first + # Check the first disk (by name) is consistently choosen + expect(drive.found_device.name).to eq "/dev/sda" + expect(drive.alias).to_not be_nil + end + + include_examples "set alias" + include_examples "no alias if no candidate disk" + end + + context "with drive entries for some of the underlying disks" do + let(:drives) do + [ + { + search: "/dev/sdb", + alias: "sdb" + } + ] + end + + it "solves the boot device using the first usable drive" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("sdb") + end + + include_examples "no alias if no candidate disk" + end + end + + context "and some physical volumes are software RAIDs on top of partitioned disks" do + let(:scenario) { "lvm-over-raids.yaml" } + let(:vg_name) { "/dev/vg0" } + + context "with no drive entries for the underlying disks" do + let(:drives) { [] } + + it "creates a drive entry for the first partitioned disk of the RAID members" do + subject.solve(config) + drive = config.drives.first + expect(drive.found_device.name).to eq "/dev/vda" + expect(drive.alias).to_not be_nil + end + + include_examples "set alias" + include_examples "no alias if no candidate disk" + end + + context "with drive entries for some of the underlying disks" do + let(:drives) do + [ + { + search: "/dev/vdc", + alias: "vdc" + }, + { + search: "/dev/vda", + alias: "vda" + } + ] + end + + it "solves the boot device using the first usable drive" do + subject.solve(config) + # vdc cannot be chosen because it is fully used as RAID member, no partitions + expect(config.boot.device.device_alias).to eq("vda") + end + + include_examples "no alias if no candidate disk" + end + end + + context "and all physical volumes are software RAIDs on top of full disks" do + let(:scenario) { "lvm-over-raids.yaml" } + let(:vg_name) { "/dev/vg1" } + + context "with no drive entries for the underlying disks" do + let(:drives) { [] } + + include_examples "no alias" + end + + context "with drive entries for the underlying disks" do + let(:drives) do + [ + { + search: "/dev/vde", + alias: "vde" + }, + { + search: "/dev/vdf", + alias: "vdf" + } + ] + end + + include_examples "no alias" + end + end + + context "and the physical volumes are BIOS RAIDs and partitions on top of them" do + let(:scenario) { "lvm-over-hardware-raid.xml" } + let(:vg_name) { "/dev/system" } + + context "with no mdRaid or drive entries for the underlying devices" do + let(:drives) { [] } + let(:mdRaids) { [] } + + it "creates an mdRaid entry for the first partitioned RAID" do + subject.solve(config) + raid = config.md_raids.first + expect(raid.found_device.name).to eq "/dev/md/a" + expect(raid.alias).to_not be_nil + end + + it "sets the alias of the RAID as boot device alias" do + subject.solve(config) + raid = config.md_raids.first + expect(config.boot.device.device_alias).to eq(raid.alias) + end + end + + context "with drive entries for some of the underlying RAIDs" do + let(:drives) do + [ + { + search: "/dev/md/b", + alias: "raid-b" + }, + { + search: "/dev/md/a", + alias: "raid-a" + } + ] + end + + it "solves the boot device using the first usable drive" do + subject.solve(config) + # md/b cannot be chosen because it is fully used as physical volume, no partitions + expect(config.boot.device.device_alias).to eq("raid-a") + end + + include_examples "no alias if no candidate disk" + end + end + end + context "and there is neither a partition nor a logical volume for root" do let(:drives) do [ diff --git a/service/test/fixtures/lvm-over-hardware-raid.xml b/service/test/fixtures/lvm-over-hardware-raid.xml new file mode 100644 index 0000000000..d230db1ed3 --- /dev/null +++ b/service/test/fixtures/lvm-over-hardware-raid.xml @@ -0,0 +1,329 @@ + + + + + + 42 + /dev/sdd + sdd + /devices/pci0000:00/0000:00:1f.2/ata4/host3/target3:0:0/3:0:0:0/block/sdd + + 33554432 + 512 + + pci-0000:00:1f.2-ata-4 + ata-VBOX_HARDDISK_VBb7981c42-298aa589 + scsi-0ATA_VBOX_HARDDISK_VBb7981c42-298aa589 + scsi-1ATA_VBOX_HARDDISK_VBb7981c42-298aa589 + scsi-SATA_VBOX_HARDDISK_VBb7981c42-298aa589 + 256 + true + SATA + + + 43 + /dev/sdb + sdb + /devices/pci0000:00/0000:00:1f.2/ata2/host1/target1:0:0/1:0:0:0/block/sdb + + 33554432 + 512 + + pci-0000:00:1f.2-ata-2 + ata-VBOX_HARDDISK_VB6a9bf539-bed09c6d + scsi-0ATA_VBOX_HARDDISK_VB6a9bf539-bed09c6d + scsi-1ATA_VBOX_HARDDISK_VB6a9bf539-bed09c6d + scsi-SATA_VBOX_HARDDISK_VB6a9bf539-bed09c6d + 256 + true + SATA + + + 44 + /dev/sdc + sdc + /devices/pci0000:00/0000:00:1f.2/ata3/host2/target2:0:0/2:0:0:0/block/sdc + + 33554432 + 512 + + pci-0000:00:1f.2-ata-3 + ata-VBOX_HARDDISK_VBe0e2de48-b05a036b + scsi-0ATA_VBOX_HARDDISK_VBe0e2de48-b05a036b + scsi-1ATA_VBOX_HARDDISK_VBe0e2de48-b05a036b + scsi-SATA_VBOX_HARDDISK_VBe0e2de48-b05a036b + 256 + true + SATA + + + 45 + /dev/sda + sda + /devices/pci0000:00/0000:00:1f.2/ata1/host0/target0:0:0/0:0:0:0/block/sda + + 33554432 + 512 + + pci-0000:00:1f.2-ata-1 + ata-VBOX_HARDDISK_VB777f5d67-56603f01 + scsi-0ATA_VBOX_HARDDISK_VB777f5d67-56603f01 + scsi-1ATA_VBOX_HARDDISK_VB777f5d67-56603f01 + scsi-SATA_VBOX_HARDDISK_VB777f5d67-56603f01 + 256 + SATA + + + 46 + /dev/md/imsm0 + md127 + /devices/virtual/block/md127 + + 0 + 512 + + md-uuid-00000000:00000000:00000000:00000000 + 256 + CONTAINER + 955f4f3a:dea4bc4d:f597c49e:c118251c + imsm + false + + + 47 + /dev/md/b + md125 + /devices/virtual/block/md125 + + 10485760 + 512 + + md-uuid-76c4b28a:fdeeacfb:4c011925:c3f3fbe0 + 256 + RAID1 + 76c4b28a:fdeeacfb:4c011925:c3f3fbe0 + false + + + 48 + /dev/md/a + md126 + /devices/virtual/block/md126 + + 20971520 + 512 + + + 262144 + + md-uuid-8f600ff3:ccc9872c:539cd6c8:91e3b4a1 + 256 + RAID0 + 131072 + 8f600ff3:ccc9872c:539cd6c8:91e3b4a1 + false + + + 49 + + + 50 + /dev/sda1 + sda1 + /devices/pci0000:00/0000:00:1f.2/ata1/host0/target0:0:0/0:0:0:0/block/sda/sda1 + + 2048 + 3049472 + 512 + + pci-0000:00:1f.2-ata-1-part1 + ata-VBOX_HARDDISK_VB777f5d67-56603f01-part1 + scsi-0ATA_VBOX_HARDDISK_VB777f5d67-56603f01-part1 + scsi-1ATA_VBOX_HARDDISK_VB777f5d67-56603f01-part1 + scsi-SATA_VBOX_HARDDISK_VB777f5d67-56603f01-part1 + primary + 0x82 + + + 51 + /dev/sda2 + sda2 + /devices/pci0000:00/0000:00:1f.2/ata1/host0/target0:0:0/0:0:0:0/block/sda/sda2 + + 3051520 + 30502912 + 512 + + pci-0000:00:1f.2-ata-1-part2 + ata-VBOX_HARDDISK_VB777f5d67-56603f01-part2 + scsi-0ATA_VBOX_HARDDISK_VB777f5d67-56603f01-part2 + scsi-1ATA_VBOX_HARDDISK_VB777f5d67-56603f01-part2 + scsi-SATA_VBOX_HARDDISK_VB777f5d67-56603f01-part2 + primary + 0x83 + true + + + 54 + 691eb75f-2f8f-4ec9-8515-60b34237bd6d + + + 56 + + 4d2e6fde-d105-4f15-b8e1-4173badc8c66 + + + 59 + + + 61 + /dev/md/a1 + md126p1 + /devices/virtual/block/md126/md126p1 + + 2048 + 18874368 + 512 + + + 262144 + + md-uuid-8f600ff3:ccc9872c:539cd6c8:91e3b4a1-part1 + primary + 0x8e + + + 62 + system + + + 3582 + 4194304 + + 0 + + + 63 + + 1048576 + + + 64 + + 1048576 + + + 65 + /dev/system/root + + 1536 + 4194304 + + system-root + root + normal + + 1536 + 1 + 4096 + + + 68 + + + + + 43 + 46 + true + + + 44 + 46 + true + + + 42 + 46 + true + + + 43 + 47 + + + 44 + 47 + + + 46 + 47 + 1 + + + 43 + 48 + + + 42 + 48 + + + 46 + 48 + 0 + + + 45 + 49 + + + 49 + 50 + + + 49 + 51 + + + 50 + 54 + + + 51 + 56 + + + 48 + 59 + + + 59 + 61 + + + 61 + 63 + + + 63 + 62 + + + 47 + 64 + + + 64 + 62 + + + 62 + 65 + + + 65 + 68 + + + diff --git a/service/test/fixtures/lvm-over-raids.yaml b/service/test/fixtures/lvm-over-raids.yaml new file mode 100644 index 0000000000..40e3d6e73c --- /dev/null +++ b/service/test/fixtures/lvm-over-raids.yaml @@ -0,0 +1,81 @@ +--- +- disk: + name: /dev/vda + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 50 GiB + name: /dev/vda1 + - partition: + size: 50 GiB + name: /dev/vda2 + - partition: + size: 50 GiB + name: /dev/vda3 +- disk: + name: /dev/vdb + size: 500 GiB + partition_table: gpt + partitions: + - partition: + size: 50 GiB + name: /dev/vdb1 + - partition: + size: 50 GiB + name: /dev/vdb2 + - partition: + size: 50 GiB + name: /dev/vdb3 +- disk: + name: /dev/vdc + size: 500 GiB +- disk: + name: /dev/vdd + size: 500 GiB +- disk: + name: /dev/vde + size: 500 GiB +- disk: + name: /dev/vdf + size: 500 GiB +- md: + name: "/dev/md0" + md_devices: + - md_device: + blk_device: /dev/vda1 + - md_device: + blk_device: /dev/vdb1 +- md: + name: "/dev/md1" + md_devices: + - md_device: + blk_device: /dev/vda2 + - md_device: + blk_device: /dev/vdb2 +- md: + name: "/dev/md2" + md_devices: + - md_device: + blk_device: /dev/vdc + - md_device: + blk_device: /dev/vdd +- md: + name: "/dev/md3" + md_devices: + - md_device: + blk_device: /dev/vde + - md_device: + blk_device: /dev/vdf +- lvm_vg: + vg_name: "vg0" + lvm_pvs: + - lvm_pv: + blk_device: "/dev/md0" + - lvm_pv: + blk_device: "/dev/md2" +- lvm_vg: + vg_name: "vg1" + lvm_pvs: + - lvm_pv: + blk_device: "/dev/md3"