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.
Files changed (77) hide show
  1. chroot_distro/__init__.py +6 -0
  2. chroot_distro/arch.py +135 -0
  3. chroot_distro/atomic.py +23 -0
  4. chroot_distro/cli.py +235 -0
  5. chroot_distro/commands/backup.py +292 -0
  6. chroot_distro/commands/build.py +316 -0
  7. chroot_distro/commands/clear_cache.py +61 -0
  8. chroot_distro/commands/copy.py +96 -0
  9. chroot_distro/commands/help/__init__.py +109 -0
  10. chroot_distro/commands/help/pages.py +607 -0
  11. chroot_distro/commands/help/render.py +276 -0
  12. chroot_distro/commands/install.py +256 -0
  13. chroot_distro/commands/install_local.py +213 -0
  14. chroot_distro/commands/list_cmd.py +34 -0
  15. chroot_distro/commands/login/__init__.py +610 -0
  16. chroot_distro/commands/login/bindings.py +442 -0
  17. chroot_distro/commands/login/chroot_cmd.py +63 -0
  18. chroot_distro/commands/login/env.py +163 -0
  19. chroot_distro/commands/login/passwd.py +363 -0
  20. chroot_distro/commands/push.py +91 -0
  21. chroot_distro/commands/remove.py +102 -0
  22. chroot_distro/commands/rename.py +61 -0
  23. chroot_distro/commands/reset.py +73 -0
  24. chroot_distro/commands/restore.py +328 -0
  25. chroot_distro/commands/run.py +64 -0
  26. chroot_distro/commands/sync.py +414 -0
  27. chroot_distro/commands/unmount.py +89 -0
  28. chroot_distro/completions/_chroot-distro +313 -0
  29. chroot_distro/completions/chroot-distro.bash +330 -0
  30. chroot_distro/completions/chroot-distro.fish +395 -0
  31. chroot_distro/constants.py +73 -0
  32. chroot_distro/elevate.py +97 -0
  33. chroot_distro/exceptions.py +46 -0
  34. chroot_distro/helpers/__init__.py +1 -0
  35. chroot_distro/helpers/android.py +194 -0
  36. chroot_distro/helpers/build_cache.py +148 -0
  37. chroot_distro/helpers/build_engine/__init__.py +15 -0
  38. chroot_distro/helpers/build_engine/constants.py +61 -0
  39. chroot_distro/helpers/build_engine/copy_step.py +558 -0
  40. chroot_distro/helpers/build_engine/dockerignore.py +58 -0
  41. chroot_distro/helpers/build_engine/engine.py +396 -0
  42. chroot_distro/helpers/build_engine/errors.py +2 -0
  43. chroot_distro/helpers/build_engine/handlers.py +271 -0
  44. chroot_distro/helpers/build_engine/parsing.py +76 -0
  45. chroot_distro/helpers/build_engine/run_step.py +203 -0
  46. chroot_distro/helpers/build_engine/stage.py +42 -0
  47. chroot_distro/helpers/build_engine/users.py +56 -0
  48. chroot_distro/helpers/docker/__init__.py +53 -0
  49. chroot_distro/helpers/docker/cache.py +63 -0
  50. chroot_distro/helpers/docker/layers.py +125 -0
  51. chroot_distro/helpers/docker/media.py +18 -0
  52. chroot_distro/helpers/docker/pull.py +228 -0
  53. chroot_distro/helpers/docker/push.py +347 -0
  54. chroot_distro/helpers/docker/refs.py +52 -0
  55. chroot_distro/helpers/docker/transport.py +163 -0
  56. chroot_distro/helpers/dockerfile.py +472 -0
  57. chroot_distro/helpers/download.py +60 -0
  58. chroot_distro/helpers/layer_diff.py +465 -0
  59. chroot_distro/helpers/mount_manager.py +253 -0
  60. chroot_distro/helpers/namespace.py +409 -0
  61. chroot_distro/helpers/oci_writer.py +267 -0
  62. chroot_distro/helpers/rootfs.py +151 -0
  63. chroot_distro/helpers/session.py +140 -0
  64. chroot_distro/helpers/tar_extract.py +175 -0
  65. chroot_distro/helpers/x11.py +195 -0
  66. chroot_distro/locking.py +178 -0
  67. chroot_distro/message.py +140 -0
  68. chroot_distro/names.py +18 -0
  69. chroot_distro/parser.py +318 -0
  70. chroot_distro/paths.py +69 -0
  71. chroot_distro/progress.py +91 -0
  72. chroot_distro/py.typed +1 -0
  73. chroot_distro-1.5.6.dist-info/METADATA +1076 -0
  74. chroot_distro-1.5.6.dist-info/RECORD +77 -0
  75. chroot_distro-1.5.6.dist-info/WHEEL +4 -0
  76. chroot_distro-1.5.6.dist-info/entry_points.txt +2 -0
  77. chroot_distro-1.5.6.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,6 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version("chroot-distro")
5
+ except importlib.metadata.PackageNotFoundError:
6
+ __version__ = "rolling"
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
+ }
@@ -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)