linux-image-manager 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lim/__init__.py +3 -0
- lim/catalog.py +31 -0
- lim/cli.py +167 -0
- lim/config.py +18 -0
- lim/configuration/packages/general.txt +6 -0
- lim/configuration/packages/server/luks.txt +8 -0
- lim/data/__init__.py +0 -0
- lim/data/crypt.py +24 -0
- lim/data/sync.py +135 -0
- lim/device.py +130 -0
- lim/distributions.yml +67 -0
- lim/errors.py +2 -0
- lim/fsutil.py +32 -0
- lim/image/__init__.py +0 -0
- lim/image/backup.py +32 -0
- lim/image/choosers.py +111 -0
- lim/image/chroot.py +23 -0
- lim/image/encryption.py +117 -0
- lim/image/plan.py +31 -0
- lim/image/raspberry.py +236 -0
- lim/image/session.py +190 -0
- lim/image/setup.py +96 -0
- lim/image/transfer.py +140 -0
- lim/image/verify.py +100 -0
- lim/luks.py +80 -0
- lim/packages.py +18 -0
- lim/runner.py +134 -0
- lim/storage/__init__.py +0 -0
- lim/storage/common.py +38 -0
- lim/storage/raid1.py +68 -0
- lim/storage/single_drive.py +88 -0
- lim/system.py +35 -0
- lim/ui.py +62 -0
- linux_image_manager-2.1.0.dist-info/METADATA +142 -0
- linux_image_manager-2.1.0.dist-info/RECORD +39 -0
- linux_image_manager-2.1.0.dist-info/WHEEL +5 -0
- linux_image_manager-2.1.0.dist-info/entry_points.txt +2 -0
- linux_image_manager-2.1.0.dist-info/licenses/LICENSE.txt +674 -0
- linux_image_manager-2.1.0.dist-info/top_level.txt +1 -0
lim/__init__.py
ADDED
lim/catalog.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Read-only access to the image catalog (distributions.yml in the package)."""
|
|
2
|
+
|
|
3
|
+
from functools import cache
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from lim import config
|
|
8
|
+
|
|
9
|
+
CATALOG_PATH = config.CATALOG_PATH
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cache
|
|
13
|
+
def _load() -> dict:
|
|
14
|
+
with CATALOG_PATH.open() as handle:
|
|
15
|
+
return yaml.safe_load(handle)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def arch_rpi_images() -> dict[str, dict[str, str]]:
|
|
19
|
+
return _load()["arch_rpi_images"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def manjaro_gnome_releases() -> dict[str, dict[str, str]]:
|
|
23
|
+
return _load()["manjaro_gnome_releases"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def retropie_images() -> dict[str, dict[str, str]]:
|
|
27
|
+
return _load()["retropie_images"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mkinitcpio_modules_by_rpi() -> dict[str, str]:
|
|
31
|
+
return _load()["mkinitcpio_modules_by_rpi"]
|
lim/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Command line interface: `lim --type <command>`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from lim import system, ui
|
|
9
|
+
from lim.data import crypt, sync
|
|
10
|
+
from lim.errors import LimError
|
|
11
|
+
from lim.image import backup, chroot
|
|
12
|
+
from lim.image import setup as image_setup
|
|
13
|
+
from lim.storage import raid1, single_drive
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Command:
|
|
18
|
+
func: Callable[[], None]
|
|
19
|
+
description: str
|
|
20
|
+
needs_root: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
COMMANDS: dict[str, Command] = {
|
|
24
|
+
"image": Command(
|
|
25
|
+
image_setup.run_setup,
|
|
26
|
+
"Linux Image Setup:\n"
|
|
27
|
+
" - Creates partitions and formats them.\n"
|
|
28
|
+
" - Transfers the Linux image file to the device.\n"
|
|
29
|
+
" - Configures boot and root partitions.",
|
|
30
|
+
needs_root=True,
|
|
31
|
+
),
|
|
32
|
+
"single": Command(
|
|
33
|
+
single_drive.setup,
|
|
34
|
+
"Single Drive Encryption Setup:\n"
|
|
35
|
+
" - Sets up disk encryption using LUKS on one drive.\n"
|
|
36
|
+
" - Configures a Btrfs file system for secure storage.",
|
|
37
|
+
needs_root=True,
|
|
38
|
+
),
|
|
39
|
+
"raid1": Command(
|
|
40
|
+
raid1.setup,
|
|
41
|
+
"RAID1 Encryption Setup:\n"
|
|
42
|
+
" - Configures a virtual RAID1 with two drives.\n"
|
|
43
|
+
" - Uses LUKS encryption and a Btrfs RAID1 file system for redundancy.",
|
|
44
|
+
needs_root=True,
|
|
45
|
+
),
|
|
46
|
+
"backup": Command(
|
|
47
|
+
backup.run_backup,
|
|
48
|
+
"Backup Image Setup:\n"
|
|
49
|
+
" - Creates an image backup from a memory device to a file.\n"
|
|
50
|
+
" - Uses dd to transfer the image from the specified device to an image file.",
|
|
51
|
+
needs_root=True,
|
|
52
|
+
),
|
|
53
|
+
"chroot": Command(
|
|
54
|
+
chroot.run_chroot,
|
|
55
|
+
"Chroot Environment Setup:\n"
|
|
56
|
+
" - Mounts partitions and configures the chroot environment for a Linux image.\n"
|
|
57
|
+
" - Provides a shell within the Linux image for system maintenance.",
|
|
58
|
+
needs_root=True,
|
|
59
|
+
),
|
|
60
|
+
"mount": Command(
|
|
61
|
+
single_drive.mount,
|
|
62
|
+
"Mount Encrypted Storage:\n"
|
|
63
|
+
" - Unlocks a LUKS partition and mounts it.",
|
|
64
|
+
needs_root=True,
|
|
65
|
+
),
|
|
66
|
+
"umount": Command(
|
|
67
|
+
single_drive.umount,
|
|
68
|
+
"Unmount Encrypted Storage:\n"
|
|
69
|
+
" - Unmounts a LUKS partition and closes the mapper.",
|
|
70
|
+
needs_root=True,
|
|
71
|
+
),
|
|
72
|
+
"single-boot": Command(
|
|
73
|
+
single_drive.mount_on_boot,
|
|
74
|
+
"Single Drive Automount Setup:\n"
|
|
75
|
+
" - Creates a LUKS keyfile and registers it in crypttab and fstab.",
|
|
76
|
+
needs_root=True,
|
|
77
|
+
),
|
|
78
|
+
"raid1-boot": Command(
|
|
79
|
+
raid1.mount_on_boot,
|
|
80
|
+
"RAID1 Automount Setup:\n"
|
|
81
|
+
" - Creates LUKS keyfiles for both drives and registers them in crypttab/fstab.",
|
|
82
|
+
needs_root=True,
|
|
83
|
+
),
|
|
84
|
+
"unlock": Command(
|
|
85
|
+
crypt.unlock,
|
|
86
|
+
"Unlock Data:\n"
|
|
87
|
+
" - Decrypts the encfs data store into the decrypted folder.",
|
|
88
|
+
needs_root=False,
|
|
89
|
+
),
|
|
90
|
+
"lock": Command(
|
|
91
|
+
crypt.lock,
|
|
92
|
+
"Lock Data:\n"
|
|
93
|
+
" - Unmounts the decrypted encfs folder.",
|
|
94
|
+
needs_root=False,
|
|
95
|
+
),
|
|
96
|
+
"import": Command(
|
|
97
|
+
sync.import_from_system,
|
|
98
|
+
"Import Data:\n"
|
|
99
|
+
" - Copies personal data from the system into the encrypted store.",
|
|
100
|
+
needs_root=False,
|
|
101
|
+
),
|
|
102
|
+
"export": Command(
|
|
103
|
+
sync.export_to_system,
|
|
104
|
+
"Export Data:\n"
|
|
105
|
+
" - Copies personal data from the encrypted store back to the system.",
|
|
106
|
+
needs_root=False,
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
112
|
+
epilog_lines = ["Available script types:"]
|
|
113
|
+
epilog_lines += [
|
|
114
|
+
f" {name:<12} - {command.description.splitlines()[0].rstrip(':')}"
|
|
115
|
+
for name, command in COMMANDS.items()
|
|
116
|
+
]
|
|
117
|
+
parser = argparse.ArgumentParser(
|
|
118
|
+
prog="lim",
|
|
119
|
+
description="Linux Image Manager — manages Linux images and encrypted storage.",
|
|
120
|
+
epilog="\n".join(epilog_lines),
|
|
121
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--type",
|
|
125
|
+
required=True,
|
|
126
|
+
choices=list(COMMANDS.keys()),
|
|
127
|
+
help="Select the command to execute.",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--auto-confirm",
|
|
131
|
+
action="store_true",
|
|
132
|
+
help="Automatically confirm execution without prompting the user.",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--extra",
|
|
136
|
+
nargs=argparse.REMAINDER,
|
|
137
|
+
default=[],
|
|
138
|
+
help="Deprecated, ignored. Former extra parameters for the shell scripts.",
|
|
139
|
+
)
|
|
140
|
+
return parser
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> None:
|
|
144
|
+
args = build_parser().parse_args(argv)
|
|
145
|
+
command = COMMANDS[args.type]
|
|
146
|
+
|
|
147
|
+
if args.extra:
|
|
148
|
+
ui.warning("--extra is deprecated and ignored.")
|
|
149
|
+
if command.needs_root:
|
|
150
|
+
system.ensure_root()
|
|
151
|
+
|
|
152
|
+
ui.header()
|
|
153
|
+
ui.info(f"Selected command: {args.type}")
|
|
154
|
+
ui.info("Description:")
|
|
155
|
+
print(command.description)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
if not args.auto_confirm:
|
|
159
|
+
input("Press Enter to execute or Ctrl+C to cancel...")
|
|
160
|
+
command.func()
|
|
161
|
+
except LimError as exc:
|
|
162
|
+
ui.error(f"{exc} -> Leaving program.")
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
except KeyboardInterrupt:
|
|
165
|
+
print()
|
|
166
|
+
ui.error("Execution aborted by user.")
|
|
167
|
+
sys.exit(1)
|
lim/config.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Package and repository paths shared by all modules.
|
|
2
|
+
|
|
3
|
+
Configuration and the image catalog ship inside the package so that
|
|
4
|
+
wheel installs work; the encfs data store stays repository-relative.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
PACKAGE_ROOT = Path(__file__).resolve().parent
|
|
10
|
+
CONFIGURATION_PATH = PACKAGE_ROOT / "configuration"
|
|
11
|
+
PACKAGE_PATH = CONFIGURATION_PATH / "packages"
|
|
12
|
+
CATALOG_PATH = PACKAGE_ROOT / "distributions.yml"
|
|
13
|
+
|
|
14
|
+
REPOSITORY_PATH = PACKAGE_ROOT.parent
|
|
15
|
+
ENCRYPTED_PATH = REPOSITORY_PATH / ".encrypted"
|
|
16
|
+
DECRYPTED_PATH = REPOSITORY_PATH / "decrypted"
|
|
17
|
+
DATA_PATH = DECRYPTED_PATH / "data"
|
|
18
|
+
BACKUP_PATH = DECRYPTED_PATH / "backup"
|
lim/data/__init__.py
ADDED
|
File without changes
|
lim/data/crypt.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Lock/unlock the encfs-encrypted data directory."""
|
|
2
|
+
|
|
3
|
+
from lim import config, runner, ui
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def unlock() -> None:
|
|
7
|
+
ui.info(f"Unlocking directory {config.DECRYPTED_PATH}...")
|
|
8
|
+
if not config.DECRYPTED_PATH.is_dir():
|
|
9
|
+
ui.info(f"Creating directory {config.DECRYPTED_PATH}...")
|
|
10
|
+
config.DECRYPTED_PATH.mkdir()
|
|
11
|
+
ui.info(f"Decrypting directory {config.ENCRYPTED_PATH} to {config.DECRYPTED_PATH}...")
|
|
12
|
+
runner.run(["encfs", str(config.ENCRYPTED_PATH), str(config.DECRYPTED_PATH)])
|
|
13
|
+
print("ATTENTION: DATA IS NOW DECRYPTED!")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def lock() -> None:
|
|
17
|
+
ui.info(f"Locking directory {config.DECRYPTED_PATH}...")
|
|
18
|
+
runner.run(
|
|
19
|
+
["fusermount", "-u", str(config.DECRYPTED_PATH)],
|
|
20
|
+
error_msg="Unmounting failed.",
|
|
21
|
+
)
|
|
22
|
+
ui.info("Data is now encrypted.")
|
|
23
|
+
ui.info(f"Removing directory {config.DECRYPTED_PATH}...")
|
|
24
|
+
config.DECRYPTED_PATH.rmdir()
|
lim/data/sync.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Import personal data from the system into the encrypted store, or export it back."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lim import config, runner, system, ui
|
|
8
|
+
from lim.data import crypt
|
|
9
|
+
from lim.errors import LimError
|
|
10
|
+
|
|
11
|
+
# Paths relative to $HOME; trailing "/" marks a directory tree.
|
|
12
|
+
BACKUP_ITEMS = [
|
|
13
|
+
".ssh/",
|
|
14
|
+
".gitconfig",
|
|
15
|
+
".atom/config.cson",
|
|
16
|
+
".projectlibre/projectlibre.conf",
|
|
17
|
+
".local/share/rhythmbox/rhythmdb.xml",
|
|
18
|
+
".config/keepassxc/keepassxc.ini",
|
|
19
|
+
"Documents/certificates/",
|
|
20
|
+
"Documents/security/",
|
|
21
|
+
"Documents/identity/",
|
|
22
|
+
"Documents/health/",
|
|
23
|
+
"Documents/licenses/",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SyncOperation:
|
|
29
|
+
source: str
|
|
30
|
+
destination: str
|
|
31
|
+
backup_dir: str
|
|
32
|
+
is_directory: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_sync_plan(
|
|
36
|
+
mode: str,
|
|
37
|
+
home: Path,
|
|
38
|
+
data_path: Path,
|
|
39
|
+
backup_folder: Path,
|
|
40
|
+
) -> list[SyncOperation]:
|
|
41
|
+
"""One rsync operation per existing backup item; missing sources are skipped."""
|
|
42
|
+
operations = []
|
|
43
|
+
for item in BACKUP_ITEMS:
|
|
44
|
+
is_directory = item.endswith("/")
|
|
45
|
+
system_path = home / item
|
|
46
|
+
# The store mirrors the absolute system path below DATA_PATH.
|
|
47
|
+
data_path_item = Path(f"{data_path}{system_path}")
|
|
48
|
+
if mode == "export":
|
|
49
|
+
source, destination = data_path_item, system_path
|
|
50
|
+
elif mode == "import":
|
|
51
|
+
source, destination = system_path, data_path_item
|
|
52
|
+
else:
|
|
53
|
+
raise LimError(f"Unknown sync mode: {mode}")
|
|
54
|
+
ui.info(f"{mode.capitalize()} data from {source} to {destination}...")
|
|
55
|
+
if not (source.is_file() or source.is_dir()):
|
|
56
|
+
ui.warning(f"{source} doesn't exist. Copying data is not possible.")
|
|
57
|
+
continue
|
|
58
|
+
if is_directory:
|
|
59
|
+
backup_dir = Path(f"{backup_folder}{system_path}")
|
|
60
|
+
else:
|
|
61
|
+
backup_dir = Path(f"{backup_folder}{system_path}").parent
|
|
62
|
+
operations.append(
|
|
63
|
+
SyncOperation(
|
|
64
|
+
source=f"{source}/" if is_directory else str(source),
|
|
65
|
+
destination=f"{destination}/" if is_directory else str(destination),
|
|
66
|
+
backup_dir=str(backup_dir),
|
|
67
|
+
is_directory=is_directory,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return operations
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def rsync_command(operation: SyncOperation) -> list[str]:
|
|
74
|
+
command = ["rsync", "-abcEPuvW"]
|
|
75
|
+
if operation.is_directory:
|
|
76
|
+
command.append("--delete")
|
|
77
|
+
command += [f"--backup-dir={operation.backup_dir}", operation.source, operation.destination]
|
|
78
|
+
return command
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def execute_sync_plan(operations: list[SyncOperation]) -> None:
|
|
82
|
+
for operation in operations:
|
|
83
|
+
Path(operation.backup_dir).mkdir(parents=True, exist_ok=True)
|
|
84
|
+
destination_dir = (
|
|
85
|
+
Path(operation.destination)
|
|
86
|
+
if operation.is_directory
|
|
87
|
+
else Path(operation.destination).parent
|
|
88
|
+
)
|
|
89
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
if not operation.is_directory and Path(operation.destination).is_file():
|
|
91
|
+
ui.info("The destination file already exists!")
|
|
92
|
+
ui.info("Difference:")
|
|
93
|
+
runner.run(
|
|
94
|
+
["diff", operation.destination, operation.source], check=False
|
|
95
|
+
)
|
|
96
|
+
runner.run(rsync_command(operation))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ensure_unlocked() -> None:
|
|
100
|
+
mounts = runner.output(["mount"], check=False)
|
|
101
|
+
if str(config.DECRYPTED_PATH) not in mounts:
|
|
102
|
+
ui.info(
|
|
103
|
+
f"The decrypted folder {config.DECRYPTED_PATH} is locked. "
|
|
104
|
+
"You need to unlock it!"
|
|
105
|
+
)
|
|
106
|
+
crypt.unlock()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def import_from_system(mode: str = "import") -> None:
|
|
110
|
+
ensure_unlocked()
|
|
111
|
+
backup_folder = (
|
|
112
|
+
config.BACKUP_PATH / mode / time.strftime("%Y%m%d%H%M%S")
|
|
113
|
+
)
|
|
114
|
+
backup_folder.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
operations = build_sync_plan(
|
|
116
|
+
mode, system.real_home(), config.DATA_PATH, backup_folder
|
|
117
|
+
)
|
|
118
|
+
execute_sync_plan(operations)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def export_to_system() -> None:
|
|
122
|
+
import_from_system(mode="export")
|
|
123
|
+
user = system.real_user()
|
|
124
|
+
home = system.real_home()
|
|
125
|
+
ui.info("Setting right permissions for imported files...")
|
|
126
|
+
try:
|
|
127
|
+
(home / ".ssh").chmod(0o700)
|
|
128
|
+
for child in (home / ".ssh").rglob("*"):
|
|
129
|
+
child.chmod(0o700 if child.is_dir() else 0o600)
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
raise LimError(f"Failed to set correct ssh permissions: {exc}") from exc
|
|
132
|
+
# Best effort without privilege escalation, like the original script.
|
|
133
|
+
chowned = runner.run(["chown", "-R", f"{user}:{user}", str(home)], check=False)
|
|
134
|
+
if chowned.returncode != 0:
|
|
135
|
+
ui.warning(f'Not all files could be owned by user "{user}"...')
|
lim/device.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Block-device selection and low-level device helpers."""
|
|
2
|
+
|
|
3
|
+
import stat as stat_module
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lim import runner, ui
|
|
8
|
+
from lim.errors import LimError
|
|
9
|
+
|
|
10
|
+
SYS_BLOCK_PATH = Path("/sys/block")
|
|
11
|
+
|
|
12
|
+
PARTITION_TABLE_INFO = """\
|
|
13
|
+
##########################################################################################
|
|
14
|
+
Note on Partition Table Deletion:
|
|
15
|
+
---------------------------------------------
|
|
16
|
+
• MBR (Master Boot Record):
|
|
17
|
+
- Typically occupies the first sector (512 bytes), i.e., 1 block.
|
|
18
|
+
|
|
19
|
+
• GPT (GUID Partition Table):
|
|
20
|
+
- Uses a protective MBR (1 block), a GPT header (1 block),
|
|
21
|
+
and usually a partition entry array that takes up about 32 blocks.
|
|
22
|
+
- Total: approximately 34 blocks (assuming a 512-byte block size).
|
|
23
|
+
|
|
24
|
+
Recommendation: For deleting a GPT partition table, use a block size of 512 bytes
|
|
25
|
+
and overwrite at least 34 blocks to ensure the entire table is cleared.
|
|
26
|
+
##########################################################################################"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def optimal_blocksize(name: str, sys_block_path: Path = SYS_BLOCK_PATH) -> str:
|
|
30
|
+
"""64 * physical block size, or "4K" when the size cannot be read.
|
|
31
|
+
|
|
32
|
+
See https://www.heise.de/ct/hotline/Optimale-Blockgroesse-fuer-dd-2056768.html
|
|
33
|
+
"""
|
|
34
|
+
size_path = sys_block_path / name / "queue" / "physical_block_size"
|
|
35
|
+
try:
|
|
36
|
+
return str(64 * int(size_path.read_text().strip()))
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
return "4K"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Device:
|
|
43
|
+
name: str # e.g. "sda" or "mmcblk0"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def path(self) -> str:
|
|
47
|
+
return f"/dev/{self.name}"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def optimal_blocksize(self) -> str:
|
|
51
|
+
return optimal_blocksize(self.name)
|
|
52
|
+
|
|
53
|
+
def partition(self, number: int) -> str:
|
|
54
|
+
"""/dev/sda -> /dev/sda1, /dev/mmcblk0 -> /dev/mmcblk0p1."""
|
|
55
|
+
if self.name[-1].isdigit():
|
|
56
|
+
return f"{self.path}p{number}"
|
|
57
|
+
return f"{self.path}{number}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_block_device(path: str) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
return stat_module.S_ISBLK(Path(path).stat().st_mode)
|
|
63
|
+
except OSError:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def select_device() -> Device:
|
|
68
|
+
ui.info("Available devices:")
|
|
69
|
+
runner.run(["lsblk", "-o", "NAME,SIZE,TYPE,MODEL"], check=False)
|
|
70
|
+
name = ui.ask("Please type in the name of the device: /dev/")
|
|
71
|
+
device = Device(name)
|
|
72
|
+
if not name or not is_block_device(device.path):
|
|
73
|
+
raise LimError(f"{device.path} is not a valid device.")
|
|
74
|
+
ui.info(f"Device path set to: {device.path}")
|
|
75
|
+
ui.info(f"Optimal blocksize set to: {device.optimal_blocksize}")
|
|
76
|
+
return device
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def overwrite_device(device: Device) -> None:
|
|
80
|
+
"""Optionally overwrite the device (or its first blocks) with zeros."""
|
|
81
|
+
print(PARTITION_TABLE_INFO)
|
|
82
|
+
answer = ui.ask(
|
|
83
|
+
f"Should {device.path} be overwritten with zeros before copying? (y/N/block count)"
|
|
84
|
+
)
|
|
85
|
+
if answer == "y":
|
|
86
|
+
ui.info("Overwriting entire device...")
|
|
87
|
+
runner.run(
|
|
88
|
+
[
|
|
89
|
+
"dd",
|
|
90
|
+
"if=/dev/zero",
|
|
91
|
+
f"of={device.path}",
|
|
92
|
+
f"bs={device.optimal_blocksize}",
|
|
93
|
+
"status=progress",
|
|
94
|
+
],
|
|
95
|
+
sudo=True,
|
|
96
|
+
error_msg=f"Overwriting {device.path} failed.",
|
|
97
|
+
)
|
|
98
|
+
runner.sync_disks()
|
|
99
|
+
elif answer in ("", "N"):
|
|
100
|
+
ui.info("Skipping Overwriting...")
|
|
101
|
+
elif answer.isdigit():
|
|
102
|
+
ui.info(f"Overwriting {answer} blocks...")
|
|
103
|
+
runner.run(
|
|
104
|
+
[
|
|
105
|
+
"dd",
|
|
106
|
+
"if=/dev/zero",
|
|
107
|
+
f"of={device.path}",
|
|
108
|
+
f"bs={device.optimal_blocksize}",
|
|
109
|
+
f"count={answer}",
|
|
110
|
+
"status=progress",
|
|
111
|
+
],
|
|
112
|
+
sudo=True,
|
|
113
|
+
error_msg=f"Overwriting {device.path} failed.",
|
|
114
|
+
)
|
|
115
|
+
runner.sync_disks()
|
|
116
|
+
else:
|
|
117
|
+
raise LimError("Invalid input. Block count must be a number.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def blkid_value(path: str, tag: str) -> str:
|
|
121
|
+
"""Return a blkid tag value ("TYPE", "UUID", ...) or "" when unavailable."""
|
|
122
|
+
return runner.output(
|
|
123
|
+
["blkid", path, "-s", tag, "-o", "value"], sudo=True, check=False
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_mounted(path_fragment: str) -> bool:
|
|
128
|
+
"""Whether any current mount line contains the given fragment."""
|
|
129
|
+
mounts = runner.output(["mount"], check=False)
|
|
130
|
+
return any(path_fragment in line for line in mounts.splitlines())
|
lim/distributions.yml
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Single point of truth for the downloadable image catalog.
|
|
2
|
+
# Consumed by lim/catalog.py.
|
|
3
|
+
|
|
4
|
+
# Image and LUKS --pbkdf-memory cost per Raspberry Pi version.
|
|
5
|
+
arch_rpi_images:
|
|
6
|
+
"1":
|
|
7
|
+
image: ArchLinuxARM-rpi-armv7-latest.tar.gz
|
|
8
|
+
luks_memory_cost: "64000"
|
|
9
|
+
"2":
|
|
10
|
+
image: ArchLinuxARM-rpi-armv7-latest.tar.gz
|
|
11
|
+
luks_memory_cost: "128000"
|
|
12
|
+
"3b":
|
|
13
|
+
image: ArchLinuxARM-rpi-aarch64-latest.tar.gz
|
|
14
|
+
luks_memory_cost: "128000"
|
|
15
|
+
"3b+":
|
|
16
|
+
image: ArchLinuxARM-rpi-aarch64-latest.tar.gz
|
|
17
|
+
luks_memory_cost: "128000"
|
|
18
|
+
"4":
|
|
19
|
+
image: ArchLinuxARM-rpi-aarch64-latest.tar.gz
|
|
20
|
+
luks_memory_cost: "256000"
|
|
21
|
+
|
|
22
|
+
manjaro_gnome_releases:
|
|
23
|
+
"20":
|
|
24
|
+
checksum: 2df3697908483550d4a473815b08c1377e6b6892
|
|
25
|
+
url: https://osdn.net/projects/manjaro-archive/storage/gnome/20.0/
|
|
26
|
+
image: manjaro-gnome-20.0-200426-linux56.iso
|
|
27
|
+
"21":
|
|
28
|
+
url: https://download.manjaro.org/gnome/21.3.7/
|
|
29
|
+
image: manjaro-gnome-21.3.7-220816-linux515.iso
|
|
30
|
+
"22":
|
|
31
|
+
url: https://download.manjaro.org/gnome/22.1.3/
|
|
32
|
+
image: manjaro-gnome-22.1.3-230529-linux61.iso
|
|
33
|
+
"24":
|
|
34
|
+
url: https://download.manjaro.org/gnome/24.2.1/
|
|
35
|
+
image: manjaro-gnome-24.2.1-241216-linux612.iso
|
|
36
|
+
"25":
|
|
37
|
+
url: https://download.manjaro.org/gnome/25.0.10/
|
|
38
|
+
image: manjaro-gnome-25.0.10-251013-linux612.iso
|
|
39
|
+
# At the moment just optimized for Raspberry Pi 4.
|
|
40
|
+
raspberrypi:
|
|
41
|
+
url: https://github.com/manjaro-arm/rpi4-images/releases/download/23.02/
|
|
42
|
+
image: Manjaro-ARM-gnome-rpi4-23.02.img.xz
|
|
43
|
+
luks_memory_cost: "256000"
|
|
44
|
+
raspberry_pi_version: "4"
|
|
45
|
+
|
|
46
|
+
retropie_images:
|
|
47
|
+
"1":
|
|
48
|
+
checksum: 95a6f84453df36318830de7e8507170e
|
|
49
|
+
image: retropie-buster-4.8-rpi1_zero.img.gz
|
|
50
|
+
"2":
|
|
51
|
+
checksum: 224e64d8820fc64046ba3850f481c87e
|
|
52
|
+
image: retropie-buster-4.8-rpi2_3_zero2w.img.gz
|
|
53
|
+
"3":
|
|
54
|
+
checksum: 224e64d8820fc64046ba3850f481c87e
|
|
55
|
+
image: retropie-buster-4.8-rpi2_3_zero2w.img.gz
|
|
56
|
+
"4":
|
|
57
|
+
checksum: b5daa6e7660a99c246966f3f09b4014b
|
|
58
|
+
image: retropie-buster-4.8-rpi4_400.img.gz
|
|
59
|
+
|
|
60
|
+
# Extra kernel modules the initramfs needs for early network access,
|
|
61
|
+
# see https://raspberrypi.stackexchange.com/questions/67051
|
|
62
|
+
mkinitcpio_modules_by_rpi:
|
|
63
|
+
"1": ""
|
|
64
|
+
"2": ""
|
|
65
|
+
"3b": smsc95xx
|
|
66
|
+
"3b+": lan78xx
|
|
67
|
+
"4": lan78xx
|
lim/errors.py
ADDED
lim/fsutil.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Small file manipulation helpers."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lim import ui
|
|
6
|
+
from lim.errors import LimError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def replace_in_file(search: str, replace: str, path: str | Path) -> None:
|
|
10
|
+
"""Replace every literal occurrence of ``search``; fail when absent."""
|
|
11
|
+
path = Path(path)
|
|
12
|
+
text = path.read_text()
|
|
13
|
+
new_text = text.replace(search, replace)
|
|
14
|
+
if new_text == text:
|
|
15
|
+
raise LimError(f"Search string '{search}' not found in {path}.")
|
|
16
|
+
path.write_text(new_text)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensure_line_in_file(line: str, path: str | Path) -> bool:
|
|
20
|
+
"""Append ``line`` unless already present. Returns True when appended."""
|
|
21
|
+
path = Path(path)
|
|
22
|
+
content = path.read_text() if path.exists() else ""
|
|
23
|
+
if line in content.splitlines():
|
|
24
|
+
ui.warning(f"File {path} already contains the following entry:")
|
|
25
|
+
print(line)
|
|
26
|
+
ui.info("Skipped.")
|
|
27
|
+
return False
|
|
28
|
+
with path.open("a") as handle:
|
|
29
|
+
if content and not content.endswith("\n"):
|
|
30
|
+
handle.write("\n")
|
|
31
|
+
handle.write(line + "\n")
|
|
32
|
+
return True
|
lim/image/__init__.py
ADDED
|
File without changes
|
lim/image/backup.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Create an image backup from a memory device into a file."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lim import device as device_module
|
|
6
|
+
from lim import runner, ui
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_backup() -> None:
|
|
10
|
+
ui.info("Backupscript for memory devices started...")
|
|
11
|
+
print()
|
|
12
|
+
device = device_module.select_device()
|
|
13
|
+
|
|
14
|
+
working_dir = Path.cwd()
|
|
15
|
+
path = ""
|
|
16
|
+
while not path:
|
|
17
|
+
path = ui.ask(f"Please type in backup image path+name relative to {working_dir}:")
|
|
18
|
+
output_file = f"{path}.img" if path.startswith("/") else f"{working_dir}/{path}.img"
|
|
19
|
+
|
|
20
|
+
ui.info(f"Input file: {device.path}")
|
|
21
|
+
ui.info(f"Output file: {output_file}")
|
|
22
|
+
ui.ask('Please confirm by pushing "Enter". To cancel use "Ctrl + C"')
|
|
23
|
+
|
|
24
|
+
ui.info("Imagetransfer starts. This can take a while...")
|
|
25
|
+
runner.run(
|
|
26
|
+
["dd", f"if={device.path}", f"of={output_file}", "bs=1M", "status=progress"],
|
|
27
|
+
sudo=True,
|
|
28
|
+
error_msg='"dd" failed.',
|
|
29
|
+
)
|
|
30
|
+
runner.sync_disks()
|
|
31
|
+
|
|
32
|
+
ui.success("Imagetransfer successfull.")
|