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: