runspec-linux-core 0.3.0__tar.gz → 0.3.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 (26) hide show
  1. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/CHANGELOG.md +13 -0
  2. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/pyproject.toml +1 -1
  4. runspec_linux_core-0.3.1/runspec_linux_core/_paths.py +33 -0
  5. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/power.py +11 -7
  6. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/sudoers.py +14 -10
  7. runspec_linux_core-0.3.1/tests/test_paths.py +24 -0
  8. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/tests/test_power.py +4 -4
  9. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/tests/test_preconditions.py +2 -2
  10. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/tests/test_sudoers.py +3 -2
  11. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/.gitignore +0 -0
  12. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/__init__.py +0 -0
  13. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/containers.py +0 -0
  14. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/errors.py +0 -0
  15. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/files.py +0 -0
  16. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/logs.py +0 -0
  17. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/nc.py +0 -0
  18. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/network.py +0 -0
  19. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/packages.py +0 -0
  20. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/security.py +0 -0
  21. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/services.py +0 -0
  22. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/runspec_linux_core/system.py +0 -0
  23. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/tests/__init__.py +0 -0
  24. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/tests/test_nc_send.py +0 -0
  25. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/tests/test_network.py +0 -0
  26. {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.1}/tests/test_packages.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.3.1] — 2026-06-20
4
+
5
+ Fix `enable_passwordless_sudo` failing with "visudo not installed" (and the same
6
+ latent bug in `reboot_host`'s `shutdown` lookup). The console runs runnables over
7
+ a non-login, non-interactive SSH shell whose `PATH` usually omits `/usr/sbin` and
8
+ `/sbin`, so `shutil.which()` couldn't find these sbin-resident admin tools even
9
+ when installed.
10
+
11
+ New `_paths.which()` searches `PATH` first, then the standard sbin/bin dirs
12
+ (`/usr/local/sbin`, `/usr/sbin`, `/sbin`, `/usr/local/bin`, `/usr/bin`, `/bin`),
13
+ and returns an absolute path. `sudoers.py` and `power.py` resolve and invoke
14
+ `visudo` / `shutdown` / `systemctl` through it, so they work regardless of `PATH`.
15
+
3
16
  ## [0.3.0] — 2026-06-20
4
17
 
5
18
  Add `enable_passwordless_sudo()` (new `sudoers.py`) — the one-time bootstrap that
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux-core
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux-core"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  requires-python = ">=3.10"
9
9
  description = "Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)"
10
10
  dependencies = []
@@ -0,0 +1,33 @@
1
+ """Resolve system tools that may live in sbin dirs not on a non-login PATH.
2
+
3
+ The console runs runnables over a non-login, non-interactive SSH shell whose
4
+ ``PATH`` often omits ``/usr/sbin`` and ``/sbin`` — so ``shutil.which()`` can't
5
+ find admin tools like ``visudo`` and ``shutdown`` even when they are installed.
6
+ ``which()`` searches ``PATH`` first, then the standard sbin/bin locations, and
7
+ returns an absolute path so the caller can invoke the tool regardless of PATH.
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+
13
+ # Searched in order after PATH — the standard admin-tool locations.
14
+ _FALLBACK_DIRS = (
15
+ "/usr/local/sbin",
16
+ "/usr/sbin",
17
+ "/sbin",
18
+ "/usr/local/bin",
19
+ "/usr/bin",
20
+ "/bin",
21
+ )
22
+
23
+
24
+ def which(name: str) -> str | None:
25
+ """Return the absolute path to ``name`` (PATH first, then sbin/bin), or None."""
26
+ found = shutil.which(name)
27
+ if found:
28
+ return found
29
+ for directory in _FALLBACK_DIRS:
30
+ candidate = os.path.join(directory, name)
31
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
32
+ return candidate
33
+ return None
@@ -1,8 +1,8 @@
1
1
  """Host power helpers: reboot."""
2
2
 
3
- import shutil
4
3
  import subprocess
5
4
 
5
+ from runspec_linux_core._paths import which
6
6
  from runspec_linux_core.errors import CommandError, ToolNotFoundError
7
7
 
8
8
 
@@ -14,22 +14,26 @@ def reboot_host(delay_minutes: int = 0) -> dict:
14
14
  ``shutdown``, falling back to ``systemctl reboot`` for an immediate reboot if
15
15
  ``shutdown`` is absent. Returns ``{scheduled: True, delay_minutes, command}``.
16
16
 
17
- Raises ToolNotFoundError if no usable tool is present, or CommandError if the
17
+ ``shutdown`` lives in /usr/sbin, often absent from a non-login SSH PATH, so
18
+ it is resolved to an absolute path (see ``_paths.which``). Raises
19
+ ToolNotFoundError if no usable tool is present, or CommandError if the
18
20
  command exits non-zero (for example when run as an unprivileged user).
19
21
  """
20
- if shutil.which("shutdown"):
22
+ shutdown = which("shutdown")
23
+ if shutdown:
21
24
  when = "now" if delay_minutes <= 0 else f"+{delay_minutes}"
22
- cmd = ["shutdown", "-r", when]
25
+ cmd = [shutdown, "-r", when]
23
26
  result = subprocess.run(cmd, capture_output=True, text=True)
24
27
  if result.returncode != 0:
25
28
  raise CommandError(result.stderr.strip() or "shutdown failed")
26
29
  return {"scheduled": True, "delay_minutes": max(delay_minutes, 0), "command": " ".join(cmd)}
27
30
 
28
31
  # No `shutdown` (unusual). systemctl can only do an immediate reboot.
29
- if shutil.which("systemctl") and delay_minutes <= 0:
30
- result = subprocess.run(["systemctl", "reboot"], capture_output=True, text=True)
32
+ systemctl = which("systemctl")
33
+ if systemctl and delay_minutes <= 0:
34
+ result = subprocess.run([systemctl, "reboot"], capture_output=True, text=True)
31
35
  if result.returncode != 0:
32
36
  raise CommandError(result.stderr.strip() or "systemctl reboot failed")
33
- return {"scheduled": True, "delay_minutes": 0, "command": "systemctl reboot"}
37
+ return {"scheduled": True, "delay_minutes": 0, "command": f"{systemctl} reboot"}
34
38
 
35
39
  raise ToolNotFoundError("shutdown not available (and systemctl cannot schedule a delayed reboot)")
@@ -24,11 +24,11 @@ Two grant scopes:
24
24
  import getpass
25
25
  import os
26
26
  import re
27
- import shutil
28
27
  import subprocess
29
28
  import sys
30
29
  import tempfile
31
30
 
31
+ from runspec_linux_core._paths import which
32
32
  from runspec_linux_core.errors import CommandError, ToolNotFoundError
33
33
 
34
34
  _DROP_IN = "/etc/sudoers.d/runspec"
@@ -56,10 +56,10 @@ def _sudoers_content(user: str, scope: str, bin_dir: str) -> str:
56
56
  return f"{_MANAGED_HEADER}{rule}\n"
57
57
 
58
58
 
59
- def _sudo(password: str, argv: list[str]) -> subprocess.CompletedProcess:
59
+ def _sudo(sudo_path: str, password: str, argv: list[str]) -> subprocess.CompletedProcess:
60
60
  """Run ``sudo -S -p '' argv``, feeding ``password`` on stdin (never on argv)."""
61
61
  return subprocess.run(
62
- ["sudo", "-S", "-p", "", *argv],
62
+ [sudo_path, "-S", "-p", "", *argv],
63
63
  input=password + "\n",
64
64
  capture_output=True,
65
65
  text=True,
@@ -90,9 +90,13 @@ def enable_passwordless_sudo(password: str, user: str | None = None, scope: str
90
90
  user = user or getpass.getuser()
91
91
  if not _USER_RE.match(user):
92
92
  raise ValueError(f"invalid user name: {user!r}")
93
- if not shutil.which("sudo"):
93
+ # visudo lives in /usr/sbin, which is often absent from a non-login SSH PATH;
94
+ # resolve to absolute paths so the lookups (and invocations) work anyway.
95
+ sudo_path = which("sudo")
96
+ if not sudo_path:
94
97
  raise ToolNotFoundError("sudo is not installed")
95
- if not shutil.which("visudo"):
98
+ visudo_path = which("visudo")
99
+ if not visudo_path:
96
100
  raise ToolNotFoundError("visudo is not installed (needed to validate the sudoers file)")
97
101
 
98
102
  content = _sudoers_content(user, scope, _runspec_bin_dir())
@@ -102,25 +106,25 @@ def enable_passwordless_sudo(password: str, user: str | None = None, scope: str
102
106
  tmp_path = tf.name
103
107
  try:
104
108
  # 1) Validate the generated syntax locally before touching the system.
105
- check = subprocess.run(["visudo", "-cf", tmp_path], capture_output=True, text=True)
109
+ check = subprocess.run([visudo_path, "-cf", tmp_path], capture_output=True, text=True)
106
110
  if check.returncode != 0:
107
111
  detail = check.stdout.strip() or check.stderr.strip()
108
112
  raise CommandError(f"generated sudoers failed validation: {detail}")
109
113
 
110
114
  # 2) Idempotent: identical drop-in already present? (reading it needs root)
111
- existing = _sudo(password, ["cat", _DROP_IN])
115
+ existing = _sudo(sudo_path, password, ["cat", _DROP_IN])
112
116
  if existing.returncode == 0 and existing.stdout == content:
113
117
  return {"user": user, "scope": scope, "path": _DROP_IN, "installed": True, "already_configured": True}
114
118
 
115
119
  # 3) Install atomically as root, 0440 root:root.
116
- install = _sudo(password, ["install", "-m", "0440", "-o", "root", "-g", "root", tmp_path, _DROP_IN])
120
+ install = _sudo(sudo_path, password, ["install", "-m", "0440", "-o", "root", "-g", "root", tmp_path, _DROP_IN])
117
121
  if install.returncode != 0:
118
122
  raise CommandError(_auth_hint(install))
119
123
 
120
124
  # 4) Re-validate the whole sudoers tree; roll our drop-in back if broken.
121
- verify = _sudo(password, ["visudo", "-c"])
125
+ verify = _sudo(sudo_path, password, [visudo_path, "-c"])
122
126
  if verify.returncode != 0:
123
- _sudo(password, ["rm", "-f", _DROP_IN])
127
+ _sudo(sudo_path, password, ["rm", "-f", _DROP_IN])
124
128
  detail = verify.stdout.strip() or verify.stderr.strip()
125
129
  raise CommandError(f"sudoers validation failed after install — rolled back: {detail}")
126
130
 
@@ -0,0 +1,24 @@
1
+ """which() falls back to sbin/bin dirs when a tool isn't on PATH."""
2
+
3
+ import pytest
4
+
5
+ from runspec_linux_core._paths import which
6
+
7
+
8
+ def test_which_prefers_path(monkeypatch: pytest.MonkeyPatch) -> None:
9
+ monkeypatch.setattr("runspec_linux_core._paths.shutil.which", lambda n: "/usr/bin/" + n)
10
+ assert which("sudo") == "/usr/bin/sudo"
11
+
12
+
13
+ def test_which_falls_back_to_sbin(monkeypatch: pytest.MonkeyPatch) -> None:
14
+ # Not on PATH (the non-login SSH case), but present in /usr/sbin.
15
+ monkeypatch.setattr("runspec_linux_core._paths.shutil.which", lambda n: None)
16
+ monkeypatch.setattr("runspec_linux_core._paths.os.path.isfile", lambda p: p == "/usr/sbin/visudo")
17
+ monkeypatch.setattr("runspec_linux_core._paths.os.access", lambda p, mode: True)
18
+ assert which("visudo") == "/usr/sbin/visudo"
19
+
20
+
21
+ def test_which_none_when_absent(monkeypatch: pytest.MonkeyPatch) -> None:
22
+ monkeypatch.setattr("runspec_linux_core._paths.shutil.which", lambda n: None)
23
+ monkeypatch.setattr("runspec_linux_core._paths.os.path.isfile", lambda p: False)
24
+ assert which("nope") is None
@@ -14,7 +14,7 @@ def _completed(returncode: int = 0, stderr: str = "") -> subprocess.CompletedPro
14
14
 
15
15
  def test_reboot_now_uses_shutdown(monkeypatch: pytest.MonkeyPatch) -> None:
16
16
  seen: list[list[str]] = []
17
- monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "shutdown")
17
+ monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "shutdown" else None)
18
18
  monkeypatch.setattr(
19
19
  "runspec_linux_core.power.subprocess.run",
20
20
  lambda cmd, **kw: seen.append(cmd) or _completed(),
@@ -26,7 +26,7 @@ def test_reboot_now_uses_shutdown(monkeypatch: pytest.MonkeyPatch) -> None:
26
26
 
27
27
  def test_reboot_with_delay_schedules(monkeypatch: pytest.MonkeyPatch) -> None:
28
28
  seen: list[list[str]] = []
29
- monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "shutdown")
29
+ monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "shutdown" else None)
30
30
  monkeypatch.setattr(
31
31
  "runspec_linux_core.power.subprocess.run",
32
32
  lambda cmd, **kw: seen.append(cmd) or _completed(),
@@ -38,7 +38,7 @@ def test_reboot_with_delay_schedules(monkeypatch: pytest.MonkeyPatch) -> None:
38
38
 
39
39
  def test_reboot_falls_back_to_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
40
40
  seen: list[list[str]] = []
41
- monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "systemctl")
41
+ monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "systemctl" else None)
42
42
  monkeypatch.setattr(
43
43
  "runspec_linux_core.power.subprocess.run",
44
44
  lambda cmd, **kw: seen.append(cmd) or _completed(),
@@ -49,7 +49,7 @@ def test_reboot_falls_back_to_systemctl(monkeypatch: pytest.MonkeyPatch) -> None
49
49
 
50
50
 
51
51
  def test_reboot_non_zero_raises(monkeypatch: pytest.MonkeyPatch) -> None:
52
- monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "shutdown")
52
+ monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "shutdown" else None)
53
53
  monkeypatch.setattr(
54
54
  "runspec_linux_core.power.subprocess.run",
55
55
  lambda cmd, **kw: _completed(returncode=1, stderr="Operation not permitted"),
@@ -101,12 +101,12 @@ def test_remove_package_requires_a_manager(monkeypatch: pytest.MonkeyPatch) -> N
101
101
 
102
102
 
103
103
  def test_reboot_host_requires_a_tool(monkeypatch: pytest.MonkeyPatch) -> None:
104
- monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda _: None)
104
+ monkeypatch.setattr("runspec_linux_core.power.which", lambda _: None)
105
105
  with pytest.raises(ToolNotFoundError):
106
106
  core.reboot_host()
107
107
 
108
108
 
109
109
  def test_enable_passwordless_sudo_requires_sudo(monkeypatch: pytest.MonkeyPatch) -> None:
110
- monkeypatch.setattr("runspec_linux_core.sudoers.shutil.which", lambda _: None)
110
+ monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda _: None)
111
111
  with pytest.raises(ToolNotFoundError):
112
112
  core.enable_passwordless_sudo("pw", "alice", "scoped")
@@ -19,7 +19,8 @@ def _ok(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.C
19
19
 
20
20
  @pytest.fixture(autouse=True)
21
21
  def _tools_present(monkeypatch: pytest.MonkeyPatch) -> None:
22
- monkeypatch.setattr("runspec_linux_core.sudoers.shutil.which", lambda name: f"/usr/sbin/{name}")
22
+ # Resolver returns the bare name so the stubbed commands read cleanly.
23
+ monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda name: name)
23
24
  monkeypatch.setattr("runspec_linux_core.sudoers._runspec_bin_dir", lambda: "/opt/rs/bin")
24
25
 
25
26
 
@@ -142,6 +143,6 @@ def test_post_validate_failure_rolls_back(monkeypatch: pytest.MonkeyPatch) -> No
142
143
 
143
144
 
144
145
  def test_requires_visudo(monkeypatch: pytest.MonkeyPatch) -> None:
145
- monkeypatch.setattr("runspec_linux_core.sudoers.shutil.which", lambda name: None if name == "visudo" else "/usr/bin/sudo")
146
+ monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda name: None if name == "visudo" else "/usr/bin/sudo")
146
147
  with pytest.raises(ToolNotFoundError, match="visudo"):
147
148
  sudoers.enable_passwordless_sudo("pw", "alice", "scoped")