diff --git a/snapcraft.yaml b/snapcraft.yaml index 86095a47..0f2ee93f 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -49,7 +49,7 @@ parts: plugin: python source-type: git source: https://git.launchpad.net/curtin - source-commit: 8c13139961b9decc23a57f2f515f5248a21e1b06 + source-commit: a18e5feb755f9ee04a284830114b2a09b03af175 build-packages: - shared-mime-info - zlib1g-dev diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index d5e5bb49..5e82a6cb 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -291,7 +291,7 @@ class API: class edit_partition: """required field number - optional fields wipe, mount, format + optional fields wipe, mount, format, size It is an error to do wipe=null and change the format. It is an error to modify other Partition fields. """ diff --git a/subiquity/common/filesystem/labels.py b/subiquity/common/filesystem/labels.py index 791c4849..368e93c7 100644 --- a/subiquity/common/filesystem/labels.py +++ b/subiquity/common/filesystem/labels.py @@ -310,6 +310,7 @@ def _for_client_partition(partition, *, min_size=0): annotations=annotations(partition) + usage_labels(partition), os=partition.os, offset=partition.offset, + resize=partition.resize, mount=partition.mount, format=partition.format) diff --git a/subiquity/common/filesystem/manipulator.py b/subiquity/common/filesystem/manipulator.py index dbdfbb37..e6ca3f51 100644 --- a/subiquity/common/filesystem/manipulator.py +++ b/subiquity/common/filesystem/manipulator.py @@ -15,6 +15,8 @@ import logging +from curtin.block import get_resize_fstypes + from subiquity.common.filesystem import boot, gaps from subiquity.common.types import Bootloader from subiquity.models.filesystem import ( @@ -43,7 +45,7 @@ class FilesystemManipulator: self.model.remove_mount(mount) def create_filesystem(self, volume, spec): - if spec['fstype'] is None: + if spec.get('fstype') is None: # prep partitions are always wiped (and never have a filesystem) if getattr(volume, 'flag', None) != 'prep': volume.wipe = None @@ -61,9 +63,9 @@ class FilesystemManipulator: volume.flag = "swap" elif volume.flag == "swap": volume.flag = "" - if spec['fstype'] == "swap": + if spec.get('fstype') == "swap": self.model.add_mount(fs, "") - if spec['fstype'] is None and spec['use_swap']: + if spec.get('fstype') is None and spec.get('use_swap'): self.model.add_mount(fs, "") self.create_mount(fs, spec) return fs @@ -165,19 +167,29 @@ class FilesystemManipulator: self.delete_partition(p, True) self.clear(disk) + def can_resize_partition(self, partition): + if not partition.preserve: + return True + if partition.format not in get_resize_fstypes(): + return False + return True + def partition_disk_handler(self, disk, spec, *, partition=None, gap=None): log.debug('partition_disk_handler: %s %s %s %s', disk, spec, partition, gap) if partition is not None: - if 'size' in spec: + if 'size' in spec and spec['size'] != partition.size: trailing, gap_size = \ gaps.movable_trailing_partitions_and_gap_size(partition) new_size = align_up(spec['size']) size_change = new_size - partition.size if size_change > gap_size: raise Exception("partition size too large") + if not self.can_resize_partition(partition): + raise Exception("partition cannot support resize") partition.size = new_size + partition.resize = True for part in trailing: part.offset += size_change self.delete_filesystem(partition.fs()) diff --git a/subiquity/common/filesystem/tests/test_manipulator.py b/subiquity/common/filesystem/tests/test_manipulator.py index 4e8aff77..108ee3c2 100644 --- a/subiquity/common/filesystem/tests/test_manipulator.py +++ b/subiquity/common/filesystem/tests/test_manipulator.py @@ -29,6 +29,7 @@ from subiquity.models.tests.test_filesystem import ( make_disk, make_model, make_partition, + make_filesystem, ) from subiquity.models.filesystem import ( Bootloader, @@ -663,3 +664,26 @@ class TestReformat(unittest.TestCase): disk = make_disk(self.manipulator.model, ptable=None) self.manipulator.reformat(disk, 'msdos') self.assertEqual('msdos', disk.ptable) + + +class TestCanResize(unittest.TestCase): + def setUp(self): + self.manipulator = make_manipulator() + self.manipulator.model._probe_data = {} + + def test_resize_unpreserved(self): + disk = make_disk(self.manipulator.model, ptable=None) + part = make_partition(self.manipulator.model, disk, preserve=False) + self.assertTrue(self.manipulator.can_resize_partition(part)) + + def test_resize_ext4(self): + disk = make_disk(self.manipulator.model, ptable=None) + part = make_partition(self.manipulator.model, disk, preserve=True) + make_filesystem(self.manipulator.model, partition=part, fstype='ext4') + self.assertTrue(self.manipulator.can_resize_partition(part)) + + def test_resize_invalid(self): + disk = make_disk(self.manipulator.model, ptable=None) + part = make_partition(self.manipulator.model, disk, preserve=True) + make_filesystem(self.manipulator.model, partition=part, fstype='asdf') + self.assertFalse(self.manipulator.can_resize_partition(part)) diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 290c98cf..ad89f397 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -267,6 +267,7 @@ class Partition: boot: Optional[bool] = None os: Optional[OsProber] = None offset: Optional[int] = None + resize: Optional[bool] = None @attr.s(auto_attribs=True) diff --git a/subiquity/models/filesystem.py b/subiquity/models/filesystem.py index 9fe3303b..baf655e3 100644 --- a/subiquity/models/filesystem.py +++ b/subiquity/models/filesystem.py @@ -672,6 +672,7 @@ class Partition(_Formattable): name = attr.ib(default=None) multipath = attr.ib(default=None) offset = attr.ib(default=None) + resize = attr.ib(default=None) def available(self): if self.flag in ['bios_grub', 'prep'] or self.grub_device: diff --git a/subiquity/models/tests/test_filesystem.py b/subiquity/models/tests/test_filesystem.py index 92230808..e495f985 100644 --- a/subiquity/models/tests/test_filesystem.py +++ b/subiquity/models/tests/test_filesystem.py @@ -21,6 +21,7 @@ from subiquity.models.filesystem import ( Bootloader, dehumanize_size, Disk, + Filesystem, FilesystemModel, get_raid_size, humanize_size, @@ -179,6 +180,10 @@ def make_partition(model, device=None, *, preserve=False, size=None, return partition +def make_filesystem(model, *, partition, **kw): + return Filesystem(m=model, volume=partition, **kw) + + def make_model_and_partition(bootloader=None): model, disk = make_model_and_disk(bootloader) return model, make_partition(model, disk) diff --git a/subiquity/server/controllers/filesystem.py b/subiquity/server/controllers/filesystem.py index 17347031..5e174f6f 100644 --- a/subiquity/server/controllers/filesystem.py +++ b/subiquity/server/controllers/filesystem.py @@ -391,15 +391,18 @@ class FilesystemController(SubiquityController, FilesystemManipulator): log.debug(data) disk = self.model._one(id=data.disk_id) partition = self.get_partition(disk, data.partition.number) - if data.partition.size not in (None, partition.size): + if data.partition.size not in (None, partition.size) \ + and self.app.opts.storage_version < 2: raise ValueError('edit_partition does not support changing size') if data.partition.boot is not None \ and data.partition.boot != partition.boot: raise ValueError('edit_partition does not support changing boot') - spec = { - 'fstype': data.partition.format or partition.format, - 'mount': data.partition.mount or partition.mount, - } + spec = {'mount': data.partition.mount or partition.mount} + if data.partition.format is not None \ + and data.partition.format != partition.format: + spec['fstype'] = data.partition.format + if data.partition.size is not None: + spec['size'] = data.partition.size self.partition_disk_handler(disk, spec, partition=partition) return await self.v2_GET() diff --git a/subiquity/tests/api/test_api.py b/subiquity/tests/api/test_api.py index 00788e85..8151caa0 100755 --- a/subiquity/tests/api/test_api.py +++ b/subiquity/tests/api/test_api.py @@ -11,6 +11,7 @@ import unittest from unittest.mock import patch from urllib.parse import unquote +from subiquitycore.tests import SubiTestCase from subiquitycore.utils import astart_command default_timeout = 10 @@ -26,6 +27,11 @@ def first(items, key, value): return next(find(items, key, value)) +def match(items, **kwargs): + return [item for item in items + if all(item.get(k) == v for k, v in kwargs.items())] + + def timeout(multiplier=1): def wrapper(coro): @wraps(coro) @@ -128,7 +134,7 @@ class Server(Client): pass -class TestAPI(unittest.IsolatedAsyncioTestCase): +class TestAPI(unittest.IsolatedAsyncioTestCase, SubiTestCase): def assertDictSubset(self, expected, actual): """All keys in dictionary expected, and matching values, must match keys and values in actual. Actual may contain additional keys and @@ -291,7 +297,7 @@ class TestFlow(TestAPI): edit_sda2 = first(edit_sda['partitions'], 'number', 2) for key in 'size', 'number', 'mount', 'boot': - self.assertEqual(add_sda2[key], edit_sda2[key]) + self.assertEqual(add_sda2[key], edit_sda2[key], key) self.assertEqual('ext4', edit_sda2['format']) del_resp = await inst.post('/storage/v2/delete_partition', data) @@ -830,10 +836,7 @@ class TestPartitionTableEditing(TestAPI): [sda] = resp['disks'] [p1, p2, p3, p4] = sda['partitions'] e1.pop('annotations') - e1.update({ - 'mount': '/boot/efi', - 'grub_device': True, - }) + e1.update({'mount': '/boot/efi', 'grub_device': True}) self.assertDictSubset(e1, p1) self.assertEqual(e2, p2) self.assertEqual(e3, p3) @@ -847,6 +850,77 @@ class TestPartitionTableEditing(TestAPI): } self.assertDictSubset(e4, p4) + @timeout() + async def test_resize(self): + # load config, edit size, use that for server + with open('examples/ubuntu-and-free-space.json', 'r') as fp: + data = json.load(fp) + + # expand sda3 to use the rest of the disk + def get_size(key): + return int(data['storage']['blockdev'][key]['attrs']['size']) + + sda_size = get_size('/dev/sda') + sda1_size = get_size('/dev/sda1') + sda2_size = get_size('/dev/sda2') + sda3_size = sda_size - sda1_size - sda2_size - (2 << 20) + data['storage']['blockdev']['/dev/sda3']['attrs']['size'] = \ + str(sda3_size) + cfg = self.tmp_path('machine-config.json') + with open(cfg, 'w') as fp: + json.dump(data, fp) + + extra = ['--storage-version', '2'] + async with start_server(cfg, extra_args=extra) as inst: + # Disk has 3 existing partitions and no free space. + resp = await inst.get('/storage/v2') + [sda] = resp['disks'] + [orig_p1, orig_p2, orig_p3] = sda['partitions'] + + p3 = orig_p3.copy() + p3['size'] = 10 << 30 + data = { + 'disk_id': 'disk-sda', + 'partition': p3, + } + resp = await inst.post('/storage/v2/edit_partition', data) + [sda] = resp['disks'] + [_, _, actual_p3, g1] = sda['partitions'] + self.assertEqual(10 << 30, actual_p3['size']) + self.assertEqual(True, actual_p3['resize']) + self.assertIsNone(actual_p3['wipe']) + end_size = orig_p3['size'] - (10 << 30) + self.assertEqual(end_size, g1['size']) + + expected_p1 = orig_p1.copy() + expected_p1.pop('annotations') + expected_p1.update({'mount': '/boot/efi', 'grub_device': True}) + expected_p3 = actual_p3 + data = { + 'disk_id': 'disk-sda', + 'gap': g1, + 'partition': { + 'format': 'ext4', + 'mount': '/srv', + } + } + resp = await inst.post('/storage/v2/add_partition', data) + [sda] = resp['disks'] + [actual_p1, actual_p2, actual_p3, actual_p4] = sda['partitions'] + self.assertDictSubset(expected_p1, actual_p1) + self.assertEqual(orig_p2, actual_p2) + self.assertEqual(expected_p3, actual_p3) + self.assertEqual(end_size, actual_p4['size']) + self.assertEqual('Partition', actual_p4['$type']) + + v1resp = await inst.get('/storage') + config = v1resp['config'] + [sda3] = match(config, type='partition', number=3) + [sda3_format] = match(config, type='format', volume=sda3['id']) + self.assertTrue(sda3['preserve']) + self.assertTrue(sda3['resize']) + self.assertTrue(sda3_format['preserve']) + class TestGap(TestAPI): async def test_blank_disk_is_one_big_gap(self):