From 3b2f2fc0fc28315577ed5c35f87333fd886208d0 Mon Sep 17 00:00:00 2001 From: Olivier Gayot Date: Wed, 20 Nov 2024 15:12:38 +0100 Subject: [PATCH] storage: advertise erase and install scenarios Signed-off-by: Olivier Gayot --- subiquity/common/types/storage.py | 1 + subiquity/server/controllers/filesystem.py | 57 ++++++++++++++++++- .../controllers/tests/test_filesystem.py | 44 ++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/subiquity/common/types/storage.py b/subiquity/common/types/storage.py index 024603282..e4290d943 100644 --- a/subiquity/common/types/storage.py +++ b/subiquity/common/types/storage.py @@ -277,6 +277,7 @@ class GuidedStorageTargetManual: GuidedStorageTarget = Union[ + GuidedStorageTargetEraseInstall, GuidedStorageTargetReformat, GuidedStorageTargetResize, GuidedStorageTargetUseGap, diff --git a/subiquity/server/controllers/filesystem.py b/subiquity/server/controllers/filesystem.py index 03d46c1cd..61c965f26 100644 --- a/subiquity/server/controllers/filesystem.py +++ b/subiquity/server/controllers/filesystem.py @@ -890,8 +890,10 @@ async def POST(self, config: list): self.model.expose_recovery_keys() await self.configured() - def potential_boot_disks(self, check_boot=True, with_reformatting=False): - disks = [] + def potential_boot_disks( + self, check_boot=True, with_reformatting=False + ) -> list[ModelDisk | Raid]: + disks: list[ModelDisk | Raid] = [] for raid in self.model._all(type="raid"): if check_boot and not boot.can_be_boot_device( raid, with_reformatting=with_reformatting @@ -1277,6 +1279,56 @@ def available_target_resize_scenarios( scenarios.append((vals.install_max, resize)) return scenarios + def available_erase_install_scenarios( + self, install_min: int + ) -> list[tuple[int, GuidedStorageTargetEraseInstall]]: + scenarios: list[tuple[int, GuidedStorageTargetEraseInstall]] = [] + + for disk in self.potential_boot_disks(check_boot=False): + # Skip RAID until we know how to proceed. + if not isinstance(disk, ModelDisk): + continue + + for partition in disk.partitions(): + if partition._is_in_use: + continue + + if partition.os is None: + continue + + # Make an ephemeral copy of the disk object with the relevant + # partition removed. Then it's as if we're installing in the + # resulting gap (which will include free space that was + # directly before or after the partition that we removed). + altered_disk = disk._excluding_partition(partition) + if not boot.can_be_boot_device(altered_disk, with_reformatting=False): + continue + + gap = gaps.includes(altered_disk, offset=partition.offset) + if not self.use_gap_has_enough_room_for_partitions(altered_disk, gap): + log.error( + "skipping TargetEraseInstall: not enough room for primary" + " partitions" + ) + continue + + capability_info = CapabilityInfo() + for variation in self._variation_info.values(): + if variation.is_core_boot_classic(): + continue + capability_info.combine( + variation.capability_info_for_gap(gap, install_min) + ) + + erase = GuidedStorageTargetEraseInstall( + disk.id, + partition.number, + allowed=capability_info.allowed, + disallowed=capability_info.disallowed, + ) + scenarios.append((gap.size, erase)) + return scenarios + async def v2_guided_GET(self, wait: bool = False) -> GuidedStorageResponseV2: """Acquire a list of possible guided storage configuration scenarios. Results are sorted by the size of the space potentially available to @@ -1314,6 +1366,7 @@ async def v2_guided_GET(self, wait: bool = False) -> GuidedStorageResponseV2: scenarios.extend(self.available_use_gap_scenarios(install_min)) scenarios.extend(self.available_target_resize_scenarios(install_min)) + scenarios.extend(self.available_erase_install_scenarios(install_min)) scenarios.sort(reverse=True, key=lambda x: x[0]) return GuidedStorageResponseV2( diff --git a/subiquity/server/controllers/tests/test_filesystem.py b/subiquity/server/controllers/tests/test_filesystem.py index a36416aa8..7249db78f 100644 --- a/subiquity/server/controllers/tests/test_filesystem.py +++ b/subiquity/server/controllers/tests/test_filesystem.py @@ -1709,6 +1709,50 @@ async def test_available_use_gap_scenarios( self.assertEqual(expected_scenario, scenarios != []) + async def test_available_erase_install_scenarios(self): + await self._setup(Bootloader.NONE, "gpt", fix_bios=True) + install_min = self.fsc.calculate_suggested_install_min() + + p1 = make_partition(self.model, self.disk, preserve=True, size=4 << 20) + p2 = make_partition(self.model, self.disk, preserve=True, size=4 << 20) + + self.model._probe_data["os"] = { + p1._path(): { + "label": "Ubuntu", + "long": "Ubuntu 22.04.1 LTS", + "type": "linux", + "version": "22.04.1", + }, + p2._path(): { + "label": "Ubuntu", + "long": "Ubuntu 20.04.7 LTS", + "type": "linux", + "version": "20.04.7", + }, + } + + scenario1, scenario2 = self.fsc.available_erase_install_scenarios(install_min) + + # available_*_scenarios returns a list of tuple having an int as an index + scenario1 = scenario1[1] + scenario2 = scenario2[1] + + self.assertIsInstance(scenario1, GuidedStorageTargetEraseInstall) + self.assertEqual(self.disk.id, scenario1.disk_id) + self.assertEqual(p1.number, scenario1.partition_number) + self.assertIsInstance(scenario2, GuidedStorageTargetEraseInstall) + self.assertEqual(self.disk.id, scenario2.disk_id) + self.assertEqual(p2.number, scenario2.partition_number) + + async def test_available_erase_install_scenarios__no_os(self): + await self._setup(Bootloader.NONE, "gpt", fix_bios=True) + install_min = self.fsc.calculate_suggested_install_min() + + make_partition(self.model, self.disk, preserve=True, size=4 << 20) + make_partition(self.model, self.disk, preserve=True, size=4 << 20) + + self.assertFalse(self.fsc.available_erase_install_scenarios(install_min)) + async def test_resize_has_enough_room_for_partitions__one_primary(self): await self._setup(Bootloader.NONE, "gpt", fix_bios=True)