Skip to content

Commit df46c56

Browse files
committed
Implemented explicit source boot stanza exclusion
1 parent 1e3b697 commit df46c56

File tree

13 files changed

+142
-51
lines changed

13 files changed

+142
-51
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ I'm assuming here that the next available subvolume ID was 503 (an increment of
139139
With this setup the newly created snapshot ended up being nested under the root subvolume but you can of course make your own adjustments as you see fit. This tool will only create the destination directory in case it doesn't exist. It won't do anything other than that.
140140
I've personally created another subvolume named @rw-snapshots directly under the default filesystem subvolume (ID 5) and mounted it at /root/.refind-btrfs. In my case the logical path of rwsnap_2020-12-14_05-00-00_ID502 would be /@rw-snapshots/rwsnap_2020-12-14_05-00-00_ID502.
141141

142-
A generated manual boot stanza's file name is formatted like "{volume}_{loader}.conf" and converted to all lowercase letters which would result in, for this example, a file named "arch_vmlinuz-linux.conf". This file is then saved in a subdirectory (relative to rEFInd's root directory) named "btrfs-snapshot-stanzas" and finally included in the main config file by appending an "include" directive which would, again for this example, look like this: "include btrfs-snapshot-stanzas/arch_vmlinuz-linux.conf". This last step is performed only once, during an initial run. Afterwards, it is detected as already being included in the main config file.
142+
A generated manual boot stanza's filename is formatted like "{volume}_{loader}.conf" and converted to all lowercase letters which would result in, for this example, a file named "arch_vmlinuz-linux.conf". This file is then saved in a subdirectory (relative to rEFInd's root directory) named "btrfs-snapshot-stanzas" and finally included in the main config file by appending an "include" directive which would, again for this example, look like this: "include btrfs-snapshot-stanzas/arch_vmlinuz-linux.conf". This last step is performed only once, during an initial run. Afterwards, it is detected as already being included in the main config file.
143143

144144
You are free to rearrange the appended include directives however you want, this tool does not care about where exactly they appear in the main config file. This is particularly useful in case you've defined multiple boot stanzas (each one pointing to a different kernel image, for example) and wish to alter the order of the boot menu entries.
145145

src/refind_btrfs/boot/boot_stanza.py

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@
2323

2424
from __future__ import annotations
2525

26+
import inspect
2627
import re
2728
from collections import defaultdict
28-
from functools import cached_property
29+
from functools import cached_property, singledispatchmethod
2930
from itertools import chain
30-
from typing import DefaultDict, Iterable, Iterator, Optional, Set
31+
from typing import Any, DefaultDict, Iterable, Iterator, Optional, Set
3132

3233
from more_itertools import always_iterable, last
3334

@@ -207,22 +208,14 @@ def with_sub_menus(self, sub_menus: Iterable[SubMenu]) -> BootStanza:
207208

208209
return self
209210

210-
def is_matched_with(self, block_device: BlockDevice) -> bool:
211-
if self.can_be_used_for_bootable_snapshot():
212-
boot_options = self.boot_options
213-
214-
if boot_options.is_matched_with(block_device):
215-
return True
216-
else:
217-
sub_menus = self.sub_menus
218-
219-
if has_items(sub_menus):
220-
return any(
221-
sub_menu.is_matched_with(block_device)
222-
for sub_menu in none_throws(sub_menus)
223-
)
211+
@singledispatchmethod
212+
def is_matched_with(self, argument: Any) -> bool:
213+
frame = none_throws(inspect.currentframe())
224214

225-
return False
215+
raise NotImplementedError(
216+
f"Cannot call the '{inspect.getframeinfo(frame).function}' method "
217+
f"for parameter of type '{type(argument).__name__}'!"
218+
)
226219

227220
def has_unmatched_boot_files(self) -> bool:
228221
boot_files_check_result = self.boot_files_check_result
@@ -276,6 +269,28 @@ def validate_icon_path(
276269
"icon generation!"
277270
)
278271

272+
@is_matched_with.register(BlockDevice)
273+
def _is_matched_with_block_device(self, block_device: BlockDevice) -> bool:
274+
if self.can_be_used_for_bootable_snapshot():
275+
boot_options = self.boot_options
276+
277+
if boot_options.is_matched_with(block_device):
278+
return True
279+
else:
280+
sub_menus = self.sub_menus
281+
282+
if has_items(sub_menus):
283+
return any(
284+
sub_menu.is_matched_with(block_device)
285+
for sub_menu in none_throws(sub_menus)
286+
)
287+
288+
return False
289+
290+
@is_matched_with.register(str)
291+
def _is_matched_with_loader_filename(self, loader_filename: str) -> bool:
292+
return self._loader_filename == loader_filename
293+
279294
def _get_all_boot_file_paths(
280295
self,
281296
) -> Iterator[tuple[BootFilePathSource, str]]:
@@ -369,17 +384,13 @@ def sub_menus(self) -> Optional[list[SubMenu]]:
369384
return self._sub_menus
370385

371386
@cached_property
372-
def file_name(self) -> str:
387+
def filename(self) -> str:
373388
if self.can_be_used_for_bootable_snapshot():
374389
normalized_volume = self.normalized_volume
375-
dir_separator_pattern = re.compile(constants.DIR_SEPARATOR_PATTERN)
376-
split_loader_path = dir_separator_pattern.split(
377-
none_throws(self.loader_path)
378-
)
379-
loader = last(split_loader_path)
390+
loader_filename = self._loader_filename
380391
extension = constants.CONFIG_FILE_EXTENSION
381392

382-
return f"{normalized_volume}_{loader}{extension}".lower()
393+
return f"{normalized_volume}_{loader_filename}{extension}".lower()
383394

384395
return constants.EMPTY_STR
385396

@@ -395,3 +406,17 @@ def all_boot_file_paths(self) -> DefaultDict[BootFilePathSource, Set[str]]:
395406
result[key].add(value)
396407

397408
return result
409+
410+
@cached_property
411+
def _loader_filename(self) -> str:
412+
loader_path = self.loader_path
413+
414+
if not is_none_or_whitespace(loader_path):
415+
dir_separator_pattern = re.compile(constants.DIR_SEPARATOR_PATTERN)
416+
split_loader_path = dir_separator_pattern.split(
417+
none_throws(self.loader_path)
418+
)
419+
420+
return last(split_loader_path)
421+
422+
return constants.EMPTY_STR

src/refind_btrfs/boot/refind_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,14 @@ def generate_new_from(
139139
migrated_boot_stanza = migration.migrate(
140140
file_path, boot_stanza_generation, icon_command
141141
)
142-
boot_stanza_file_name = migrated_boot_stanza.file_name
142+
boot_stanza_filename = migrated_boot_stanza.filename
143143

144-
if not is_none_or_whitespace(boot_stanza_file_name):
144+
if not is_none_or_whitespace(boot_stanza_filename):
145145
destination_directory = (
146146
parent_directory / constants.SNAPSHOT_STANZAS_DIR_NAME
147147
)
148148
boot_stanza_config_file_path = (
149-
destination_directory / boot_stanza_file_name
149+
destination_directory / boot_stanza_filename
150150
)
151151
boot_stanza_config = RefindConfig(
152152
boot_stanza_config_file_path.resolve()

src/refind_btrfs/common/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
)
8888

8989
CONFIG_FILE_EXTENSION = ".conf"
90-
CONFIG_FILE_NAME = PACKAGE_NAME + CONFIG_FILE_EXTENSION
90+
CONFIG_FILENAME = PACKAGE_NAME + CONFIG_FILE_EXTENSION
9191
SNAPSHOT_STANZAS_DIR_NAME = "btrfs-snapshot-stanzas"
9292
ICONS_DIR = "icons"
9393

@@ -98,7 +98,7 @@
9898
LIB_DIR = Path("lib")
9999

100100
FSTAB_FILE = ETC_DIR / "fstab"
101-
PACKAGE_CONFIG_FILE = ROOT_DIR / ETC_DIR / CONFIG_FILE_NAME
101+
PACKAGE_CONFIG_FILE = ROOT_DIR / ETC_DIR / CONFIG_FILENAME
102102
PACKAGE_LIB_DIR = ROOT_DIR / VAR_DIR / LIB_DIR / PACKAGE_NAME
103103
BTRFS_LOGOS_DIR = PACKAGE_LIB_DIR / ICONS_DIR / "btrfs_logo"
104104
DB_FILE = PACKAGE_LIB_DIR / "local_db"

src/refind_btrfs/common/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ class BootStanzaGenerationConfigKey(AutoNameToLower):
133133
REFIND_CONFIG = auto()
134134
INCLUDE_PATHS = auto()
135135
INCLUDE_SUB_MENUS = auto()
136+
SOURCE_EXCLUSION = auto()
136137
ICON = auto()
137138

138139

src/refind_btrfs/common/package_config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class BootStanzaGeneration(NamedTuple):
8383
refind_config: str
8484
include_paths: bool
8585
include_sub_menus: bool
86+
source_exclusion: Set[str]
8687
icon: Icon
8788

8889
def with_include_paths(
@@ -94,7 +95,11 @@ def with_include_paths(
9495
include_paths = boot_device is None
9596

9697
return BootStanzaGeneration(
97-
self.refind_config, include_paths, self.include_sub_menus, self.icon
98+
self.refind_config,
99+
include_paths,
100+
self.include_sub_menus,
101+
self.source_exclusion,
102+
self.icon,
98103
)
99104

100105

src/refind_btrfs/data/refind-btrfs.conf-sample

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,24 @@ cleanup_exclusion = []
128128
## "selection_count" value (greater than 10, for example) or, worse yet, by
129129
## setting it to "inf" can potentially result in an overcrowded "Boot Options"
130130
## menu.
131+
#
132+
## source_exclusion = <array<string>>
133+
## Array comprised of loader filenames ("loader") with which the matched source
134+
## boot stanzas can be arbitrarily excluded from processing, i.e., these boot
135+
## stanzas will not be taken into account during the generation phase.
136+
## For example, it can be defined as: ["vmlinuz-linux", "vmlinuz-linux-lts"].
137+
## WARNING: This array must not contain all of the matched source boot stanza's
138+
## loader filenames. If it does, an error is issued and a premature exit is
139+
## performed.
140+
## Also, a manual cleanup of the generated boot stanza (or stanzas) and its
141+
## inclusion within the rEFInd's main configuration file is required in case
142+
## the array's members were defined after the fact.
131143

132144
[boot-stanza-generation]
133145
refind_config = "refind.conf"
134146
include_paths = true
135147
include_sub_menus = false
148+
source_exclusion = []
136149

137150
# [boot-stanza-generation.icon]
138151
## Subobject used to configure the process of defining the generated boot

src/refind_btrfs/device/partition.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,15 @@ def is_boot(self) -> bool:
113113

114114
return False
115115

116-
def search_paths_for(self, file_name: str) -> Optional[list[Path]]:
117-
if is_none_or_whitespace(file_name):
118-
raise ValueError("The 'file_name' parameter must be initialized!")
116+
def search_paths_for(self, filename: str) -> Optional[list[Path]]:
117+
if is_none_or_whitespace(filename):
118+
raise ValueError("The 'filename' parameter must be initialized!")
119119

120120
filesystem = none_throws(self.filesystem)
121121

122122
if filesystem.is_mounted():
123123
search_directory = Path(filesystem.mount_point)
124-
all_matches = find_all_matched_files_in(search_directory, file_name)
124+
all_matches = find_all_matched_files_in(search_directory, filename)
125125

126126
return list(all_matches)
127127

src/refind_btrfs/state_management/conditions.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,28 @@ def check_boot_stanzas_with_snapshots(self) -> bool:
232232
logger = self._logger
233233
model = self._model
234234
boot_stanzas_with_snapshots = model.boot_stanzas_with_snapshots
235+
excluded_boot_stanzas_count = len(
236+
list(item for item in boot_stanzas_with_snapshots if item.is_excluded)
237+
)
238+
239+
if len(boot_stanzas_with_snapshots) == excluded_boot_stanzas_count:
240+
logger.error(
241+
"All of the matched boot stanzas are "
242+
"explicitly excluded from processing!"
243+
)
244+
245+
return False
235246

236247
for item in boot_stanzas_with_snapshots:
237-
if item.has_unmatched_snapshots():
248+
boot_stanza = item.boot_stanza
249+
normalized_name = boot_stanza.normalized_name
250+
251+
if item.is_excluded:
252+
logger.info(
253+
f"Skipping the '{normalized_name}' boot stanza "
254+
"because it is explicitly excluded from processing."
255+
)
256+
elif item.has_unmatched_snapshots():
238257
unmatched_snapshots = item.unmatched_snapshots
239258

240259
for snapshot in unmatched_snapshots:
@@ -243,14 +262,11 @@ def check_boot_stanzas_with_snapshots(self) -> bool:
243262
except SubvolumeError as e:
244263
logger.warning(e.formatted_message)
245264

246-
if not item.has_matched_snapshots():
247-
boot_stanza = item.boot_stanza
248-
normalized_name = boot_stanza.normalized_name
249-
250-
logger.warning(
251-
"None of the prepared snapshots are matched "
252-
f"with the '{normalized_name}' boot stanza!"
253-
)
265+
if not item.has_matched_snapshots():
266+
logger.warning(
267+
"None of the prepared snapshots are matched "
268+
f"with the '{normalized_name}' boot stanza!"
269+
)
254270

255271
usable_boot_stanzas_with_snapshots = model.usable_boot_stanzas_with_snapshots
256272

src/refind_btrfs/state_management/model.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def has_changes(self) -> bool:
7272

7373
class BootStanzaWithSnapshots(NamedTuple):
7474
boot_stanza: BootStanza
75+
is_excluded: bool
7576
matched_snapshots: list[Subvolume]
7677
unmatched_snapshots: list[Subvolume]
7778

@@ -81,6 +82,9 @@ def has_matched_snapshots(self) -> bool:
8182
def has_unmatched_snapshots(self) -> bool:
8283
return has_items(self.unmatched_snapshots)
8384

85+
def is_usable(self) -> bool:
86+
return not self.is_excluded and self.has_matched_snapshots()
87+
8488
def replace_matched_snapshot(
8589
self, current_snapshot: Subvolume, replacement_snapshot: Subvolume
8690
) -> None:
@@ -229,6 +233,7 @@ def initialize_prepared_snapshots(self) -> None:
229233
def combine_boot_stanzas_with_snapshots(self) -> None:
230234
usable_boot_stanzas = self.usable_boot_stanzas
231235
actual_bootable_snapshots = self.actual_bootable_snapshots
236+
boot_stanza_generation = self.package_config.boot_stanza_generation
232237
include_paths = self._should_include_paths_during_generation()
233238
boot_stanza_preparation_results: list[BootStanzaWithSnapshots] = []
234239

@@ -241,6 +246,10 @@ def combine_boot_stanzas_with_snapshots(self) -> None:
241246
snapshot.with_boot_files_check_result(boot_stanza)
242247
for snapshot in actual_bootable_snapshots
243248
)
249+
is_excluded = any(
250+
boot_stanza.is_matched_with(loader_filename)
251+
for loader_filename in boot_stanza_generation.source_exclusion
252+
)
244253

245254
for snapshot in checked_bootable_snapshots:
246255
append_func = (
@@ -255,7 +264,7 @@ def combine_boot_stanzas_with_snapshots(self) -> None:
255264

256265
boot_stanza_preparation_results.append(
257266
BootStanzaWithSnapshots(
258-
boot_stanza, matched_snapshots, unmatched_snapshots
267+
boot_stanza, is_excluded, matched_snapshots, unmatched_snapshots
259268
)
260269
)
261270

@@ -469,5 +478,5 @@ def usable_boot_stanzas_with_snapshots(self) -> dict[BootStanza, list[Subvolume]
469478
return {
470479
item.boot_stanza: item.matched_snapshots
471480
for item in boot_stanzas_with_snapshots
472-
if item.has_matched_snapshots()
481+
if item.is_usable()
473482
}

src/refind_btrfs/utility/file_package_config_provider.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class FilePackageConfigProvider(BasePackageConfigProvider):
9191
"refind.conf",
9292
True,
9393
False,
94+
set(),
9495
Icon(
9596
BootStanzaIconGenerationMode.DEFAULT,
9697
Path("btrfs-snapshot-stanzas/icons/sample_icon.png"),
@@ -435,6 +436,23 @@ def _map_to_boot_stanza_generation(
435436
bool,
436437
default_boot_stanza_generation,
437438
)
439+
source_exclusion_key = BootStanzaGenerationConfigKey.SOURCE_EXCLUSION.value
440+
source_exclusion = cast(
441+
list,
442+
FilePackageConfigProvider._get_config_value(
443+
container,
444+
source_exclusion_key,
445+
list,
446+
default_boot_stanza_generation,
447+
),
448+
)
449+
450+
if has_items(source_exclusion):
451+
for item in source_exclusion:
452+
if not isinstance(item, str):
453+
raise PackageConfigError(
454+
f"Every member of the '{source_exclusion_key}' array must be a string!"
455+
)
438456

439457
icon_key = BootStanzaGenerationConfigKey.ICON.value
440458

@@ -447,7 +465,11 @@ def _map_to_boot_stanza_generation(
447465
icon = default_boot_stanza_generation.icon
448466

449467
return BootStanzaGeneration(
450-
refind_config, include_paths, include_sub_menus, icon
468+
refind_config,
469+
include_paths,
470+
include_sub_menus,
471+
set(cast(str, loader_filename) for loader_filename in source_exclusion),
472+
icon,
451473
)
452474

453475
@staticmethod

src/refind_btrfs/utility/helpers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,16 @@ def item_count_suffix(value: Sized) -> str:
128128
return constants.EMPTY_STR if is_singleton(value) else "s"
129129

130130

131-
def find_all_matched_files_in(root_directory: Path, file_name: str) -> Iterator[Path]:
131+
def find_all_matched_files_in(root_directory: Path, filename: str) -> Iterator[Path]:
132132
if root_directory.exists() and root_directory.is_dir():
133133
children = root_directory.iterdir()
134134

135135
for child in children:
136136
if child.is_file():
137-
if child.name == file_name:
137+
if child.name == filename:
138138
yield child
139139
elif child.is_dir():
140-
yield from find_all_matched_files_in(child, file_name)
140+
yield from find_all_matched_files_in(child, filename)
141141

142142

143143
def find_all_directories_in(

0 commit comments

Comments
 (0)