chroot-distro 2.2.0__tar.gz → 2.2.1__tar.gz

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 (136) hide show
  1. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/PKG-INFO +1 -1
  2. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/pyproject.toml +1 -1
  3. chroot_distro-2.2.1/src/chroot_distro/commands/kill.py +157 -0
  4. chroot_distro-2.2.1/tests/unit/test_kill.py +317 -0
  5. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/uv.lock +1 -1
  6. chroot_distro-2.2.0/src/chroot_distro/commands/kill.py +0 -88
  7. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.editorconfig +0 -0
  8. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.github/codeql/codeql-config.yml +0 -0
  9. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.github/dependabot.yml +0 -0
  10. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.github/workflows/ci.yml +0 -0
  11. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.github/workflows/codeql.yml +0 -0
  12. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.github/workflows/publish.yml +0 -0
  13. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.gitignore +0 -0
  14. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/.python-version +0 -0
  15. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/LICENSE +0 -0
  16. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/README.md +0 -0
  17. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/check-before-commit.sh +0 -0
  18. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/__init__.py +0 -0
  19. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/arch.py +0 -0
  20. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/atomic.py +0 -0
  21. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/cli.py +0 -0
  22. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/backup.py +0 -0
  23. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/build.py +0 -0
  24. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/clear_cache.py +0 -0
  25. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/copy.py +0 -0
  26. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/diff.py +0 -0
  27. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/help/__init__.py +0 -0
  28. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/help/pages.py +0 -0
  29. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/help/render.py +0 -0
  30. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/install.py +0 -0
  31. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/install_local.py +0 -0
  32. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/list_cmd.py +0 -0
  33. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/login/__init__.py +0 -0
  34. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/login/bindings.py +0 -0
  35. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/login/chroot_cmd.py +0 -0
  36. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/login/env.py +0 -0
  37. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/login/passwd.py +0 -0
  38. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/ps.py +0 -0
  39. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/push.py +0 -0
  40. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/remove.py +0 -0
  41. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/rename.py +0 -0
  42. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/reset.py +0 -0
  43. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/restore.py +0 -0
  44. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/run.py +0 -0
  45. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/search.py +0 -0
  46. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/sync.py +0 -0
  47. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/commands/unmount.py +0 -0
  48. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/completions/_chroot-distro +0 -0
  49. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/completions/chroot-distro.bash +0 -0
  50. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/completions/chroot-distro.fish +0 -0
  51. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/constants.py +0 -0
  52. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/elevate.py +0 -0
  53. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/exceptions.py +0 -0
  54. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/__init__.py +0 -0
  55. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/android.py +0 -0
  56. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_cache.py +0 -0
  57. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/__init__.py +0 -0
  58. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/constants.py +0 -0
  59. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/copy_step.py +0 -0
  60. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/dockerignore.py +0 -0
  61. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/engine.py +0 -0
  62. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/errors.py +0 -0
  63. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/handlers.py +0 -0
  64. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/parsing.py +0 -0
  65. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/run_step.py +0 -0
  66. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/stage.py +0 -0
  67. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/build_engine/users.py +0 -0
  68. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/display.py +0 -0
  69. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/__init__.py +0 -0
  70. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/cache.py +0 -0
  71. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/layers.py +0 -0
  72. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/media.py +0 -0
  73. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/pull.py +0 -0
  74. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/push.py +0 -0
  75. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/refs.py +0 -0
  76. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/docker/transport.py +0 -0
  77. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/dockerfile.py +0 -0
  78. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/download.py +0 -0
  79. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/gpu.py +0 -0
  80. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/layer_diff.py +0 -0
  81. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/mount_manager.py +0 -0
  82. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/namespace.py +0 -0
  83. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/nvidia.py +0 -0
  84. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/oci_writer.py +0 -0
  85. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/rootfs.py +0 -0
  86. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/session.py +0 -0
  87. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/sound.py +0 -0
  88. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/tar_extract.py +0 -0
  89. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/wayland.py +0 -0
  90. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/helpers/x11.py +0 -0
  91. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/locking.py +0 -0
  92. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/message.py +0 -0
  93. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/names.py +0 -0
  94. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/parser.py +0 -0
  95. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/paths.py +0 -0
  96. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/progress.py +0 -0
  97. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/py.typed +0 -0
  98. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/src/chroot_distro/rate_limit.py +0 -0
  99. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/conftest.py +0 -0
  100. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_android.py +0 -0
  101. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_arch.py +0 -0
  102. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_backup_restore.py +0 -0
  103. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_bind_options.py +0 -0
  104. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_cli.py +0 -0
  105. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_constants.py +0 -0
  106. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_diff_baseline_cache.py +0 -0
  107. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_display.py +0 -0
  108. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_display_sockets.py +0 -0
  109. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_docker_refs.py +0 -0
  110. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_dockerfile.py +0 -0
  111. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_download_algorithms.py +0 -0
  112. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_download_blob_multi.py +0 -0
  113. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_download_multi.py +0 -0
  114. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_elevate.py +0 -0
  115. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_gpu.py +0 -0
  116. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_install.py +0 -0
  117. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_install_local.py +0 -0
  118. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_layer_diff.py +0 -0
  119. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_list.py +0 -0
  120. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_locking.py +0 -0
  121. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_login_helpers.py +0 -0
  122. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_message.py +0 -0
  123. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_mount_manager_ns.py +0 -0
  124. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_names.py +0 -0
  125. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_namespace.py +0 -0
  126. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_parser.py +0 -0
  127. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_paths.py +0 -0
  128. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_progress.py +0 -0
  129. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_push_chunked.py +0 -0
  130. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_remove.py +0 -0
  131. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_rootfs.py +0 -0
  132. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_sound.py +0 -0
  133. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_tar_extract.py +0 -0
  134. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_unmount.py +0 -0
  135. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_wayland.py +0 -0
  136. {chroot_distro-2.2.0 → chroot_distro-2.2.1}/tests/unit/test_x11.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chroot-distro
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: chroot-distro is a lightweight Linux container management utility built around chroot.
5
5
  Project-URL: Homepage, https://github.com/sabamdarif/chroot-distro
6
6
  Project-URL: Repository, https://github.com/sabamdarif/chroot-distro
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "chroot-distro"
7
- version = "2.2.0"
7
+ version = "2.2.1"
8
8
  description = "chroot-distro is a lightweight Linux container management utility built around chroot."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,157 @@
1
+ import contextlib
2
+ import os
3
+ import signal
4
+ import subprocess
5
+ import sys
6
+ import time
7
+
8
+ import chroot_distro.helpers.mount_manager as mount_manager
9
+ import chroot_distro.helpers.namespace as namespace
10
+ import chroot_distro.helpers.session as session
11
+ from chroot_distro.locking import ContainerLock
12
+ from chroot_distro.message import crit_error, log_info, warn
13
+ from chroot_distro.names import require_valid_name
14
+ from chroot_distro.paths import container_rootfs
15
+
16
+ _SIGTERM_GRACE_SECS = 1.0
17
+ _SIGKILL_WAIT_SECS = 2.0
18
+
19
+
20
+ def _wait_until_gone(container_name: str, timeout: float) -> list[int]:
21
+ """Poll for active chroot PIDs until none remain or *timeout* elapses."""
22
+ deadline = time.time() + timeout
23
+ while time.time() < deadline:
24
+ remaining = session.get_active_chroot_pids(container_name)
25
+ if not remaining:
26
+ return []
27
+ time.sleep(0.1)
28
+ return session.get_active_chroot_pids(container_name)
29
+
30
+
31
+ def command_kill(args) -> None:
32
+ """Forcibly stop all processes in a container and tear it down.
33
+
34
+ First try standard unmount, then lazy unmount. If mounts remain or processes
35
+ are active, kill all processes and retry unmounting. If still failing,
36
+ try forceful unmount and print a detailed error if mounts remain.
37
+ """
38
+ container_name = args.container_name
39
+ require_valid_name(container_name)
40
+
41
+ rootfs_dir = container_rootfs(container_name)
42
+ if not os.path.isdir(rootfs_dir):
43
+ crit_error(f"container '{container_name}' is not installed.")
44
+ sys.exit(1)
45
+
46
+ holder = namespace.get_live_holder(container_name)
47
+
48
+ active_pids = session.get_active_chroot_pids(container_name)
49
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
50
+ if not active_pids and holder is None and not active_mounts:
51
+ log_info(f"Container '{container_name}' is not running.")
52
+ return
53
+
54
+ umount_bin = mount_manager._resolve_umount()
55
+
56
+ def run_umount(target_path: str, flags: list[str] | None = None) -> bool:
57
+ cmd = [umount_bin]
58
+ if flags:
59
+ cmd.extend(flags)
60
+ cmd.append(target_path)
61
+
62
+ if holder is not None:
63
+ res = holder.run(cmd, capture_output=True, text=True)
64
+ else:
65
+ res = subprocess.run(cmd, capture_output=True, text=True, check=False)
66
+ return res.returncode == 0
67
+
68
+ lock = ContainerLock(container_name, exclusive=True, command="kill")
69
+ acquired = lock.acquire()
70
+ if not acquired:
71
+ log_info(f"Container '{container_name}' is busy (active sessions exist). Forcing cleanup...")
72
+
73
+ try:
74
+ # Step 1: Try standard unmount
75
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
76
+ if active_mounts:
77
+ log_info("Attempting standard unmount of active mount points...")
78
+ for m in active_mounts:
79
+ run_umount(m)
80
+
81
+ # Step 2: Try lazy unmount if mounts remain
82
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
83
+ if active_mounts:
84
+ log_info("Some mounts remain busy. Attempting lazy unmount...")
85
+ for m in active_mounts:
86
+ run_umount(m, ["-l"])
87
+
88
+ # Step 3: Kill processes and unmount again if active PIDs or mounts remain
89
+ active_pids = session.get_active_chroot_pids(container_name)
90
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
91
+ if active_pids or active_mounts:
92
+ if active_pids:
93
+ log_info(
94
+ f"Killing {len(active_pids)} process(es) in container '{container_name}' (PIDs: {active_pids})..."
95
+ )
96
+ for pid in active_pids:
97
+ with contextlib.suppress(OSError):
98
+ os.kill(pid, signal.SIGTERM)
99
+
100
+ remaining = _wait_until_gone(container_name, _SIGTERM_GRACE_SECS)
101
+ if remaining:
102
+ log_info(f"Processes {remaining} did not exit; sending SIGKILL...")
103
+ for pid in remaining:
104
+ with contextlib.suppress(OSError):
105
+ os.kill(pid, signal.SIGKILL)
106
+ remaining = _wait_until_gone(container_name, _SIGKILL_WAIT_SECS)
107
+ if remaining:
108
+ warn(f"Some processes could not be killed: {remaining}")
109
+
110
+ # Now that processes have been signaled, attempt to acquire the exclusive lock
111
+ if not acquired:
112
+ acquired = lock.acquire()
113
+
114
+ # Retry unmounting after killing processes
115
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
116
+ if active_mounts:
117
+ log_info("Retrying standard unmount after killing processes...")
118
+ for m in active_mounts:
119
+ run_umount(m)
120
+
121
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
122
+ if active_mounts:
123
+ log_info("Retrying lazy unmount after killing processes...")
124
+ for m in active_mounts:
125
+ run_umount(m, ["-l"])
126
+
127
+ # Step 4: Forceful unmount and detailed error if still failed
128
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
129
+ if active_mounts:
130
+ log_info("Some mounts still remain. Attempting forceful unmount...")
131
+ for m in active_mounts:
132
+ run_umount(m, ["-f"])
133
+
134
+ active_mounts = mount_manager.get_active_mounts(rootfs_dir, holder=holder)
135
+ if active_mounts:
136
+ active_pids = session.get_active_chroot_pids(container_name)
137
+ crit_error(
138
+ f"Failed to kill and unmount container '{container_name}'.\n"
139
+ f"Remaining active mounts:\n" + "\n".join(f" - {m}" for m in active_mounts) + "\n"
140
+ f"Remaining active process PIDs: {active_pids if active_pids else 'None'}"
141
+ )
142
+ sys.exit(1)
143
+
144
+ # Cleanup namespace and sessions
145
+ session.reset(container_name)
146
+ if holder is not None:
147
+ namespace.release_holder(container_name)
148
+ namespace.clear_isolation_mode(container_name)
149
+
150
+ log_info(f"Container '{container_name}' successfully killed and unmounted.")
151
+
152
+ finally:
153
+ if acquired:
154
+ lock.release()
155
+
156
+
157
+ __all__ = ("command_kill",)
@@ -0,0 +1,317 @@
1
+ import signal
2
+ import sys
3
+ from unittest.mock import MagicMock, call, patch
4
+
5
+ import pytest
6
+
7
+ from chroot_distro.commands.kill import command_kill
8
+ from chroot_distro.exceptions import LockConflictError
9
+ from chroot_distro.parser import build_parser
10
+
11
+
12
+ def test_parser_kill():
13
+ parser = build_parser()
14
+ args = parser.parse_args(["kill", "ubuntu"])
15
+ assert args.command == "kill"
16
+ assert args.container_name == "ubuntu"
17
+
18
+
19
+ @patch("chroot_distro.commands.kill.container_rootfs", return_value="/mock/containers/ubuntu/rootfs")
20
+ @patch("os.path.isdir", return_value=False)
21
+ @patch("chroot_distro.commands.kill.crit_error")
22
+ def test_kill_container_not_installed(mock_crit_error, mock_isdir, mock_rootfs):
23
+ args = MagicMock()
24
+ args.container_name = "ubuntu"
25
+
26
+ with pytest.raises(SystemExit) as exc_info:
27
+ command_kill(args)
28
+
29
+ assert exc_info.value.code == 1
30
+ mock_crit_error.assert_called_once_with("container 'ubuntu' is not installed.")
31
+
32
+
33
+ @patch("chroot_distro.commands.kill.namespace.get_live_holder", return_value=None)
34
+ @patch("chroot_distro.commands.kill.container_rootfs", return_value="/mock/containers/ubuntu/rootfs")
35
+ @patch("os.path.isdir", return_value=True)
36
+ @patch("chroot_distro.commands.kill.session")
37
+ @patch("chroot_distro.commands.kill.mount_manager")
38
+ @patch("chroot_distro.commands.kill.log_info")
39
+ def test_kill_not_running(mock_log, mock_mount, mock_session, mock_isdir, mock_rootfs, *_mocks):
40
+ args = MagicMock()
41
+ args.container_name = "ubuntu"
42
+
43
+ mock_session.get_active_chroot_pids.return_value = []
44
+ mock_mount.get_active_mounts.return_value = []
45
+
46
+ command_kill(args)
47
+
48
+ mock_log.assert_called_once_with("Container 'ubuntu' is not running.")
49
+
50
+
51
+ @patch("chroot_distro.commands.kill.namespace.get_live_holder", return_value=None)
52
+ @patch("chroot_distro.commands.kill.container_rootfs", return_value="/mock/containers/ubuntu/rootfs")
53
+ @patch("os.path.isdir", return_value=True)
54
+ @patch("chroot_distro.commands.kill.ContainerLock")
55
+ @patch("chroot_distro.commands.kill.session")
56
+ @patch("chroot_distro.commands.kill.mount_manager")
57
+ @patch("chroot_distro.commands.kill.log_info")
58
+ @patch("subprocess.run")
59
+ def test_kill_standard_unmount_success(
60
+ mock_run, mock_log, mock_mount, mock_session, mock_lock, mock_isdir, mock_rootfs, *_mocks
61
+ ):
62
+ """If standard unmount succeeds, we don't need lazy/kill/forceful."""
63
+ args = MagicMock()
64
+ args.container_name = "ubuntu"
65
+
66
+ mock_session.get_active_chroot_pids.return_value = []
67
+ # get_active_mounts returns active mounts for first check, then empty for subsequent checks
68
+ mock_mount.get_active_mounts.side_effect = [
69
+ ["/mock/containers/ubuntu/rootfs/proc"], # initial check
70
+ ["/mock/containers/ubuntu/rootfs/proc"], # Step 1 check
71
+ [], # Step 2 check
72
+ [], # Step 3 check
73
+ [], # Step 4 check
74
+ ]
75
+ mock_mount._resolve_umount.return_value = "/bin/umount"
76
+
77
+ mock_lock_instance = MagicMock()
78
+ mock_lock_instance.acquire.return_value = True
79
+ mock_lock.return_value = mock_lock_instance
80
+
81
+ mock_run_res = MagicMock()
82
+ mock_run_res.returncode = 0
83
+ mock_run.return_value = mock_run_res
84
+
85
+ command_kill(args)
86
+
87
+ mock_lock_instance.acquire.assert_called_once()
88
+ mock_run.assert_called_once_with(
89
+ ["/bin/umount", "/mock/containers/ubuntu/rootfs/proc"], capture_output=True, text=True, check=False
90
+ )
91
+ mock_session.reset.assert_called_once_with("ubuntu")
92
+ mock_log.assert_any_call("Container 'ubuntu' successfully killed and unmounted.")
93
+ mock_lock_instance.release.assert_called_once()
94
+
95
+
96
+ @patch("chroot_distro.commands.kill.namespace.get_live_holder", return_value=None)
97
+ @patch("chroot_distro.commands.kill.container_rootfs", return_value="/mock/containers/ubuntu/rootfs")
98
+ @patch("os.path.isdir", return_value=True)
99
+ @patch("chroot_distro.commands.kill.ContainerLock")
100
+ @patch("chroot_distro.commands.kill.session")
101
+ @patch("chroot_distro.commands.kill.mount_manager")
102
+ @patch("chroot_distro.commands.kill.log_info")
103
+ @patch("subprocess.run")
104
+ def test_kill_lazy_unmount_success(
105
+ mock_run, mock_log, mock_mount, mock_session, mock_lock, mock_isdir, mock_rootfs, *_mocks
106
+ ):
107
+ """If standard unmount fails, lazy unmount succeeds."""
108
+ args = MagicMock()
109
+ args.container_name = "ubuntu"
110
+
111
+ mock_session.get_active_chroot_pids.return_value = []
112
+ # get_active_mounts sequences:
113
+ mock_mount.get_active_mounts.side_effect = [
114
+ ["/mock/containers/ubuntu/rootfs/proc"], # initial check
115
+ ["/mock/containers/ubuntu/rootfs/proc"], # Step 1 check
116
+ ["/mock/containers/ubuntu/rootfs/proc"], # Step 2 check
117
+ [], # Step 3 check
118
+ [], # Step 4 check
119
+ ]
120
+ mock_mount._resolve_umount.return_value = "/bin/umount"
121
+
122
+ mock_lock_instance = MagicMock()
123
+ mock_lock_instance.acquire.return_value = True
124
+ mock_lock.return_value = mock_lock_instance
125
+
126
+ # Standard umount fails, lazy umount succeeds
127
+ mock_run_res_fail = MagicMock(returncode=1)
128
+ mock_run_res_ok = MagicMock(returncode=0)
129
+ mock_run.side_effect = [mock_run_res_fail, mock_run_res_ok]
130
+
131
+ command_kill(args)
132
+
133
+ mock_run.assert_has_calls(
134
+ [
135
+ call(["/bin/umount", "/mock/containers/ubuntu/rootfs/proc"], capture_output=True, text=True, check=False),
136
+ call(
137
+ ["/bin/umount", "-l", "/mock/containers/ubuntu/rootfs/proc"],
138
+ capture_output=True,
139
+ text=True,
140
+ check=False,
141
+ ),
142
+ ]
143
+ )
144
+ mock_session.reset.assert_called_once_with("ubuntu")
145
+ mock_log.assert_any_call("Container 'ubuntu' successfully killed and unmounted.")
146
+
147
+
148
+ @patch("chroot_distro.commands.kill.namespace.get_live_holder", return_value=None)
149
+ @patch("chroot_distro.commands.kill.container_rootfs", return_value="/mock/containers/ubuntu/rootfs")
150
+ @patch("os.path.isdir", return_value=True)
151
+ @patch("chroot_distro.commands.kill.ContainerLock")
152
+ @patch("chroot_distro.commands.kill.session")
153
+ @patch("chroot_distro.commands.kill.mount_manager")
154
+ @patch("chroot_distro.commands.kill.log_info")
155
+ @patch("subprocess.run")
156
+ @patch("chroot_distro.commands.kill.os.kill")
157
+ @patch("chroot_distro.commands.kill.time.sleep")
158
+ @patch("chroot_distro.commands.kill.time.time")
159
+ def test_kill_process_then_unmount(
160
+ mock_time,
161
+ mock_sleep,
162
+ mock_kill,
163
+ mock_run,
164
+ mock_log,
165
+ mock_mount,
166
+ mock_session,
167
+ mock_lock,
168
+ mock_isdir,
169
+ mock_rootfs,
170
+ *_mocks,
171
+ ):
172
+ """Processes are active, we terminate them and successfully retry unmounting."""
173
+ args = MagicMock()
174
+ args.container_name = "ubuntu"
175
+
176
+ # PIDs sequence:
177
+ # 1. Initial check (PIDs active) -> [1000]
178
+ # 2. Step 3 check -> [1000]
179
+ # 3. _wait_until_gone loop check -> [] (exited after SIGTERM)
180
+ mock_session.get_active_chroot_pids.side_effect = [[1000], [1000], []]
181
+
182
+ # Mounts sequence:
183
+ # 1. Initial check -> ["/mock/containers/ubuntu/rootfs/proc"]
184
+ # 2. Step 1 (standard) -> ["/mock/containers/ubuntu/rootfs/proc"]
185
+ # 3. Step 2 (lazy) -> ["/mock/containers/ubuntu/rootfs/proc"]
186
+ # 4. Step 3 (post-kill standard retry) -> ["/mock/containers/ubuntu/rootfs/proc"]
187
+ # 5. Step 3 (post-kill lazy retry) -> []
188
+ # 6. Step 4 (forceful check) -> []
189
+ mock_mount.get_active_mounts.side_effect = [
190
+ ["/mock/containers/ubuntu/rootfs/proc"],
191
+ ["/mock/containers/ubuntu/rootfs/proc"],
192
+ ["/mock/containers/ubuntu/rootfs/proc"],
193
+ ["/mock/containers/ubuntu/rootfs/proc"],
194
+ ["/mock/containers/ubuntu/rootfs/proc"],
195
+ [],
196
+ [],
197
+ ]
198
+ mock_mount._resolve_umount.return_value = "/bin/umount"
199
+
200
+ mock_lock_instance = MagicMock()
201
+ mock_lock_instance.acquire.return_value = True
202
+ mock_lock.return_value = mock_lock_instance
203
+
204
+ mock_time.side_effect = [0, 0.1, 0.2, 0.3]
205
+
206
+ # Standard umounts fail, lazy umounts succeed
207
+ mock_run_res_fail = MagicMock(returncode=1)
208
+ mock_run_res_ok = MagicMock(returncode=0)
209
+ mock_run.side_effect = [
210
+ mock_run_res_fail, # Step 1 standard
211
+ mock_run_res_fail, # Step 2 lazy
212
+ mock_run_res_fail, # Step 3 post-kill standard
213
+ mock_run_res_ok, # Step 3 post-kill lazy
214
+ ]
215
+
216
+ command_kill(args)
217
+
218
+ mock_kill.assert_called_once_with(1000, signal.SIGTERM)
219
+ mock_session.reset.assert_called_once_with("ubuntu")
220
+ mock_log.assert_any_call("Container 'ubuntu' successfully killed and unmounted.")
221
+
222
+
223
+ @patch("chroot_distro.commands.kill.namespace.get_live_holder", return_value=None)
224
+ @patch("chroot_distro.commands.kill.container_rootfs", return_value="/mock/containers/ubuntu/rootfs")
225
+ @patch("os.path.isdir", return_value=True)
226
+ @patch("chroot_distro.commands.kill.ContainerLock")
227
+ @patch("chroot_distro.commands.kill.session")
228
+ @patch("chroot_distro.commands.kill.mount_manager")
229
+ @patch("chroot_distro.commands.kill.log_info")
230
+ @patch("subprocess.run")
231
+ @patch("chroot_distro.commands.kill.crit_error")
232
+ def test_kill_forceful_failure_diagnostic(
233
+ mock_crit_error, mock_run, mock_log, mock_mount, mock_session, mock_lock, mock_isdir, mock_rootfs, *_mocks
234
+ ):
235
+ """If forceful unmount also fails, we output detailed diagnostics."""
236
+ args = MagicMock()
237
+ args.container_name = "ubuntu"
238
+
239
+ mock_session.get_active_chroot_pids.return_value = []
240
+ # get_active_mounts always returns a mount
241
+ mock_mount.get_active_mounts.return_value = ["/mock/containers/ubuntu/rootfs/proc"]
242
+ mock_mount._resolve_umount.return_value = "/bin/umount"
243
+
244
+ mock_lock_instance = MagicMock()
245
+ mock_lock_instance.acquire.return_value = True
246
+ mock_lock.return_value = mock_lock_instance
247
+
248
+ # All umount commands fail
249
+ mock_run.return_value = MagicMock(returncode=1)
250
+
251
+ with pytest.raises(SystemExit) as exc_info:
252
+ command_kill(args)
253
+
254
+ assert exc_info.value.code == 1
255
+ mock_crit_error.assert_called_once_with(
256
+ "Failed to kill and unmount container 'ubuntu'.\n"
257
+ "Remaining active mounts:\n"
258
+ " - /mock/containers/ubuntu/rootfs/proc\n"
259
+ "Remaining active process PIDs: None"
260
+ )
261
+
262
+
263
+ @patch("chroot_distro.commands.kill.namespace.get_live_holder", return_value=None)
264
+ @patch("chroot_distro.commands.kill.container_rootfs", return_value="/mock/containers/ubuntu/rootfs")
265
+ @patch("os.path.isdir", return_value=True)
266
+ @patch("chroot_distro.commands.kill.ContainerLock")
267
+ @patch("chroot_distro.commands.kill.session")
268
+ @patch("chroot_distro.commands.kill.mount_manager")
269
+ @patch("chroot_distro.commands.kill.log_info")
270
+ @patch("subprocess.run")
271
+ @patch("chroot_distro.commands.kill.os.kill")
272
+ @patch("chroot_distro.commands.kill.time.sleep")
273
+ @patch("chroot_distro.commands.kill.time.time")
274
+ def test_kill_lock_conflict_bypass(
275
+ mock_time,
276
+ mock_sleep,
277
+ mock_kill,
278
+ mock_run,
279
+ mock_log,
280
+ mock_mount,
281
+ mock_session,
282
+ mock_lock,
283
+ mock_isdir,
284
+ mock_rootfs,
285
+ *_mocks,
286
+ ):
287
+ """When container lock is busy initially, we bypass it, kill processes, and acquire it afterward."""
288
+ args = MagicMock()
289
+ args.container_name = "ubuntu"
290
+
291
+ mock_session.get_active_chroot_pids.side_effect = [[1000], [1000], []]
292
+ mock_mount.get_active_mounts.side_effect = [
293
+ ["/mock/containers/ubuntu/rootfs/proc"], # initial check
294
+ ["/mock/containers/ubuntu/rootfs/proc"], # Step 1 standard
295
+ ["/mock/containers/ubuntu/rootfs/proc"], # Step 2 lazy
296
+ ["/mock/containers/ubuntu/rootfs/proc"], # Step 3 post-kill standard
297
+ [], # Step 3 post-kill lazy
298
+ [], # Step 4 check
299
+ ]
300
+ mock_mount._resolve_umount.return_value = "/bin/umount"
301
+
302
+ mock_lock_instance = MagicMock()
303
+ # First acquire fails, second acquire (after processes killed) succeeds
304
+ mock_lock_instance.acquire.side_effect = [False, True]
305
+ mock_lock.return_value = mock_lock_instance
306
+
307
+ mock_time.side_effect = [0, 0.1, 0.2, 0.3]
308
+ mock_run.return_value = MagicMock(returncode=0)
309
+
310
+ command_kill(args)
311
+
312
+ # We logged a warning/info about lock conflict
313
+ mock_log.assert_any_call("Container 'ubuntu' is busy (active sessions exist). Forcing cleanup...")
314
+ # Two calls to acquire
315
+ assert mock_lock_instance.acquire.call_count == 2
316
+ # Lock released at the end
317
+ mock_lock_instance.release.assert_called_once()
@@ -330,7 +330,7 @@ wheels = [
330
330
 
331
331
  [[package]]
332
332
  name = "chroot-distro"
333
- version = "2.2.0"
333
+ version = "2.2.1"
334
334
  source = { editable = "." }
335
335
  dependencies = [
336
336
  { name = "backports-zstd", marker = "python_full_version < '3.14'" },
@@ -1,88 +0,0 @@
1
- import contextlib
2
- import os
3
- import signal
4
- import sys
5
- import time
6
-
7
- import chroot_distro.helpers.mount_manager as mount_manager
8
- import chroot_distro.helpers.namespace as namespace
9
- import chroot_distro.helpers.session as session
10
- from chroot_distro.locking import ContainerLock
11
- from chroot_distro.message import crit_error, log_info, warn
12
- from chroot_distro.names import require_valid_name
13
- from chroot_distro.paths import container_rootfs
14
-
15
- _SIGTERM_GRACE_SECS = 1.0
16
- _SIGKILL_WAIT_SECS = 2.0
17
-
18
-
19
- def _wait_until_gone(container_name: str, timeout: float) -> list[int]:
20
- """Poll for active chroot PIDs until none remain or *timeout* elapses."""
21
- deadline = time.time() + timeout
22
- while time.time() < deadline:
23
- remaining = session.get_active_chroot_pids(container_name)
24
- if not remaining:
25
- return []
26
- time.sleep(0.1)
27
- return session.get_active_chroot_pids(container_name)
28
-
29
-
30
- def command_kill(args) -> None:
31
- """Forcibly stop all processes in a container and tear it down.
32
-
33
- The abrupt counterpart to ``unmount``: send SIGTERM, then SIGKILL after a
34
- short grace period, then unmount and release the namespace holder.
35
- """
36
- container_name = args.container_name
37
- require_valid_name(container_name)
38
-
39
- rootfs_dir = container_rootfs(container_name)
40
- if not os.path.isdir(rootfs_dir):
41
- crit_error(f"container '{container_name}' is not installed.")
42
- sys.exit(1)
43
-
44
- with ContainerLock(container_name, exclusive=True, command="kill"):
45
- active_pids = session.get_active_chroot_pids(container_name)
46
- holder = namespace.get_live_holder(container_name)
47
-
48
- if not active_pids and holder is None and not mount_manager.get_active_mounts(rootfs_dir):
49
- log_info(f"Container '{container_name}' is not running.")
50
- return
51
-
52
- if active_pids:
53
- log_info(f"Killing {len(active_pids)} process(es) in container '{container_name}' (PIDs: {active_pids})...")
54
- for pid in active_pids:
55
- with contextlib.suppress(OSError):
56
- os.kill(pid, signal.SIGTERM)
57
-
58
- remaining = _wait_until_gone(container_name, _SIGTERM_GRACE_SECS)
59
- if remaining:
60
- log_info(f"Processes {remaining} did not exit; sending SIGKILL...")
61
- for pid in remaining:
62
- with contextlib.suppress(OSError):
63
- os.kill(pid, signal.SIGKILL)
64
- remaining = _wait_until_gone(container_name, _SIGKILL_WAIT_SECS)
65
- if remaining:
66
- warn(f"Some processes could not be killed: {remaining}")
67
-
68
- session.reset(container_name)
69
-
70
- log_info("Unmounting active mount points under rootfs...")
71
- try:
72
- mount_manager.unmount_all(rootfs_dir, holder=holder)
73
- except Exception as e:
74
- crit_error(f"Failed to unmount: {e}")
75
- sys.exit(1)
76
-
77
- if holder is not None:
78
- namespace.release_holder(container_name)
79
- namespace.clear_isolation_mode(container_name)
80
-
81
- remaining_mounts = mount_manager.get_active_mounts(rootfs_dir)
82
- if remaining_mounts:
83
- warn(f"Some active mounts remain: {remaining_mounts}")
84
- else:
85
- log_info(f"Container '{container_name}' killed and unmounted.")
86
-
87
-
88
- __all__ = ("command_kill",)
File without changes
File without changes
File without changes