chroot-distro 1.5.6__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.
- chroot_distro/__init__.py +6 -0
- chroot_distro/arch.py +135 -0
- chroot_distro/atomic.py +23 -0
- chroot_distro/cli.py +235 -0
- chroot_distro/commands/backup.py +292 -0
- chroot_distro/commands/build.py +316 -0
- chroot_distro/commands/clear_cache.py +61 -0
- chroot_distro/commands/copy.py +96 -0
- chroot_distro/commands/help/__init__.py +109 -0
- chroot_distro/commands/help/pages.py +607 -0
- chroot_distro/commands/help/render.py +276 -0
- chroot_distro/commands/install.py +256 -0
- chroot_distro/commands/install_local.py +213 -0
- chroot_distro/commands/list_cmd.py +34 -0
- chroot_distro/commands/login/__init__.py +610 -0
- chroot_distro/commands/login/bindings.py +442 -0
- chroot_distro/commands/login/chroot_cmd.py +63 -0
- chroot_distro/commands/login/env.py +163 -0
- chroot_distro/commands/login/passwd.py +363 -0
- chroot_distro/commands/push.py +91 -0
- chroot_distro/commands/remove.py +102 -0
- chroot_distro/commands/rename.py +61 -0
- chroot_distro/commands/reset.py +73 -0
- chroot_distro/commands/restore.py +328 -0
- chroot_distro/commands/run.py +64 -0
- chroot_distro/commands/sync.py +414 -0
- chroot_distro/commands/unmount.py +89 -0
- chroot_distro/completions/_chroot-distro +313 -0
- chroot_distro/completions/chroot-distro.bash +330 -0
- chroot_distro/completions/chroot-distro.fish +395 -0
- chroot_distro/constants.py +73 -0
- chroot_distro/elevate.py +97 -0
- chroot_distro/exceptions.py +46 -0
- chroot_distro/helpers/__init__.py +1 -0
- chroot_distro/helpers/android.py +194 -0
- chroot_distro/helpers/build_cache.py +148 -0
- chroot_distro/helpers/build_engine/__init__.py +15 -0
- chroot_distro/helpers/build_engine/constants.py +61 -0
- chroot_distro/helpers/build_engine/copy_step.py +558 -0
- chroot_distro/helpers/build_engine/dockerignore.py +58 -0
- chroot_distro/helpers/build_engine/engine.py +396 -0
- chroot_distro/helpers/build_engine/errors.py +2 -0
- chroot_distro/helpers/build_engine/handlers.py +271 -0
- chroot_distro/helpers/build_engine/parsing.py +76 -0
- chroot_distro/helpers/build_engine/run_step.py +203 -0
- chroot_distro/helpers/build_engine/stage.py +42 -0
- chroot_distro/helpers/build_engine/users.py +56 -0
- chroot_distro/helpers/docker/__init__.py +53 -0
- chroot_distro/helpers/docker/cache.py +63 -0
- chroot_distro/helpers/docker/layers.py +125 -0
- chroot_distro/helpers/docker/media.py +18 -0
- chroot_distro/helpers/docker/pull.py +228 -0
- chroot_distro/helpers/docker/push.py +347 -0
- chroot_distro/helpers/docker/refs.py +52 -0
- chroot_distro/helpers/docker/transport.py +163 -0
- chroot_distro/helpers/dockerfile.py +472 -0
- chroot_distro/helpers/download.py +60 -0
- chroot_distro/helpers/layer_diff.py +465 -0
- chroot_distro/helpers/mount_manager.py +253 -0
- chroot_distro/helpers/namespace.py +409 -0
- chroot_distro/helpers/oci_writer.py +267 -0
- chroot_distro/helpers/rootfs.py +151 -0
- chroot_distro/helpers/session.py +140 -0
- chroot_distro/helpers/tar_extract.py +175 -0
- chroot_distro/helpers/x11.py +195 -0
- chroot_distro/locking.py +178 -0
- chroot_distro/message.py +140 -0
- chroot_distro/names.py +18 -0
- chroot_distro/parser.py +318 -0
- chroot_distro/paths.py +69 -0
- chroot_distro/progress.py +91 -0
- chroot_distro/py.typed +1 -0
- chroot_distro-1.5.6.dist-info/METADATA +1076 -0
- chroot_distro-1.5.6.dist-info/RECORD +77 -0
- chroot_distro-1.5.6.dist-info/WHEEL +4 -0
- chroot_distro-1.5.6.dist-info/entry_points.txt +2 -0
- chroot_distro-1.5.6.dist-info/licenses/LICENSE +674 -0
chroot_distro/arch.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import os
|
|
3
|
+
import struct
|
|
4
|
+
|
|
5
|
+
from chroot_distro.constants import TERMUX_PREFIX
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Host/Guest CPU architecture detection
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_device_cpu_arch() -> str:
|
|
13
|
+
"""Return the host CPU arch in chroot-distro's naming scheme.
|
|
14
|
+
|
|
15
|
+
armv7l / armv8l are collapsed to "arm"; everything else is the
|
|
16
|
+
raw `uname -m` value.
|
|
17
|
+
"""
|
|
18
|
+
machine = os.uname().machine
|
|
19
|
+
if machine in ("armv7l", "armv8l"):
|
|
20
|
+
return "arm"
|
|
21
|
+
return machine
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def supports_32bit() -> bool:
|
|
25
|
+
"""Return True if the host CPU supports 32-bit userspace execution."""
|
|
26
|
+
machine = os.uname().machine
|
|
27
|
+
|
|
28
|
+
if machine in ("x86_64", "amd64"):
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
if machine in ("aarch64", "arm64"):
|
|
32
|
+
per_linux32 = 0x0008
|
|
33
|
+
try:
|
|
34
|
+
libc = ctypes.CDLL(None)
|
|
35
|
+
prev = libc.personality(per_linux32)
|
|
36
|
+
|
|
37
|
+
if prev == -1:
|
|
38
|
+
return False
|
|
39
|
+
libc.personality(prev) # restore
|
|
40
|
+
return True
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_ELF_MACHINE_MAP = {
|
|
48
|
+
3: "i686", # EM_386
|
|
49
|
+
40: "arm", # EM_ARM
|
|
50
|
+
62: "x86_64", # EM_X86_64
|
|
51
|
+
183: "aarch64", # EM_AARCH64
|
|
52
|
+
243: "riscv64", # EM_RISCV
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _elf_arch(path: str) -> str:
|
|
57
|
+
"""Return the arch name for an ELF binary, or '' on failure."""
|
|
58
|
+
try:
|
|
59
|
+
with open(path, "rb") as fh:
|
|
60
|
+
ident = fh.read(20)
|
|
61
|
+
if len(ident) < 20 or ident[:4] != b"\x7fELF":
|
|
62
|
+
return ""
|
|
63
|
+
fmt = "<H" if ident[5] == 1 else ">H" # EI_DATA: 1=LE, 2=BE
|
|
64
|
+
e_machine = struct.unpack_from(fmt, ident, 18)[0]
|
|
65
|
+
return _ELF_MACHINE_MAP.get(e_machine, "")
|
|
66
|
+
except OSError:
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def detect_installed_arch(container_name_or_rootfs: str) -> str:
|
|
71
|
+
"""Detect CPU architecture of an installed container by reading ELF headers.
|
|
72
|
+
|
|
73
|
+
Accepts either a plain container name (resolved via paths.container_rootfs)
|
|
74
|
+
or a full path to the rootfs directory.
|
|
75
|
+
"""
|
|
76
|
+
if os.sep in container_name_or_rootfs or container_name_or_rootfs.startswith("/"):
|
|
77
|
+
root = container_name_or_rootfs
|
|
78
|
+
else:
|
|
79
|
+
from chroot_distro.paths import container_rootfs
|
|
80
|
+
|
|
81
|
+
root = container_rootfs(container_name_or_rootfs)
|
|
82
|
+
|
|
83
|
+
candidates = [
|
|
84
|
+
"/usr/bin/bash",
|
|
85
|
+
"/usr/bin/sh",
|
|
86
|
+
"/usr/bin/su",
|
|
87
|
+
"/usr/bin/busybox",
|
|
88
|
+
f"{TERMUX_PREFIX}/bin/bash",
|
|
89
|
+
"/bin/bash",
|
|
90
|
+
"/bin/sh",
|
|
91
|
+
"/bin/su",
|
|
92
|
+
"/bin/busybox",
|
|
93
|
+
]
|
|
94
|
+
for rel in candidates:
|
|
95
|
+
arch = _elf_arch(root + rel)
|
|
96
|
+
if arch:
|
|
97
|
+
return arch
|
|
98
|
+
return "unknown"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_KNOWN_ARCHS = {"aarch64", "arm", "i686", "riscv64", "x86_64"}
|
|
102
|
+
|
|
103
|
+
# Docker platform strings and alternative names -> chroot-distro arch.
|
|
104
|
+
_DOCKER_TO_PROOT = {
|
|
105
|
+
"arm64": "aarch64",
|
|
106
|
+
"arm/v7": "arm",
|
|
107
|
+
"arm": "arm",
|
|
108
|
+
"386": "i686",
|
|
109
|
+
"amd64": "x86_64",
|
|
110
|
+
"riscv64": "riscv64",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def normalize_arch(arch: str) -> str | None:
|
|
115
|
+
"""Return a canonical chroot-distro arch name, or None if unrecognised.
|
|
116
|
+
|
|
117
|
+
Accepts native names (aarch64, x86_64 ...), bare Docker names
|
|
118
|
+
(arm64, amd64 ...), and linux/-prefixed Docker platform strings.
|
|
119
|
+
"""
|
|
120
|
+
s = arch.strip()
|
|
121
|
+
if s.startswith("linux/"):
|
|
122
|
+
s = s[6:]
|
|
123
|
+
if s in _KNOWN_ARCHS:
|
|
124
|
+
return s
|
|
125
|
+
return _DOCKER_TO_PROOT.get(s)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Machine string reported by `uname -m` for each arch.
|
|
129
|
+
ARCH_UNAME_M = {
|
|
130
|
+
"aarch64": "aarch64",
|
|
131
|
+
"arm": "armv7l",
|
|
132
|
+
"i686": "i686",
|
|
133
|
+
"x86_64": "x86_64",
|
|
134
|
+
"riscv64": "riscv64",
|
|
135
|
+
}
|
chroot_distro/atomic.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@contextlib.contextmanager
|
|
7
|
+
def atomic_replace(path: str, *, suffix: str = ".tmp"):
|
|
8
|
+
"""Yield a tmp path next to *path*; rename on success, remove on error."""
|
|
9
|
+
dest_dir = os.path.dirname(path) or "."
|
|
10
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
11
|
+
fd, tmp = tempfile.mkstemp(
|
|
12
|
+
prefix=os.path.basename(path) + ".",
|
|
13
|
+
suffix=suffix,
|
|
14
|
+
dir=dest_dir,
|
|
15
|
+
)
|
|
16
|
+
os.close(fd)
|
|
17
|
+
try:
|
|
18
|
+
yield tmp
|
|
19
|
+
os.replace(tmp, path)
|
|
20
|
+
except BaseException:
|
|
21
|
+
with contextlib.suppress(OSError):
|
|
22
|
+
os.remove(tmp)
|
|
23
|
+
raise
|
chroot_distro/cli.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from chroot_distro.commands.backup import command_backup
|
|
6
|
+
from chroot_distro.commands.build import command_build
|
|
7
|
+
from chroot_distro.commands.clear_cache import command_clear_cache
|
|
8
|
+
from chroot_distro.commands.copy import command_copy
|
|
9
|
+
from chroot_distro.commands.help import HELP_COMMANDS, command_help
|
|
10
|
+
from chroot_distro.commands.install import command_install
|
|
11
|
+
from chroot_distro.commands.list_cmd import command_list
|
|
12
|
+
from chroot_distro.commands.login import command_login
|
|
13
|
+
from chroot_distro.commands.push import command_push
|
|
14
|
+
from chroot_distro.commands.remove import command_remove
|
|
15
|
+
from chroot_distro.commands.rename import command_rename
|
|
16
|
+
from chroot_distro.commands.reset import command_reset
|
|
17
|
+
from chroot_distro.commands.restore import command_restore
|
|
18
|
+
from chroot_distro.commands.run import command_run
|
|
19
|
+
from chroot_distro.commands.sync import command_sync
|
|
20
|
+
from chroot_distro.commands.unmount import command_unmount
|
|
21
|
+
from chroot_distro.constants import IS_TERMUX, PROGRAM_NAME
|
|
22
|
+
from chroot_distro.exceptions import ChrootDistroError, RootRequiredError
|
|
23
|
+
from chroot_distro.message import crit_error, msg, set_quiet
|
|
24
|
+
from chroot_distro.parser import (
|
|
25
|
+
ALIAS_TO_CANONICAL,
|
|
26
|
+
REQUIRED_ARGS,
|
|
27
|
+
build_parser,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def command_stub(args) -> None:
|
|
32
|
+
raise NotImplementedError(f"Command '{args.command}' is not yet implemented.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_COMMAND_HANDLERS = {
|
|
36
|
+
"install": command_install,
|
|
37
|
+
"remove": command_remove,
|
|
38
|
+
"rename": command_rename,
|
|
39
|
+
"reset": command_reset,
|
|
40
|
+
"login": command_login,
|
|
41
|
+
"list": command_list,
|
|
42
|
+
"backup": command_backup,
|
|
43
|
+
"restore": command_restore,
|
|
44
|
+
"clear-cache": command_clear_cache,
|
|
45
|
+
"copy": command_copy,
|
|
46
|
+
"sync": command_sync,
|
|
47
|
+
"run": command_run,
|
|
48
|
+
"unmount": command_unmount,
|
|
49
|
+
"build": command_build,
|
|
50
|
+
"push": command_push,
|
|
51
|
+
"help": command_help,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sigquit_to_keyboard_interrupt(_signum, _frame):
|
|
56
|
+
raise KeyboardInterrupt()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ensure_root_user(no_elevate: bool = False, use_sudo: bool = False) -> None:
|
|
60
|
+
"""Ensure that we are running as root, elevating if necessary/possible.
|
|
61
|
+
|
|
62
|
+
Unlike proot-distro (which is rootless), chroot-distro uses the host's
|
|
63
|
+
native chroot and mount mechanisms, requiring root privileges.
|
|
64
|
+
"""
|
|
65
|
+
if os.getuid() == 0:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if no_elevate:
|
|
69
|
+
raise RootRequiredError(f"{PROGRAM_NAME} requires root privileges. Please run with sudo or as root.")
|
|
70
|
+
|
|
71
|
+
from chroot_distro.elevate import elevate_or_die
|
|
72
|
+
|
|
73
|
+
elevate_or_die(use_sudo=use_sudo)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _dispatch_help(raw_args) -> bool:
|
|
77
|
+
"""Render per-command help when -h/--help/--usage is given."""
|
|
78
|
+
if len(raw_args) < 2 or raw_args[1] not in ("-h", "--help", "--usage"):
|
|
79
|
+
return False
|
|
80
|
+
cmd = ALIAS_TO_CANONICAL.get(raw_args[0], raw_args[0])
|
|
81
|
+
if cmd in HELP_COMMANDS:
|
|
82
|
+
HELP_COMMANDS[cmd]()
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reject_unknown_command(raw_args) -> None:
|
|
88
|
+
"""Exit with help text when the first arg names no known command."""
|
|
89
|
+
if not raw_args:
|
|
90
|
+
return
|
|
91
|
+
first = raw_args[0]
|
|
92
|
+
if not first.startswith("-") and first not in _COMMAND_HANDLERS and first not in ALIAS_TO_CANONICAL:
|
|
93
|
+
msg()
|
|
94
|
+
crit_error(f"unknown command '{first}'.")
|
|
95
|
+
command_help()
|
|
96
|
+
msg()
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _split_separator(canonical, raw_args, args):
|
|
101
|
+
"""Set args.login_cmd / args.run_args from tokens after a literal '--'."""
|
|
102
|
+
if canonical == "login":
|
|
103
|
+
if "--" in raw_args:
|
|
104
|
+
sep_idx = raw_args.index("--")
|
|
105
|
+
args.login_cmd = raw_args[sep_idx + 1 :]
|
|
106
|
+
else:
|
|
107
|
+
args.login_cmd = []
|
|
108
|
+
elif canonical == "run":
|
|
109
|
+
if "--" in raw_args:
|
|
110
|
+
sep_idx = raw_args.index("--")
|
|
111
|
+
args.run_args = raw_args[sep_idx + 1 :]
|
|
112
|
+
else:
|
|
113
|
+
args.run_args = []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
"""CLI entry point.
|
|
118
|
+
|
|
119
|
+
Validates the runtime environment, parses arguments, and dispatches
|
|
120
|
+
to the chosen command's handler.
|
|
121
|
+
"""
|
|
122
|
+
signal.signal(signal.SIGQUIT, _sigquit_to_keyboard_interrupt)
|
|
123
|
+
|
|
124
|
+
if len(sys.argv) >= 2:
|
|
125
|
+
ALIAS_TO_CANONICAL.get(sys.argv[1], sys.argv[1])
|
|
126
|
+
|
|
127
|
+
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help", "hel", "he", "h"):
|
|
128
|
+
command_help()
|
|
129
|
+
sys.exit(0)
|
|
130
|
+
|
|
131
|
+
raw_args = sys.argv[1:]
|
|
132
|
+
if _dispatch_help(raw_args):
|
|
133
|
+
sys.exit(0)
|
|
134
|
+
|
|
135
|
+
_reject_unknown_command(raw_args)
|
|
136
|
+
|
|
137
|
+
parser = build_parser()
|
|
138
|
+
args, unknown = parser.parse_known_args(raw_args)
|
|
139
|
+
|
|
140
|
+
command = args.command
|
|
141
|
+
if command is None:
|
|
142
|
+
msg()
|
|
143
|
+
crit_error(f"unknown command '{raw_args[0]}'.")
|
|
144
|
+
command_help()
|
|
145
|
+
msg()
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
canonical = ALIAS_TO_CANONICAL.get(command, command)
|
|
149
|
+
|
|
150
|
+
if getattr(args, "help", False):
|
|
151
|
+
if canonical in HELP_COMMANDS:
|
|
152
|
+
HELP_COMMANDS[canonical]()
|
|
153
|
+
else:
|
|
154
|
+
command_help()
|
|
155
|
+
sys.exit(0)
|
|
156
|
+
|
|
157
|
+
check_unknown = unknown
|
|
158
|
+
if canonical in ("login", "run") and "--" in raw_args:
|
|
159
|
+
sep_idx = raw_args.index("--")
|
|
160
|
+
_, check_unknown = parser.parse_known_args(raw_args[:sep_idx])
|
|
161
|
+
if check_unknown:
|
|
162
|
+
bad = check_unknown[0]
|
|
163
|
+
kind = "unrecognized option" if bad.startswith("-") else "unexpected argument"
|
|
164
|
+
msg()
|
|
165
|
+
crit_error(f"{kind}: '{bad}'.")
|
|
166
|
+
if canonical in HELP_COMMANDS:
|
|
167
|
+
HELP_COMMANDS[canonical]()
|
|
168
|
+
msg()
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
for arg_name, error_msg in REQUIRED_ARGS.get(canonical, []):
|
|
172
|
+
if getattr(args, arg_name, None) is None:
|
|
173
|
+
msg()
|
|
174
|
+
crit_error(error_msg)
|
|
175
|
+
if canonical in HELP_COMMANDS:
|
|
176
|
+
HELP_COMMANDS[canonical]()
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
|
|
179
|
+
_split_separator(canonical, raw_args, args)
|
|
180
|
+
|
|
181
|
+
if canonical != "list" and getattr(args, "quiet", False):
|
|
182
|
+
set_quiet(True)
|
|
183
|
+
|
|
184
|
+
# Root check requirement:
|
|
185
|
+
# - In normal Linux: all commands require root except "help"
|
|
186
|
+
# - In Termux: all commands require root except "list" and "help"
|
|
187
|
+
requires_root = False
|
|
188
|
+
if IS_TERMUX:
|
|
189
|
+
if canonical not in ("list", "help"):
|
|
190
|
+
requires_root = True
|
|
191
|
+
elif canonical != "help":
|
|
192
|
+
requires_root = True
|
|
193
|
+
|
|
194
|
+
if requires_root:
|
|
195
|
+
no_elevate = getattr(args, "no_elevate", False) or os.environ.get("CHROOT_DISTRO_NO_ELEVATE") == "1"
|
|
196
|
+
use_sudo = getattr(args, "use_sudo", False) or os.environ.get("CHROOT_DISTRO_USE_SUDO") == "1"
|
|
197
|
+
try:
|
|
198
|
+
_ensure_root_user(no_elevate=no_elevate, use_sudo=use_sudo)
|
|
199
|
+
except RootRequiredError as e:
|
|
200
|
+
msg()
|
|
201
|
+
crit_error(str(e))
|
|
202
|
+
msg()
|
|
203
|
+
sys.exit(1)
|
|
204
|
+
|
|
205
|
+
handler = _COMMAND_HANDLERS.get(canonical)
|
|
206
|
+
if handler is None:
|
|
207
|
+
crit_error(f"unknown command '{command}'.")
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
handler(args)
|
|
212
|
+
except ChrootDistroError as e:
|
|
213
|
+
msg()
|
|
214
|
+
crit_error(str(e))
|
|
215
|
+
msg()
|
|
216
|
+
sys.exit(1)
|
|
217
|
+
except KeyboardInterrupt:
|
|
218
|
+
msg()
|
|
219
|
+
crit_error("Aborted by user.")
|
|
220
|
+
msg()
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
except NotImplementedError as e:
|
|
223
|
+
msg()
|
|
224
|
+
crit_error(str(e))
|
|
225
|
+
msg()
|
|
226
|
+
sys.exit(1)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
msg()
|
|
229
|
+
crit_error(f"unexpected error: {e}")
|
|
230
|
+
msg()
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
main()
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
import stat
|
|
4
|
+
import sys
|
|
5
|
+
import tarfile
|
|
6
|
+
|
|
7
|
+
import chroot_distro.helpers.mount_manager as mount_manager
|
|
8
|
+
import chroot_distro.helpers.session as session
|
|
9
|
+
from chroot_distro.locking import ContainerLock
|
|
10
|
+
from chroot_distro.message import crit_error, log_error, log_info
|
|
11
|
+
from chroot_distro.names import require_valid_name
|
|
12
|
+
from chroot_distro.paths import container_manifest, container_rootfs
|
|
13
|
+
from chroot_distro.progress import (
|
|
14
|
+
REDRAW_THRESHOLD_BYTES,
|
|
15
|
+
clear_bar,
|
|
16
|
+
draw_bytes_bar,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_COMPRESS_EXTS = (
|
|
20
|
+
(".tar.gz", "gz"),
|
|
21
|
+
(".tgz", "gz"),
|
|
22
|
+
(".tar.bz2", "bz2"),
|
|
23
|
+
(".tbz2", "bz2"),
|
|
24
|
+
(".tar.xz", "xz"),
|
|
25
|
+
(".txz", "xz"),
|
|
26
|
+
(".tar.lzma", "xz"),
|
|
27
|
+
(".tlzma", "xz"),
|
|
28
|
+
(".tar", ""),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_UNSUPPORTED_EXTS = (".tar.zst", ".tzst", ".tar.lz4", ".tar.lz")
|
|
32
|
+
|
|
33
|
+
_COMPRESSION_ARG_MAP = {
|
|
34
|
+
"gzip": "gz",
|
|
35
|
+
"bzip2": "bz2",
|
|
36
|
+
"xz": "xz",
|
|
37
|
+
"none": "",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _compression_mode(filename: str) -> str:
|
|
42
|
+
"""Return the tarfile compression suffix for *filename*'s extension."""
|
|
43
|
+
low = filename.lower()
|
|
44
|
+
for ext, comp in _COMPRESS_EXTS:
|
|
45
|
+
if low.endswith(ext):
|
|
46
|
+
return comp
|
|
47
|
+
for ext in _UNSUPPORTED_EXTS:
|
|
48
|
+
if low.endswith(ext):
|
|
49
|
+
raise ValueError(f"Compression format '{ext}' is not supported.")
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _iter_entries(root: str, arcroot: str):
|
|
54
|
+
"""Yield *(src_path, arcname)* for every entry under *root* in sorted order."""
|
|
55
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False, topdown=True):
|
|
56
|
+
rel = os.path.relpath(dirpath, root)
|
|
57
|
+
dirnames.sort()
|
|
58
|
+
arc_dir = arcroot if rel == "." else os.path.join(arcroot, rel)
|
|
59
|
+
|
|
60
|
+
yield (dirpath, arc_dir)
|
|
61
|
+
|
|
62
|
+
i = 0
|
|
63
|
+
while i < len(dirnames):
|
|
64
|
+
d = dirnames[i]
|
|
65
|
+
if os.path.islink(os.path.join(dirpath, d)):
|
|
66
|
+
yield (os.path.join(dirpath, d), os.path.join(arc_dir, d))
|
|
67
|
+
dirnames.pop(i)
|
|
68
|
+
else:
|
|
69
|
+
i += 1
|
|
70
|
+
|
|
71
|
+
for fname in sorted(filenames):
|
|
72
|
+
yield (os.path.join(dirpath, fname), os.path.join(arc_dir, fname))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _ReadCounter:
|
|
76
|
+
"""File wrapper that calls on_read(n) with the byte count after each read."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, fh, on_read):
|
|
79
|
+
self._fh = fh
|
|
80
|
+
self._on_read = on_read
|
|
81
|
+
|
|
82
|
+
def read(self, n=-1):
|
|
83
|
+
data = self._fh.read(n)
|
|
84
|
+
if data:
|
|
85
|
+
self._on_read(len(data))
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, name):
|
|
89
|
+
return getattr(self._fh, name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _add_path(
|
|
93
|
+
tf: tarfile.TarFile,
|
|
94
|
+
src: str,
|
|
95
|
+
arcname: str,
|
|
96
|
+
on_read=None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Add *src* to *tf* as *arcname*, stripping ownership info."""
|
|
99
|
+
try:
|
|
100
|
+
st = os.lstat(src)
|
|
101
|
+
except OSError:
|
|
102
|
+
return
|
|
103
|
+
m = st.st_mode
|
|
104
|
+
if stat.S_ISBLK(m) or stat.S_ISCHR(m) or stat.S_ISFIFO(m) or stat.S_ISSOCK(m):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
info = tf.gettarinfo(src, arcname=arcname)
|
|
109
|
+
except OSError:
|
|
110
|
+
return
|
|
111
|
+
info.uid = 0
|
|
112
|
+
info.gid = 0
|
|
113
|
+
info.uname = ""
|
|
114
|
+
info.gname = ""
|
|
115
|
+
if stat.S_ISREG(m):
|
|
116
|
+
try:
|
|
117
|
+
with open(src, "rb") as fh:
|
|
118
|
+
tf.addfile(info, _ReadCounter(fh, on_read) if on_read else fh)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
else:
|
|
122
|
+
tf.addfile(info)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fix_permissions(rootfs_dir: str) -> None:
|
|
126
|
+
"""Ensure all dirs and files in *rootfs_dir* are readable by owner."""
|
|
127
|
+
for dirpath, _dirs, files in os.walk(rootfs_dir):
|
|
128
|
+
with contextlib.suppress(OSError):
|
|
129
|
+
os.chmod(
|
|
130
|
+
dirpath,
|
|
131
|
+
os.stat(dirpath).st_mode | stat.S_IRUSR | stat.S_IXUSR,
|
|
132
|
+
)
|
|
133
|
+
for fname in files:
|
|
134
|
+
fpath = os.path.join(dirpath, fname)
|
|
135
|
+
try:
|
|
136
|
+
fst = os.lstat(fpath)
|
|
137
|
+
if stat.S_ISREG(fst.st_mode):
|
|
138
|
+
mode = fst.st_mode
|
|
139
|
+
if mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
|
|
140
|
+
os.chmod(fpath, mode | stat.S_IRUSR | stat.S_IXUSR)
|
|
141
|
+
else:
|
|
142
|
+
os.chmod(fpath, mode | stat.S_IRUSR)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def command_backup(args) -> None:
|
|
148
|
+
"""Archive an installed container to a tar file or stdout."""
|
|
149
|
+
container_name = args.container_name
|
|
150
|
+
output_path = getattr(args, "output", None)
|
|
151
|
+
compression_arg = getattr(args, "compression", None)
|
|
152
|
+
verbose = getattr(args, "verbose", False)
|
|
153
|
+
|
|
154
|
+
require_valid_name(container_name)
|
|
155
|
+
|
|
156
|
+
rootfs_dir = container_rootfs(container_name)
|
|
157
|
+
manifest_path = container_manifest(container_name)
|
|
158
|
+
|
|
159
|
+
if not os.path.isdir(rootfs_dir):
|
|
160
|
+
crit_error(f"container '{container_name}' does not exist.")
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
if output_path is not None and not output_path:
|
|
164
|
+
crit_error("output file path cannot be empty.")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
if output_path:
|
|
168
|
+
if os.path.isdir(output_path):
|
|
169
|
+
crit_error(f"cannot write to '{output_path}' because this path is a directory.")
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
if os.path.isfile(output_path):
|
|
172
|
+
crit_error(f"file '{output_path}' already exists. Please specify a different name.")
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
if compression_arg is not None:
|
|
175
|
+
compression = _COMPRESSION_ARG_MAP[compression_arg]
|
|
176
|
+
else:
|
|
177
|
+
try:
|
|
178
|
+
compression = _compression_mode(output_path)
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
crit_error(str(exc).lower())
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
else:
|
|
183
|
+
if sys.stdout.isatty():
|
|
184
|
+
crit_error("archive data cannot be printed to console. Please specify --output.")
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
compression = _COMPRESSION_ARG_MAP[compression_arg] if compression_arg is not None else ""
|
|
187
|
+
|
|
188
|
+
with ContainerLock(container_name, exclusive=False, command="backup"):
|
|
189
|
+
# 1. Active sessions safety check
|
|
190
|
+
active_pids = session.get_active_chroot_pids(container_name)
|
|
191
|
+
if active_pids:
|
|
192
|
+
crit_error(f"Cannot backup container '{container_name}': It has active sessions (PIDs: {active_pids}).")
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
# 2. Mount safety check: ensure no active mounts exist under rootfs
|
|
196
|
+
mounts = mount_manager.get_active_mounts(rootfs_dir)
|
|
197
|
+
if mounts:
|
|
198
|
+
crit_error(f"Cannot backup container '{container_name}': Active mounts detected under rootfs: {mounts}.")
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
|
|
201
|
+
_run_backup(
|
|
202
|
+
container_name,
|
|
203
|
+
rootfs_dir,
|
|
204
|
+
manifest_path,
|
|
205
|
+
output_path,
|
|
206
|
+
compression,
|
|
207
|
+
verbose,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _run_backup(
|
|
212
|
+
container_name,
|
|
213
|
+
rootfs_dir,
|
|
214
|
+
manifest_path,
|
|
215
|
+
output_path,
|
|
216
|
+
compression,
|
|
217
|
+
verbose,
|
|
218
|
+
):
|
|
219
|
+
log_info(f"Backing up '{container_name}'...")
|
|
220
|
+
|
|
221
|
+
if output_path:
|
|
222
|
+
log_info(f"Will write backup data to '{output_path}'.")
|
|
223
|
+
else:
|
|
224
|
+
log_info("Will write backup data to stdout.")
|
|
225
|
+
|
|
226
|
+
log_info("Fixing file permissions in rootfs...")
|
|
227
|
+
_fix_permissions(rootfs_dir)
|
|
228
|
+
|
|
229
|
+
arc_prefix = container_name
|
|
230
|
+
entries = []
|
|
231
|
+
if os.path.isfile(manifest_path):
|
|
232
|
+
entries.append((manifest_path, os.path.join(arc_prefix, "manifest.json")))
|
|
233
|
+
entries.extend(_iter_entries(rootfs_dir, os.path.join(arc_prefix, "rootfs")))
|
|
234
|
+
|
|
235
|
+
total_size = 0
|
|
236
|
+
for src, _arc in entries:
|
|
237
|
+
try:
|
|
238
|
+
st = os.lstat(src)
|
|
239
|
+
except OSError:
|
|
240
|
+
continue
|
|
241
|
+
if stat.S_ISREG(st.st_mode):
|
|
242
|
+
total_size += st.st_size
|
|
243
|
+
|
|
244
|
+
done_size = 0
|
|
245
|
+
log_info("Archiving the container...")
|
|
246
|
+
|
|
247
|
+
_last_shown = 0
|
|
248
|
+
|
|
249
|
+
def _draw_bar() -> None:
|
|
250
|
+
nonlocal _last_shown
|
|
251
|
+
draw_bytes_bar(done_size, total_size)
|
|
252
|
+
_last_shown = done_size
|
|
253
|
+
|
|
254
|
+
def _on_read(n: int) -> None:
|
|
255
|
+
nonlocal done_size
|
|
256
|
+
done_size += n
|
|
257
|
+
if done_size - _last_shown >= REDRAW_THRESHOLD_BYTES:
|
|
258
|
+
_draw_bar()
|
|
259
|
+
|
|
260
|
+
def _on_entry(arc: str) -> None:
|
|
261
|
+
if verbose:
|
|
262
|
+
log_info(f"Adding: '{arc}'")
|
|
263
|
+
_draw_bar()
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
tar_mode = f"w:{compression}" if output_path else f"w|{compression}"
|
|
267
|
+
if output_path:
|
|
268
|
+
tf = tarfile.open(output_path, mode=tar_mode) # type: ignore[call-overload] # noqa: SIM115
|
|
269
|
+
else:
|
|
270
|
+
tf = tarfile.open(fileobj=sys.stdout.buffer, mode=tar_mode) # type: ignore[call-overload] # noqa: SIM115
|
|
271
|
+
with tf:
|
|
272
|
+
for src, arc in entries:
|
|
273
|
+
_add_path(tf, src, arc, on_read=_on_read)
|
|
274
|
+
_on_entry(arc)
|
|
275
|
+
|
|
276
|
+
clear_bar()
|
|
277
|
+
log_info("Finished backing up.")
|
|
278
|
+
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
clear_bar()
|
|
281
|
+
log_error("Aborted by user.")
|
|
282
|
+
if output_path:
|
|
283
|
+
with contextlib.suppress(OSError):
|
|
284
|
+
os.remove(output_path)
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
except (OSError, tarfile.TarError) as exc:
|
|
287
|
+
clear_bar()
|
|
288
|
+
log_error(f"Failed to create backup archive: {exc}")
|
|
289
|
+
if output_path:
|
|
290
|
+
with contextlib.suppress(OSError):
|
|
291
|
+
os.remove(output_path)
|
|
292
|
+
sys.exit(1)
|