# frozen_string_literal: true # Copyright (c) [2024-2026] 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 "../agama/storage/storage_helpers" require "agama/config" require "agama/storage/config" require "agama/storage/config_conversions/from_json" require "agama/storage/system" require "y2storage" require "y2storage/agama_proposal" describe Y2Storage::AgamaProposal do using Y2Storage::Refinements::SizeCasts include Agama::RSpec::StorageHelpers subject(:proposal) do described_class.new(config, storage_system, issues_list: issues_list) end let(:storage_system) { Agama::Storage::System.new } let(:config) { config_from_json } let(:config_from_json) do Agama::Storage::ConfigConversions::FromJSON .new(config_json) .convert end let(:issues_list) { [] } before do mock_storage(devicegraph: scenario) # To speed-up the tests allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) end let(:scenario) { "empty-hd-50GiB.yaml" } describe "#propose" do context "when the config has LVM volume groups" do let(:scenario) { "empty-hd-50GiB.yaml" } let(:config_json) do { drives: [ { partitions: [ { alias: "system-pv", size: "40 GiB" }, { alias: "vg1-pv", size: "5 GiB" } ] } ], volumeGroups: [ { name: "system", extentSize: "2 MiB", physicalVolumes: ["system-pv"], logicalVolumes: [ { name: "root", size: "10 GiB", filesystem: { path: "/", type: "btrfs" }, encryption: { luks2: { password: "12345" } } }, { alias: "system-pool", name: "pool", pool: true, size: "20 GiB", stripes: 10, stripeSize: "4 KiB" }, { name: "data", size: "50 GiB", usedPool: "system-pool", filesystem: { type: "xfs" } } ] }, { name: "vg1", physicalVolumes: ["vg1-pv"], logicalVolumes: [ { name: "home", filesystem: { path: "/home", type: "xfs" }, size: "2 GiB" } ] } ] } end it "proposes the expected devices" do devicegraph = proposal.propose expect(devicegraph.lvm_vgs).to contain_exactly( an_object_having_attributes( vg_name: "system", extent_size: 2.MiB ), an_object_having_attributes( vg_name: "vg1", extent_size: 4.MiB ) ) system_vg = devicegraph.find_by_name("/dev/system") system_pvs = system_vg.lvm_pvs.map(&:plain_blk_device) system_lvs = system_vg.lvm_lvs expect(system_pvs).to contain_exactly( an_object_having_attributes(name: "/dev/sda2", size: 40.GiB) ) expect(system_lvs).to contain_exactly( an_object_having_attributes( lv_name: "root", lv_type: Y2Storage::LvType::NORMAL, size: 10.GiB, filesystem: an_object_having_attributes( type: Y2Storage::Filesystems::Type::BTRFS, mount_path: "/" ), encryption: an_object_having_attributes( type: Y2Storage::EncryptionType::LUKS2, password: "12345" ) ), an_object_having_attributes( lv_name: "pool", lv_type: Y2Storage::LvType::THIN_POOL, size: 20.GiB, filesystem: be_nil, encryption: be_nil, stripes: 10, stripe_size: 4.KiB, lvm_lvs: contain_exactly( an_object_having_attributes( lv_name: "data", lv_type: Y2Storage::LvType::THIN, size: 50.GiB, filesystem: an_object_having_attributes( type: Y2Storage::Filesystems::Type::XFS ) ) ) ) ) vg1 = devicegraph.find_by_name("/dev/vg1") vg1_pvs = vg1.lvm_pvs.map(&:plain_blk_device) vg1_lvs = vg1.lvm_lvs expect(vg1_pvs).to contain_exactly( an_object_having_attributes(name: "/dev/sda3", size: 5.GiB) ) expect(vg1_lvs).to contain_exactly( an_object_having_attributes( lv_name: "home", lv_type: Y2Storage::LvType::NORMAL, size: 2.GiB, filesystem: an_object_having_attributes( type: Y2Storage::Filesystems::Type::XFS, mount_path: "/home" ) ) ) end end context "when a LVM physical volume is not found" do let(:config_json) do { drives: [ { partitions: [ { size: "40 GiB" }, { alias: "pv1", size: "5 GiB" } ] } ], volumeGroups: [ { name: "system", extentSize: "2 MiB", physicalVolumes: ["pv1", "pv2"], logicalVolumes: [ { name: "root", filesystem: { path: "/" } } ] } ] } end it "aborts the proposal process" do proposal.propose expect(proposal.failed?).to eq true end it "reports the corresponding error" do proposal.propose expect(proposal.issues_list).to include an_object_having_attributes( description: /no LVM physical volume with alias 'pv2'/ ) end end context "when a LVM thin pool volume is not found" do let(:config_json) do { drives: [ { partitions: [ { size: "40 GiB" }, { alias: "pv1", size: "5 GiB" } ] } ], volumeGroups: [ { name: "system", extentSize: "2 MiB", physicalVolumes: ["pv1"], logicalVolumes: [ { pool: true }, { name: "root", filesystem: { path: "/" }, usedPool: "pool" } ] } ] } end it "aborts the proposal process" do proposal.propose expect(proposal.failed?).to eq true end it "reports the corresponding error" do proposal.propose expect(proposal.issues_list).to include an_object_having_attributes( description: /no LVM thin pool volume with alias 'pool'/ ) end end context "when the config has LVM volume groups with generated physical volumes" do let(:scenario) { "disks.yaml" } let(:config_json) do { drives: [ { alias: "vda", partitions: [ { search: "/dev/vda2", size: { min: "0", max: "current" } }, { size: { min: "4 GiB" }, filesystem: { path: "/foo" } } ] }, { alias: "vdb" } ], volumeGroups: [ { name: "system", physicalVolumes: [ { generate: { targetDevices: ["vda"] } } ], logicalVolumes: [ { name: "root", size: "10 GiB", filesystem: { path: "/", type: "btrfs" } }, { name: "data", size: "10 GiB", filesystem: { type: "xfs" } } ] }, { name: "vg1", physicalVolumes: [ { generate: { targetDevices: ["vdb"], encryption: { luks2: { password: "s3cr3t" } } } } ], logicalVolumes: [ { name: "home", filesystem: { path: "/home", type: "xfs" }, size: "20 GiB" } ] } ] } end before do allow_any_instance_of(Y2Storage::Partition) .to(receive(:detect_resize_info)) .and_return(resize_info) end let(:resize_info) do instance_double( Y2Storage::ResizeInfo, resize_ok?: true, min_size: Y2Storage::DiskSize::GiB(3), max_size: Y2Storage::DiskSize::GiB(35) ) end it "proposes the expected devices" do devicegraph = proposal.propose resized = devicegraph.find_by_name("/dev/vda2") expect(resized.filesystem.label).to eq("previous_root") expect(resized.size).to be > 15.GiB expect(resized.size).to be < 16.GiB foo = devicegraph.find_by_name("/dev/vda4") expect(foo.filesystem.mount_path).to eq("/foo") expect(foo.size).to be > 4.GiB expect(foo.size).to be < 5.GiB system = devicegraph.find_by_name("/dev/system") expect(system.lvm_lvs.size).to eq 2 expect(Y2Storage::DiskSize.sum(system.lvm_lvs.map(&:size))).to eq 20.GiB expect(system.lvm_pvs.size).to eq 2 vg1 = devicegraph.find_by_name("/dev/vg1") expect(vg1.lvm_lvs.size).to eq 1 expect(vg1.lvm_lvs.first.size).to eq 20.GiB expect(vg1.lvm_pvs.size).to eq 1 pv_vg1 = vg1.lvm_pvs.first expect(pv_vg1.blk_device.is?(:encryption)).to eq true expect(pv_vg1.blk_device.type.is?(:luks2)).to eq true end end context "when two volume groups with generated physical volumes share a disk" do let(:scenario) { "disks.yaml" } let(:config_json) do { drives: [ { alias: "vda" }, { alias: "vdb" }, { alias: "vdc" } ], volumeGroups: [ { name: "system", physicalVolumes: [ { generate: { targetDevices: ["vdb", "vdc"] } } ], logicalVolumes: [ { name: "root", size: "10 GiB", filesystem: { path: "/", type: "btrfs" } }, { name: "home", size: { min: "30 GiB" }, filesystem: { path: "/home", type: "xfs" } } ] }, { name: "vg1", physicalVolumes: [ { generate: { targetDevices: ["vdc"], encryption: { luks1: { password: "s3cr3t" } } } } ], logicalVolumes: [ { name: "data", filesystem: { type: "xfs" }, size: { min: "30 GiB", max: "40 GiB" } } ] } ] } end it "proposes the expected devices" do devicegraph = proposal.propose system = devicegraph.find_by_name("/dev/system") expect(system.lvm_lvs.map { |lv| lv.mount_point.path }).to contain_exactly("/", "/home") expect(system.lvm_pvs.map { |pv| pv.blk_device.partitionable.name }) .to contain_exactly("/dev/vdb", "/dev/vdc") vg1 = devicegraph.find_by_name("/dev/vg1") expect(vg1.lvm_lvs.map(&:lv_name)).to contain_exactly("data") expect(vg1.lvm_pvs.map { |pv| pv.plain_blk_device.partitionable.name }) .to contain_exactly("/dev/vdc") pv_vg1 = vg1.lvm_pvs.first expect(pv_vg1.blk_device.is?(:encryption)).to eq true expect(pv_vg1.blk_device.type.is?(:luks1)).to eq true end end context "when creating new volumes in an existing volume group" do let(:scenario) { "several_vgs.yaml" } let(:config_json) do { boot: { configure: false }, volumeGroups: [ { search: "/dev/data", logicalVolumes: previous_lvs + new_lvs } ] } end let(:previous_lvs) { [] } let(:new_lvs) do [ { name: "root", size: "5 GiB", filesystem: { path: "/", type: "btrfs" } }, { name: "home", size: { min: home_min }, filesystem: { path: "/home", type: "xfs" } } ] end context "if the LVs fit into the available space at the VG" do let(:home_min) { "5 GiB" } it "adds the new volumes to the volume group keeping the previous ones" do vg_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/data").sid proposal.propose vg = proposal.devices.find_by_name("/dev/data") expect(vg.sid).to eq vg_sid # The previous one plus the two new ones expect(vg.lvm_lvs.size).to eq 3 paths = vg.lvm_lvs.map(&:filesystem).map(&:mount_path) expect(paths).to contain_exactly(nil, "/", "/home") end end context "if the LVs fit into the total size of the VG but not in the available space" do let(:home_min) { "15 GiB" } context "and nothing is configured to make space in the VG" do it "raises an error" do expect { proposal.propose }.to raise_error(Y2Storage::NoDiskSpaceError) end end context "and all previous LVs should be deleted " do let(:previous_lvs) do [ { search: "*", delete: true } ] end it "adds the new volumes to the volume group deleting the previous ones" do vg_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/data").sid proposal.propose vg = proposal.devices.find_by_name("/dev/data") expect(vg.sid).to eq vg_sid expect(vg.lvm_lvs.size).to eq 2 paths = vg.lvm_lvs.map(&:filesystem).map(&:mount_path) expect(paths).to contain_exactly("/", "/home") end end end context "if the LVs do not fit into the total size of the VG" do let(:home_min) { "150 GiB" } context "and nothing is configured to make space in the VG" do it "raises an error" do expect { proposal.propose }.to raise_error(Y2Storage::NoDiskSpaceError) end end context "and all previous LVs should be deleted " do let(:previous_lvs) do [ { search: "*", delete: true } ] end it "raises an error" do expect { proposal.propose }.to raise_error(Y2Storage::NoDiskSpaceError) end end end end context "when using existing logical volumes" do let(:scenario) { "several_vgs.yaml" } let(:config_json) do { boot: { configure: false }, volumeGroups: [ { search: "/dev/data", logicalVolumes: [ { name: "root", size: { min: "5 GiB" }, filesystem: { path: "/", type: "btrfs" } }, { search: { max: 1 }, filesystem: { path: "/home", reuseIfPossible: true }, size: home_size } ] } ] } end let(:home_size) { nil } it "uses the volume group and the logical volume" do initial_vg = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/data") vg_sid = initial_vg.sid fs_sid = initial_vg.lvm_lvs.first.filesystem.sid proposal.propose vg = proposal.devices.find_by_name("/dev/data") expect(vg.sid).to eq vg_sid expect(vg.lvm_lvs.size).to eq 2 filesystems = vg.lvm_lvs.map(&:filesystem) expect(filesystems.map(&:mount_path)).to contain_exactly("/", "/home") expect(filesystems.map(&:sid)).to include fs_sid end it "correctly distributes the available space" do proposal.propose vg = proposal.devices.find_by_name("/dev/data") root = vg.lvm_lvs.find { |v| v.filesystem.mount_path == "/" } home = vg.lvm_lvs.find { |v| v.filesystem.mount_path == "/home" } expect(vg.available_space).to be_zero expect(root.size).to be > 12.GiB expect(home.size).to eq 30.GiB end context "if some existing logical volume is chosen for growing" do let(:home_size) { { min: "30 GiB", max: "35 GiB" } } it "grows the LV and distributes the remaining space" do proposal.propose vg = proposal.devices.find_by_name("/dev/data") root = vg.lvm_lvs.find { |v| v.filesystem.mount_path == "/" } home = vg.lvm_lvs.find { |v| v.filesystem.mount_path == "/home" } expect(vg.available_space).to be_zero expect(home.size).to eq 35.GiB expect(root.size).to be > 7.GiB expect(root.size).to be < 8.GiB end end end context "when using an existing thin pool and existing logical thin volume" do let(:scenario) { "lvm_with_nested_thin_lvs.xml" } let(:config_json) do { boot: { configure: false }, volumeGroups: [ { search: "/dev/vg_b", logicalVolumes: [ { search: "/dev/vg_b/tv_01", filesystem: { path: "/" } }, { search: "/dev/vg_b/tpool", alias: "pool" }, { name: "home", usedPool: "pool", size: "5 GiB", filesystem: { path: "/home" } } ] } ] } end it "uses the volume group and the logical volumes" do vg_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vg_b").sid lv_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vg_b/tv_01").sid pool = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vg_b/tpool") pool_sid = pool.sid pool_size = pool.lvm_lvs.size proposal.propose vg = proposal.devices.find_by_name("/dev/vg_b") expect(vg.sid).to eq vg_sid expect(vg.thin_pool_lvm_lvs.size).to eq 1 pool = vg.thin_pool_lvm_lvs.first expect(pool.sid).to eq pool_sid thin_vols = pool.lvm_lvs expect(thin_vols.size).to eq(pool_size + 1) expect(thin_vols.map(&:sid)).to include lv_sid filesystems = thin_vols.map(&:filesystem).compact expect(filesystems.map(&:mount_path)).to contain_exactly("/", "/home") end end context "when adding more PVs to an existing volume group" do let(:scenario) { "several_vgs.yaml" } let(:config_json) do { boot: { configure: false }, drives: [ { search: "/dev/sdc", partitions: [ { size: "40 GiB", alias: "pv1" } ] } ], volumeGroups: [ { search: "/dev/data", logicalVolumes: [ { name: "root", size: { min: "5 GiB" }, filesystem: { path: "/", type: "btrfs" } } ], physicalVolumes: ["pv1"] } ] } end it "uses the volume group" do vg_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/data").sid proposal.propose vg = proposal.devices.find_by_name("/dev/data") expect(vg.sid).to eq vg_sid expect(vg.lvm_lvs.map(&:filesystem).map(&:mount_path)).to include "/" end it "grows the volume group" do proposal.propose vg = proposal.devices.find_by_name("/dev/data") expect(vg.lvm_pvs.size).to eq 3 expect(vg.size).to be > Y2Storage::DiskSize.GiB(80) end it "uses the new volume group space to allocate the logical volumes" do proposal.propose lv = proposal.devices.find_by_name("/dev/data/root") expect(lv.size).to be > Y2Storage::DiskSize.GiB(50) end end context "when deleting volumes in a new volume group" do let(:config_json) do { boot: { configure: false }, drives: [ { partitions: [ { alias: "system-pv", size: "40 GiB" } ] } ], volumeGroups: [ { name: "system", physicalVolumes: ["system-pv"], logicalVolumes: [ { search: "*", delete: true }, { name: "root", size: "5 GiB", filesystem: { path: "/", type: "btrfs" } } ] } ] } end it "proposes the expected devices" do devicegraph = proposal.propose vg = devicegraph.find_by_name("/dev/system") expect(vg.lvm_lvs.map { |lv| lv.mount_point.path }).to contain_exactly("/") end end context "when a volume group has generated physical volumes with useAvailable space policy" do let(:scenario) { "disks.yaml" } let(:config_json) do { drives: [ { alias: "vda" }, { alias: "vdb" } ], volumeGroups: [ { name: "system", physicalVolumes: [ { generate: { targetDevices: ["vda", "vdb"], spacePolicy: "useAvailable" } } ], logicalVolumes: [ { name: "root", size: "10 GiB", filesystem: { path: "/", type: "btrfs" } }, { name: "home", size: { min: "20 GiB" }, filesystem: { path: "/home", type: "xfs" } } ] } ] } end it "proposes the expected devices" do devicegraph = proposal.propose system = devicegraph.find_by_name("/dev/system") expect(system.lvm_lvs.size).to eq 2 root = system.lvm_lvs.find { |lv| lv.lv_name == "root" } home = system.lvm_lvs.find { |lv| lv.lv_name == "home" } expect(root.size).to eq 10.GiB expect(root.filesystem.type).to eq Y2Storage::Filesystems::Type::BTRFS expect(root.filesystem.mount_path).to eq "/" expect(home.size).to be > 20.GiB expect(home.filesystem.type).to eq Y2Storage::Filesystems::Type::XFS expect(home.filesystem.mount_path).to eq "/home" # With useAvailable policy, PVs should use all available space on the target disks. There # is ~20 GiB in vda and ~50 GiB in vdb. expect(system.size).to be > 69.GiB vda_pv = system.lvm_pvs.find { |pv| pv.plain_blk_device.partitionable.name == "/dev/vda" } vdb_pv = system.lvm_pvs.find { |pv| pv.plain_blk_device.partitionable.name == "/dev/vdb" } expect(vda_pv.plain_blk_device.size).to be > 19.GiB expect(vdb_pv.plain_blk_device.size).to be > 49.GiB end end end end