diff --git a/subiquity/common/filesystem/sizes.py b/subiquity/common/filesystem/sizes.py index c494e721..b63c9dbf 100644 --- a/subiquity/common/filesystem/sizes.py +++ b/subiquity/common/filesystem/sizes.py @@ -13,11 +13,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import math + import attr +from curtin import swap + from subiquity.models.filesystem import ( + align_up, + align_down, MiB, + GiB, ) +from subiquity.common.types import GuidedResizeValues BIOS_GRUB_SIZE_BYTES = 1 * MiB @@ -75,3 +83,66 @@ def get_efi_size(available_space): def get_bootfs_size(available_space): all_factors = (uefi_scale, bootfs_scale, rootfs_scale) return scale_partitions(all_factors, available_space)[1] + + +# Calculation of guided resize values is primarly focues on finding a suggested +# midpoint - what will we resize down to while leaving some room for the +# existing partition and the new install? +# 1) Obtain the suggested size for the install +# (see calculate_suggested_install_min) +# 2) Look at the output from the resize tool to see the theoretical minimum +# size of the partition we might resize, and pad it a bit (2 GiB or 25%) +# 3) Subtract the two minimum suggested sizes to obtain the space that we can +# decide to keep with the existing partition, or allocate to the new install +# 4) Assume that the installs will grow proportionally to their minimum sizes, +# and split according to the ratio of the minimum sizes +def calculate_guided_resize(part_min: int, part_size: int, install_min: int, + part_align: int = MiB) -> GuidedResizeValues: + if part_min < 0: + return None + + other_room_to_grow = max(2 * GiB, math.ceil(.25 * part_min)) + padded_other_min = part_min + other_room_to_grow + other_min = min(align_up(padded_other_min, part_align), part_size) + + plausible_free_space = part_size - other_min + if plausible_free_space < install_min: + return None + + other_max = align_down(part_size - install_min, part_align) + resize_window = other_max - other_min + ratio = other_min / (other_min + install_min) + raw_recommended = math.ceil(resize_window * ratio) + other_min + recommended = align_up(raw_recommended, part_align) + return GuidedResizeValues( + minimum=other_min, recommended=recommended, maximum=other_max) + + +# Factors for suggested minimum install size: +# 1) Source minimum - The minimum reported as part of source selection. This +# is absolute bare minimum information to get bits on the disk and doesn’t +# factor in filesystem overhead. Obtained from the size value of the +# chosen source as found at /casper/install-sources.yaml. +# 2) Room for boot - we employ a scaling system to help select the recommended +# size of a dedicated /boot and/or efi system partition (see above). If +# /boot is not actually a separate partition, this space needs to be +# accounted for as part of the planned rootfs size. The files that would +# otherwise be stored in a dedicated UEFI partition are presumed to be +# negligible on non-UEFI systems. As /boot itself uses a scaling sizing +# system, use the bootfs_scale.maximum value for the purposes of finding +# minimum suggested size. The maximum value is to be used instead of the +# minimum as the boot scaling system will also make adjustments, and we +# don’t want to short change the other calculations. +# 3) Room to grow - while meaningful work can sometimes be possible on a full +# disk, it’s not the sort of thing to suggest in a guided install. +# Suggest for room to grow max(2GiB, 80% of source minimum). +# 4) Swap - Ubuntu policy recommends swap, either in the form of a partition or +# a swapfile. Curtin has an existing swap size recommendation at +# curtin.swap.suggested_swapsize(). +def calculate_suggested_install_min(source_min: int, + part_align: int = MiB) -> int: + room_for_boot = bootfs_scale.maximum + room_to_grow = max(2 * GiB, math.ceil(.8 * source_min)) + room_for_swap = swap.suggested_swapsize() + total = source_min + room_for_boot + room_to_grow + room_for_swap + return align_up(total, part_align) diff --git a/subiquity/common/filesystem/tests/test_sizes.py b/subiquity/common/filesystem/tests/test_sizes.py index 191441ca..048f06cd 100644 --- a/subiquity/common/filesystem/tests/test_sizes.py +++ b/subiquity/common/filesystem/tests/test_sizes.py @@ -14,15 +14,19 @@ # along with this program. If not, see . import unittest +from unittest import mock from subiquity.common.filesystem.sizes import ( bootfs_scale, + calculate_guided_resize, + calculate_suggested_install_min, get_efi_size, get_bootfs_size, PartitionScaleFactors, scale_partitions, uefi_scale, ) +from subiquity.common.types import GuidedResizeValues class TestPartitionSizeScaling(unittest.TestCase): @@ -74,3 +78,68 @@ class TestPartitionSizeScaling(unittest.TestCase): bootfs_size = get_bootfs_size(disk_size) self.assertTrue(bootfs_scale.maximum > bootfs_size) self.assertTrue(bootfs_size > bootfs_scale.minimum) + + +class TestCalculateGuidedResize(unittest.TestCase): + def test_ignore_nonresizable(self): + actual = calculate_guided_resize( + part_min=-1, part_size=100 << 30, install_min=10 << 30) + self.assertIsNone(actual) + + def test_too_small(self): + actual = calculate_guided_resize( + part_min=95 << 30, part_size=100 << 30, install_min=10 << 30) + self.assertIsNone(actual) + + def test_even_split(self): + # 8 GiB * 1.25 == 10 GiB + size = 10 << 30 + actual = calculate_guided_resize( + part_min=8 << 30, part_size=100 << 30, install_min=size) + expected = GuidedResizeValues( + minimum=size, recommended=50 << 30, maximum=(100 << 30) - size) + self.assertEqual(expected, actual) + + def test_weighted_split(self): + actual = calculate_guided_resize( + part_min=40 << 30, part_size=240 << 30, install_min=10 << 30) + expected = GuidedResizeValues( + minimum=50 << 30, recommended=200 << 30, maximum=230 << 30) + self.assertEqual(expected, actual) + + +class TestCalculateInstallMin(unittest.TestCase): + @mock.patch('subiquity.common.filesystem.sizes.swap.suggested_swapsize') + @mock.patch('subiquity.common.filesystem.sizes.bootfs_scale') + def test_small_setups(self, bootfs_scale, swapsize): + swapsize.return_value = 1 << 30 + bootfs_scale.maximum = 1 << 30 + source_min = 1 << 30 + # with a small source, we hit the default 2GiB padding + self.assertEqual(5 << 30, calculate_suggested_install_min(source_min)) + + @mock.patch('subiquity.common.filesystem.sizes.swap.suggested_swapsize') + @mock.patch('subiquity.common.filesystem.sizes.bootfs_scale') + def test_small_setups_big_swap(self, bootfs_scale, swapsize): + swapsize.return_value = 10 << 30 + bootfs_scale.maximum = 1 << 30 + source_min = 1 << 30 + self.assertEqual(14 << 30, calculate_suggested_install_min(source_min)) + + @mock.patch('subiquity.common.filesystem.sizes.swap.suggested_swapsize') + @mock.patch('subiquity.common.filesystem.sizes.bootfs_scale') + def test_small_setups_big_boot(self, bootfs_scale, swapsize): + swapsize.return_value = 1 << 30 + bootfs_scale.maximum = 10 << 30 + source_min = 1 << 30 + self.assertEqual(14 << 30, calculate_suggested_install_min(source_min)) + + @mock.patch('subiquity.common.filesystem.sizes.swap.suggested_swapsize') + @mock.patch('subiquity.common.filesystem.sizes.bootfs_scale') + def test_big_source(self, bootfs_scale, swapsize): + swapsize.return_value = 1 << 30 + bootfs_scale.maximum = 2 << 30 + source_min = 10 << 30 + # a bigger source should hit 80% padding + expected = (10 + 8 + 1 + 2) << 30 + self.assertEqual(expected, calculate_suggested_install_min(source_min)) diff --git a/subiquity/common/types.py b/subiquity/common/types.py index dacab493..e2eeef21 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -327,9 +327,17 @@ class StorageResponseV2: disks: List[Disk] need_root: bool # if True, there is not yet a partition mounted at "/" need_boot: bool # if True, there is not yet a boot partition + install_minimum_size: int error_report: Optional[ErrorReportRef] = None +@attr.s(auto_attribs=True) +class GuidedResizeValues: + minimum: int + recommended: int + maximum: int + + @attr.s(auto_attribs=True) class AddPartitionV2: disk_id: str diff --git a/subiquity/models/filesystem.py b/subiquity/models/filesystem.py index b28230a0..db873186 100644 --- a/subiquity/models/filesystem.py +++ b/subiquity/models/filesystem.py @@ -36,6 +36,7 @@ from subiquity.common.types import Bootloader, OsProber log = logging.getLogger('subiquity.models.filesystem') MiB = 1024 * 1024 +GiB = 1024 * 1024 * 1024 def _set_backlinks(obj): diff --git a/subiquity/server/controllers/filesystem.py b/subiquity/server/controllers/filesystem.py index 95f93cac..8e38ea2a 100644 --- a/subiquity/server/controllers/filesystem.py +++ b/subiquity/server/controllers/filesystem.py @@ -24,7 +24,6 @@ from typing import List import pyudev - from subiquitycore.async_helpers import ( run_in_thread, schedule_task, @@ -314,12 +313,17 @@ class FilesystemController(SubiquityController, FilesystemManipulator): return p raise ValueError(f'Partition {number} on {disk.id} not found') + def calculate_suggested_install_min(self): + source_min = self.app.base_model.source.current.size + return sizes.calculate_suggested_install_min(source_min) + async def v2_GET(self) -> StorageResponseV2: disks = self.model._all(type='disk') return StorageResponseV2( disks=[labels.for_client(d) for d in disks], need_root=not self.model.is_root_mounted(), need_boot=self.model.needs_bootloader_partition(), + install_minimum_size=self.calculate_suggested_install_min(), ) async def v2_POST(self) -> StorageResponseV2: