Skip to content

Commit 5a54902

Browse files
authored
Add support for Btrfs snapshots (#3500)
* Add btrfs snapshot support * Update * Update * Update
1 parent d3f32f3 commit 5a54902

8 files changed

Lines changed: 219 additions & 13 deletions

File tree

archinstall/lib/disk/disk_menu.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
from dataclasses import dataclass
22
from typing import override
33

4-
from archinstall.lib.models.device_model import DiskLayoutConfiguration, DiskLayoutType, LvmConfiguration
4+
from archinstall.lib.models.device_model import (
5+
BtrfsOptions,
6+
DiskLayoutConfiguration,
7+
DiskLayoutType,
8+
LvmConfiguration,
9+
SnapshotConfig,
10+
SnapshotType,
11+
)
512
from archinstall.lib.translationhandler import tr
13+
from archinstall.tui.curses_menu import SelectMenu
614
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
15+
from archinstall.tui.result import ResultType
16+
from archinstall.tui.types import Alignment, FrameProperties
717

818
from ..interactions.disk_conf import select_disk_config, select_lvm_config
919
from ..menu.abstract_menu import AbstractSubMenu
@@ -14,16 +24,24 @@
1424
class DiskMenuConfig:
1525
disk_config: DiskLayoutConfiguration | None
1626
lvm_config: LvmConfiguration | None
27+
btrfs_snapshot_config: SnapshotConfig | None
1728

1829

1930
class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskLayoutConfiguration]):
2031
def __init__(self, disk_layout_config: DiskLayoutConfiguration | None):
2132
if not disk_layout_config:
22-
self._disk_menu_config = DiskMenuConfig(disk_config=None, lvm_config=None)
33+
self._disk_menu_config = DiskMenuConfig(
34+
disk_config=None,
35+
lvm_config=None,
36+
btrfs_snapshot_config=None,
37+
)
2338
else:
39+
snapshot_config = disk_layout_config.btrfs_options.snapshot_config if disk_layout_config.btrfs_options else None
40+
2441
self._disk_menu_config = DiskMenuConfig(
2542
disk_config=disk_layout_config,
2643
lvm_config=disk_layout_config.lvm_config,
44+
btrfs_snapshot_config=snapshot_config,
2745
)
2846

2947
menu_optioons = self._define_menu_options()
@@ -52,6 +70,14 @@ def _define_menu_options(self) -> list[MenuItem]:
5270
dependencies=[self._check_dep_lvm],
5371
key='lvm_config',
5472
),
73+
MenuItem(
74+
text='Btrfs snapshots',
75+
action=self._select_btrfs_snapshots,
76+
value=self._disk_menu_config.btrfs_snapshot_config,
77+
preview_action=self._prev_btrfs_snapshots,
78+
dependencies=[self._check_dep_btrfs],
79+
key='btrfs_snapshot_config',
80+
),
5581
]
5682

5783
@override
@@ -60,6 +86,7 @@ def run(self) -> DiskLayoutConfiguration | None:
6086

6187
if self._disk_menu_config.disk_config:
6288
self._disk_menu_config.disk_config.lvm_config = self._disk_menu_config.lvm_config
89+
self._disk_menu_config.disk_config.btrfs_options = BtrfsOptions(snapshot_config=self._disk_menu_config.btrfs_snapshot_config)
6390
return self._disk_menu_config.disk_config
6491

6592
return None
@@ -72,10 +99,15 @@ def _check_dep_lvm(self) -> bool:
7299

73100
return False
74101

75-
def _select_disk_layout_config(
76-
self,
77-
preset: DiskLayoutConfiguration | None,
78-
) -> DiskLayoutConfiguration | None:
102+
def _check_dep_btrfs(self) -> bool:
103+
disk_layout_conf: DiskLayoutConfiguration | None = self._menu_item_group.find_by_key('disk_config').value
104+
105+
if disk_layout_conf:
106+
return disk_layout_conf.is_default_btrfs()
107+
108+
return False
109+
110+
def _select_disk_layout_config(self, preset: DiskLayoutConfiguration | None) -> DiskLayoutConfiguration | None:
79111
disk_config = select_disk_config(preset)
80112

81113
if disk_config != preset:
@@ -91,6 +123,38 @@ def _select_lvm_config(self, preset: LvmConfiguration | None) -> LvmConfiguratio
91123

92124
return preset
93125

126+
def _select_btrfs_snapshots(self, preset: SnapshotConfig | None) -> SnapshotConfig | None:
127+
preset_type = preset.snapshot_type if preset else None
128+
129+
group = MenuItemGroup.from_enum(
130+
SnapshotType,
131+
sort_items=True,
132+
preset=preset_type,
133+
)
134+
135+
result = SelectMenu[SnapshotType](
136+
group,
137+
allow_reset=True,
138+
allow_skip=True,
139+
frame=FrameProperties.min(tr('Snapshot type')),
140+
alignment=Alignment.CENTER,
141+
).run()
142+
143+
snapshot_type: SnapshotType | None = None
144+
145+
match result.type_:
146+
case ResultType.Skip:
147+
return preset
148+
case ResultType.Reset:
149+
return None
150+
case ResultType.Selection:
151+
snapshot_type = result.get_value()
152+
153+
if not snapshot_type:
154+
return None
155+
156+
return SnapshotConfig(snapshot_type=snapshot_type)
157+
94158
def _prev_disk_layouts(self, item: MenuItem) -> str | None:
95159
if not item.value:
96160
return None
@@ -146,3 +210,10 @@ def _prev_lvm_config(self, item: MenuItem) -> str | None:
146210
return output
147211

148212
return None
213+
214+
def _prev_btrfs_snapshots(self, item: MenuItem) -> str | None:
215+
if not item.value:
216+
return None
217+
218+
snapshot_config: SnapshotConfig = item.value
219+
return tr('Snapshot type: {}').format(snapshot_config.snapshot_type.value)

archinstall/lib/global_menu.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ def _prev_disk_config(self, item: MenuItem) -> str | None:
335335
if disk_layout_conf.lvm_config:
336336
output += '{}: {}'.format(tr('LVM configuration type'), disk_layout_conf.lvm_config.config_type.display_msg())
337337

338+
if disk_layout_conf.btrfs_options:
339+
btrfs_options = disk_layout_conf.btrfs_options
340+
if btrfs_options.snapshot_config:
341+
output += tr('Btrfs snapshot type: {}').format(btrfs_options.snapshot_config.snapshot_type.value)
342+
338343
return output
339344

340345
return None

archinstall/lib/installer.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
PartitionModification,
2626
SectorSize,
2727
Size,
28+
SnapshotType,
2829
SubvolumeModification,
2930
Unit,
3031
)
@@ -909,6 +910,51 @@ def minimal_installation(
909910
if hasattr(plugin, 'on_install'):
910911
plugin.on_install(self)
911912

913+
def setup_btrfs_snapshot(
914+
self,
915+
snapshot_type: SnapshotType,
916+
bootloader: Bootloader | None = None,
917+
) -> None:
918+
if snapshot_type == SnapshotType.Snapper:
919+
debug('Setting up Btrfs snapper')
920+
self.pacman.strap('snapper')
921+
922+
snapper: dict[str, str] = {
923+
'root': '/',
924+
'home': '/home',
925+
}
926+
927+
for config_name, mountpoint in snapper.items():
928+
command = [
929+
'arch-chroot',
930+
str(self.target),
931+
'snapper',
932+
'--no-dbus',
933+
'-c',
934+
config_name,
935+
'create-config',
936+
mountpoint,
937+
]
938+
939+
try:
940+
SysCommand(command, peek_output=True)
941+
except SysCallError as err:
942+
raise DiskError(f'Could not setup Btrfs snapper: {err}')
943+
944+
self.enable_service('snapper-timeline.timer')
945+
self.enable_service('snapper-cleanup.timer')
946+
elif snapshot_type == SnapshotType.Timeshift:
947+
debug('Setting up Btrfs timeshift')
948+
949+
self.pacman.strap('cronie')
950+
self.pacman.strap('timeshift')
951+
952+
self.enable_service('cronie.service')
953+
954+
if bootloader and bootloader == Bootloader.Grub:
955+
self.pacman.strap('grub-btrfs')
956+
self.enable_service('grub-btrfs.service')
957+
912958
def setup_swap(self, kind: str = 'zram') -> None:
913959
if kind == 'zram':
914960
info('Setting up swap on zram')

archinstall/lib/models/device_model.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@ class _DiskLayoutConfigurationSerialization(TypedDict):
4040
device_modifications: NotRequired[list[_DeviceModificationSerialization]]
4141
lvm_config: NotRequired[_LvmConfigurationSerialization]
4242
mountpoint: NotRequired[str]
43+
btrfs_options: NotRequired[_BtrfsOptionsSerialization]
4344

4445

4546
@dataclass
4647
class DiskLayoutConfiguration:
4748
config_type: DiskLayoutType
4849
device_modifications: list[DeviceModification] = field(default_factory=list)
4950
lvm_config: LvmConfiguration | None = None
51+
btrfs_options: BtrfsOptions | None = None
5052

5153
# used for pre-mounted config
5254
mountpoint: Path | None = None
@@ -66,6 +68,9 @@ def json(self) -> _DiskLayoutConfigurationSerialization:
6668
if self.lvm_config:
6769
config['lvm_config'] = self.lvm_config.json()
6870

71+
if self.btrfs_options:
72+
config['btrfs_options'] = self.btrfs_options.json()
73+
6974
return config
7075

7176
@classmethod
@@ -174,8 +179,22 @@ def parse_arg(cls, disk_config: _DiskLayoutConfigurationSerialization) -> DiskLa
174179
if (lvm_arg := disk_config.get('lvm_config', None)) is not None:
175180
config.lvm_config = LvmConfiguration.parse_arg(lvm_arg, config)
176181

182+
if config.is_default_btrfs():
183+
if (btrfs_arg := disk_config.get('btrfs_options', None)) is not None:
184+
config.btrfs_options = BtrfsOptions.parse_arg(btrfs_arg)
185+
177186
return config
178187

188+
def is_default_btrfs(self) -> bool:
189+
if self.config_type == DiskLayoutType.Default:
190+
for mod in self.device_modifications:
191+
for part in mod.partitions:
192+
if part.is_create_or_modify():
193+
if part.fs_type == FilesystemType.Btrfs:
194+
return True
195+
196+
return False
197+
179198

180199
class PartitionTable(Enum):
181200
GPT = 'gpt'
@@ -1309,13 +1328,46 @@ def get_root_volume(self) -> LvmVolume | None:
13091328
return None
13101329

13111330

1312-
# def get_lv_crypt_uuid(self, lv: LvmVolume, encryption: EncryptionType) -> str:
1313-
# """
1314-
# Find the LUKS superblock UUID for the device that
1315-
# contains the given logical volume
1316-
# """
1317-
# for vg in self.vol_groups:
1318-
# if vg.contains_lv(lv):
1331+
class _BtrfsOptionsSerialization(TypedDict):
1332+
snapshot_config: _SnapshotConfigSerialization | None
1333+
1334+
1335+
class _SnapshotConfigSerialization(TypedDict):
1336+
type: str
1337+
1338+
1339+
class SnapshotType(Enum):
1340+
Snapper = 'Snapper'
1341+
Timeshift = 'Timeshift'
1342+
1343+
1344+
@dataclass
1345+
class SnapshotConfig:
1346+
snapshot_type: SnapshotType
1347+
1348+
def json(self) -> _SnapshotConfigSerialization:
1349+
return {'type': self.snapshot_type.value}
1350+
1351+
@staticmethod
1352+
def parse_args(args: _SnapshotConfigSerialization) -> SnapshotConfig | None:
1353+
return SnapshotConfig(SnapshotType(args['type']))
1354+
1355+
1356+
@dataclass
1357+
class BtrfsOptions:
1358+
snapshot_config: SnapshotConfig | None
1359+
1360+
def json(self) -> _BtrfsOptionsSerialization:
1361+
return {'snapshot_config': self.snapshot_config.json() if self.snapshot_config else None}
1362+
1363+
@staticmethod
1364+
def parse_arg(arg: _BtrfsOptionsSerialization) -> BtrfsOptions | None:
1365+
snapshot_args = arg.get('snapshot_config')
1366+
if snapshot_args:
1367+
snapshot_config = SnapshotConfig.parse_args(snapshot_args)
1368+
return BtrfsOptions(snapshot_config)
1369+
1370+
return None
13191371

13201372

13211373
class _DeviceModificationSerialization(TypedDict):

archinstall/scripts/guided.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ def perform_installation(mountpoint: Path) -> None:
141141
if servies := config.services:
142142
installation.enable_service(servies)
143143

144+
if disk_config.is_default_btrfs():
145+
btrfs_options = disk_config.btrfs_options
146+
snapshot_config = btrfs_options.snapshot_config if btrfs_options else None
147+
snapshot_type = snapshot_config.snapshot_type if snapshot_config else None
148+
if snapshot_type:
149+
installation.setup_btrfs_snapshot(snapshot_type, config.bootloader)
150+
144151
# If the user provided custom commands to be run post-installation, execute them now.
145152
if cc := config.custom_commands:
146153
run_custom_user_commands(cc, installation)

archinstall/tui/menu_item.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from collections.abc import Callable
44
from dataclasses import dataclass, field
5+
from enum import Enum
56
from functools import cached_property
67
from typing import Any, ClassVar
78

@@ -119,6 +120,20 @@ def yes_no() -> 'MenuItemGroup':
119120
sort_items=True,
120121
)
121122

123+
@staticmethod
124+
def from_enum(
125+
enum_cls: type[Enum],
126+
sort_items: bool = False,
127+
preset: Enum | None = None,
128+
) -> 'MenuItemGroup':
129+
items = [MenuItem(elem.value, value=elem) for elem in enum_cls]
130+
group = MenuItemGroup(items, sort_items=False)
131+
132+
if preset is not None:
133+
group.set_selected_by_value(preset)
134+
135+
return group
136+
122137
def set_preview_for_all(self, action: Callable[[Any], str | None]) -> None:
123138
for item in self.items:
124139
item.preview_action = action

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ def config_fixture() -> Path:
88
return Path(__file__).parent / 'data' / 'test_config.json'
99

1010

11+
@pytest.fixture(scope='session')
12+
def btrfs_config_fixture() -> Path:
13+
return Path(__file__).parent / 'data' / 'test_config_btrfs.json'
14+
15+
1116
@pytest.fixture(scope='session')
1217
def creds_fixture() -> Path:
1318
return Path(__file__).parent / 'data' / 'test_creds.json'

tests/data/test_config.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
],
1111
"disk_config": {
1212
"config_type": "default_layout",
13+
"btrfs_options": {
14+
"snapshot_config": {
15+
"type": "Snapper"
16+
}
17+
},
1318
"device_modifications": [
1419
{
1520
"device": "/dev/sda",

0 commit comments

Comments
 (0)