runspec-linux-core 0.2.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.
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/CHANGELOG.md +33 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/PKG-INFO +1 -1
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/pyproject.toml +1 -1
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/__init__.py +3 -0
- runspec_linux_core-0.3.1/runspec_linux_core/_paths.py +33 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/power.py +11 -7
- runspec_linux_core-0.3.1/runspec_linux_core/sudoers.py +133 -0
- runspec_linux_core-0.3.1/tests/test_paths.py +24 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/tests/test_power.py +4 -4
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/tests/test_preconditions.py +7 -1
- runspec_linux_core-0.3.1/tests/test_sudoers.py +148 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/.gitignore +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/containers.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/files.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/logs.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/network.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/services.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/runspec_linux_core/system.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/tests/__init__.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/tests/test_network.py +0 -0
- {runspec_linux_core-0.2.0 → runspec_linux_core-0.3.1}/tests/test_packages.py +0 -0
|
@@ -1,5 +1,38 @@
|
|
|
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
|
+
|
|
16
|
+
## [0.3.0] — 2026-06-20
|
|
17
|
+
|
|
18
|
+
Add `enable_passwordless_sudo()` (new `sudoers.py`) — the one-time bootstrap that
|
|
19
|
+
makes the `run_as = "root"` package/power runnables work without a password.
|
|
20
|
+
|
|
21
|
+
- Installs a validated drop-in at `/etc/sudoers.d/runspec`. Two scopes:
|
|
22
|
+
`"scoped"` → `NOPASSWD` for the runspec venv's runnable scripts only
|
|
23
|
+
(`{venv_bin}/`); `"all"` → `NOPASSWD: ALL`.
|
|
24
|
+
- The sudo password is read from **stdin via `sudo -S`** — never on argv, in the
|
|
25
|
+
process table, shell history, or the audit log.
|
|
26
|
+
- Validates with `visudo -cf` before install *and* `visudo -c` after; rolls the
|
|
27
|
+
drop-in back if the post-install check fails, so a typo can't lock sudo out.
|
|
28
|
+
- The target user name is validated against a strict pattern before it reaches
|
|
29
|
+
the file (no rule injection); idempotent when an identical drop-in exists.
|
|
30
|
+
- Raises `ValueError` (bad scope/user), `ToolNotFoundError` (no `sudo`/`visudo`),
|
|
31
|
+
or `CommandError` (auth failure / validation failure).
|
|
32
|
+
|
|
33
|
+
Note: the console's *agent* path wraps the command in `env(1)`, so a `"scoped"`
|
|
34
|
+
grant covers manual/CLI runs; agent-driven runs need `"all"`.
|
|
35
|
+
|
|
3
36
|
## [0.2.0] — 2026-06-20
|
|
4
37
|
|
|
5
38
|
Add package-manager and host-power helpers so a fleet can be patched and
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-linux-core"
|
|
7
|
-
version = "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 = []
|
|
@@ -30,6 +30,7 @@ from runspec_linux_core.packages import (
|
|
|
30
30
|
from runspec_linux_core.power import reboot_host
|
|
31
31
|
from runspec_linux_core.security import last_logins, who
|
|
32
32
|
from runspec_linux_core.services import check_service, list_services, restart_service
|
|
33
|
+
from runspec_linux_core.sudoers import enable_passwordless_sudo
|
|
33
34
|
from runspec_linux_core.system import check_memory, disk_usage, list_processes, system_info
|
|
34
35
|
|
|
35
36
|
__all__ = [
|
|
@@ -73,6 +74,8 @@ __all__ = [
|
|
|
73
74
|
"remove_package",
|
|
74
75
|
# power
|
|
75
76
|
"reboot_host",
|
|
77
|
+
# sudoers
|
|
78
|
+
"enable_passwordless_sudo",
|
|
76
79
|
# tcp
|
|
77
80
|
"nc_send",
|
|
78
81
|
]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
+
shutdown = which("shutdown")
|
|
23
|
+
if shutdown:
|
|
21
24
|
when = "now" if delay_minutes <= 0 else f"+{delay_minutes}"
|
|
22
|
-
cmd = [
|
|
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
|
-
|
|
30
|
-
|
|
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)")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Bootstrap passwordless sudo so the console's ``run_as = "root"`` path works.
|
|
2
|
+
|
|
3
|
+
This is the one-time, interactive complement to ``run_as``: the operator supplies
|
|
4
|
+
their sudo password (delivered out of band by the caller — never on argv) and we
|
|
5
|
+
install a **validated** drop-in under ``/etc/sudoers.d`` so subsequent
|
|
6
|
+
``run_as = "root"`` invocations escalate without a password.
|
|
7
|
+
|
|
8
|
+
The password is read from stdin via ``sudo -S`` — it never appears in argv, the
|
|
9
|
+
process table, shell history, or the audit log. The drop-in is validated with
|
|
10
|
+
``visudo`` before *and* after install, and rolled back if the post-install check
|
|
11
|
+
fails, so a typo can't lock the operator out of sudo.
|
|
12
|
+
|
|
13
|
+
Two grant scopes:
|
|
14
|
+
|
|
15
|
+
- ``"scoped"`` — ``NOPASSWD`` only for the runspec venv's runnable scripts
|
|
16
|
+
(``{venv_bin}/``). Least privilege, and enough for **manual** console runs and
|
|
17
|
+
direct CLI/cron. Note: the console's *agent* path wraps the command in
|
|
18
|
+
``env(1)`` to carry ``RUNSPEC_AGENT`` through sudo, so under the agent the
|
|
19
|
+
command sudo sees is ``/usr/bin/env …`` — which this scope does **not** match.
|
|
20
|
+
Agent-driven fleet management therefore needs ``"all"``.
|
|
21
|
+
- ``"all"`` — ``NOPASSWD: ALL``. Works for every path (manual *and* agent).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import getpass
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
|
|
31
|
+
from runspec_linux_core._paths import which
|
|
32
|
+
from runspec_linux_core.errors import CommandError, ToolNotFoundError
|
|
33
|
+
|
|
34
|
+
_DROP_IN = "/etc/sudoers.d/runspec"
|
|
35
|
+
_MANAGED_HEADER = "# Managed by runspec-linux enable-passwordless-sudo\n"
|
|
36
|
+
|
|
37
|
+
# POSIX-ish user/group name: starts with a letter or underscore, then letters,
|
|
38
|
+
# digits, _, -, . and an optional trailing $ (machine accounts). Validated before
|
|
39
|
+
# interpolation into the sudoers file so a crafted name can't inject extra rules.
|
|
40
|
+
_USER_RE = re.compile(r"^[a-z_][a-z0-9_.-]*\$?$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _runspec_bin_dir() -> str:
|
|
44
|
+
"""The venv bin directory holding the runspec runnable scripts.
|
|
45
|
+
|
|
46
|
+
The console invokes a runnable as ``{bin_dir}/{runnable}``; on a standard
|
|
47
|
+
venv install that is the directory of the running interpreter (kept as the
|
|
48
|
+
venv path, not resolved through symlinks).
|
|
49
|
+
"""
|
|
50
|
+
return os.path.dirname(sys.executable)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sudoers_content(user: str, scope: str, bin_dir: str) -> str:
|
|
54
|
+
# scoped: a trailing slash matches any command in the runspec venv bin dir.
|
|
55
|
+
rule = f"{user} ALL=(ALL) NOPASSWD: ALL" if scope == "all" else f"{user} ALL=(root) NOPASSWD: {bin_dir}/"
|
|
56
|
+
return f"{_MANAGED_HEADER}{rule}\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sudo(sudo_path: str, password: str, argv: list[str]) -> subprocess.CompletedProcess:
|
|
60
|
+
"""Run ``sudo -S -p '' argv``, feeding ``password`` on stdin (never on argv)."""
|
|
61
|
+
return subprocess.run(
|
|
62
|
+
[sudo_path, "-S", "-p", "", *argv],
|
|
63
|
+
input=password + "\n",
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _auth_hint(result: subprocess.CompletedProcess) -> str:
|
|
70
|
+
err = result.stderr.strip()
|
|
71
|
+
low = err.lower()
|
|
72
|
+
if "incorrect password" in low or "try again" in low or "authentication failure" in low:
|
|
73
|
+
return "sudo authentication failed — check the password"
|
|
74
|
+
return err or "sudo command failed"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def enable_passwordless_sudo(password: str, user: str | None = None, scope: str = "scoped") -> dict:
|
|
78
|
+
"""Install a validated ``/etc/sudoers.d/runspec`` granting passwordless sudo.
|
|
79
|
+
|
|
80
|
+
``user`` defaults to the current login user. ``scope`` is ``"scoped"`` (the
|
|
81
|
+
runspec venv runnables only) or ``"all"`` (``NOPASSWD: ALL``). Returns
|
|
82
|
+
``{user, scope, path, installed: True, already_configured}``.
|
|
83
|
+
|
|
84
|
+
Raises ValueError on a bad scope/user, ToolNotFoundError if sudo/visudo are
|
|
85
|
+
missing, or CommandError on a sudo auth failure or a sudoers validation
|
|
86
|
+
failure (in which case the drop-in is rolled back).
|
|
87
|
+
"""
|
|
88
|
+
if scope not in ("scoped", "all"):
|
|
89
|
+
raise ValueError(f"invalid scope: {scope!r} (expected 'scoped' or 'all')")
|
|
90
|
+
user = user or getpass.getuser()
|
|
91
|
+
if not _USER_RE.match(user):
|
|
92
|
+
raise ValueError(f"invalid user name: {user!r}")
|
|
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:
|
|
97
|
+
raise ToolNotFoundError("sudo is not installed")
|
|
98
|
+
visudo_path = which("visudo")
|
|
99
|
+
if not visudo_path:
|
|
100
|
+
raise ToolNotFoundError("visudo is not installed (needed to validate the sudoers file)")
|
|
101
|
+
|
|
102
|
+
content = _sudoers_content(user, scope, _runspec_bin_dir())
|
|
103
|
+
|
|
104
|
+
with tempfile.NamedTemporaryFile("w", suffix=".sudoers", delete=False) as tf:
|
|
105
|
+
tf.write(content)
|
|
106
|
+
tmp_path = tf.name
|
|
107
|
+
try:
|
|
108
|
+
# 1) Validate the generated syntax locally before touching the system.
|
|
109
|
+
check = subprocess.run([visudo_path, "-cf", tmp_path], capture_output=True, text=True)
|
|
110
|
+
if check.returncode != 0:
|
|
111
|
+
detail = check.stdout.strip() or check.stderr.strip()
|
|
112
|
+
raise CommandError(f"generated sudoers failed validation: {detail}")
|
|
113
|
+
|
|
114
|
+
# 2) Idempotent: identical drop-in already present? (reading it needs root)
|
|
115
|
+
existing = _sudo(sudo_path, password, ["cat", _DROP_IN])
|
|
116
|
+
if existing.returncode == 0 and existing.stdout == content:
|
|
117
|
+
return {"user": user, "scope": scope, "path": _DROP_IN, "installed": True, "already_configured": True}
|
|
118
|
+
|
|
119
|
+
# 3) Install atomically as root, 0440 root:root.
|
|
120
|
+
install = _sudo(sudo_path, password, ["install", "-m", "0440", "-o", "root", "-g", "root", tmp_path, _DROP_IN])
|
|
121
|
+
if install.returncode != 0:
|
|
122
|
+
raise CommandError(_auth_hint(install))
|
|
123
|
+
|
|
124
|
+
# 4) Re-validate the whole sudoers tree; roll our drop-in back if broken.
|
|
125
|
+
verify = _sudo(sudo_path, password, [visudo_path, "-c"])
|
|
126
|
+
if verify.returncode != 0:
|
|
127
|
+
_sudo(sudo_path, password, ["rm", "-f", _DROP_IN])
|
|
128
|
+
detail = verify.stdout.strip() or verify.stderr.strip()
|
|
129
|
+
raise CommandError(f"sudoers validation failed after install — rolled back: {detail}")
|
|
130
|
+
|
|
131
|
+
return {"user": user, "scope": scope, "path": _DROP_IN, "installed": True, "already_configured": False}
|
|
132
|
+
finally:
|
|
133
|
+
os.unlink(tmp_path)
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,6 +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.
|
|
104
|
+
monkeypatch.setattr("runspec_linux_core.power.which", lambda _: None)
|
|
105
105
|
with pytest.raises(ToolNotFoundError):
|
|
106
106
|
core.reboot_host()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_enable_passwordless_sudo_requires_sudo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
110
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda _: None)
|
|
111
|
+
with pytest.raises(ToolNotFoundError):
|
|
112
|
+
core.enable_passwordless_sudo("pw", "alice", "scoped")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""enable_passwordless_sudo: content generation, validation, install + rollback.
|
|
2
|
+
|
|
3
|
+
No real sudo/visudo is invoked — the subprocess layer is stubbed so the control
|
|
4
|
+
flow (pre-validate, idempotency, install, post-validate, rollback) is exercised
|
|
5
|
+
deterministically. The password is asserted to travel via stdin, never argv.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from runspec_linux_core import sudoers
|
|
13
|
+
from runspec_linux_core.errors import CommandError, ToolNotFoundError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ok(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess:
|
|
17
|
+
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def _tools_present(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
22
|
+
# Resolver returns the bare name so the stubbed commands read cleanly.
|
|
23
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda name: name)
|
|
24
|
+
monkeypatch.setattr("runspec_linux_core.sudoers._runspec_bin_dir", lambda: "/opt/rs/bin")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── content ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_scoped_content_targets_venv_bin() -> None:
|
|
31
|
+
out = sudoers._sudoers_content("alice", "scoped", "/opt/rs/bin")
|
|
32
|
+
assert "alice ALL=(root) NOPASSWD: /opt/rs/bin/\n" in out
|
|
33
|
+
assert out.startswith("# Managed by runspec-linux")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_all_content_is_nopasswd_all() -> None:
|
|
37
|
+
out = sudoers._sudoers_content("alice", "all", "/opt/rs/bin")
|
|
38
|
+
assert "alice ALL=(ALL) NOPASSWD: ALL\n" in out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── input validation ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_rejects_bad_scope() -> None:
|
|
45
|
+
with pytest.raises(ValueError):
|
|
46
|
+
sudoers.enable_passwordless_sudo("pw", "alice", "everything")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("bad", ["root\nbad ALL=(ALL) NOPASSWD: ALL", "al ice", "-x", "a;b"])
|
|
50
|
+
def test_rejects_injection_in_user(bad: str) -> None:
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
sudoers.enable_passwordless_sudo("pw", bad, "scoped")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── install flow ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_install_happy_path_pipes_password_via_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
59
|
+
calls: list[dict] = []
|
|
60
|
+
|
|
61
|
+
def fake_run(cmd, **kw):
|
|
62
|
+
calls.append({"cmd": cmd, "input": kw.get("input")})
|
|
63
|
+
if cmd[:2] == ["visudo", "-cf"]: # local pre-validate
|
|
64
|
+
return _ok()
|
|
65
|
+
if cmd[:4] == ["sudo", "-S", "-p", ""]:
|
|
66
|
+
sub = cmd[4:] # args after the `sudo -S -p ''` prefix
|
|
67
|
+
if sub[0] == "cat": # idempotency probe: not yet installed
|
|
68
|
+
return _ok(returncode=1, stderr="No such file")
|
|
69
|
+
if sub[0] == "install":
|
|
70
|
+
return _ok()
|
|
71
|
+
if sub[0] == "visudo": # post-validate
|
|
72
|
+
return _ok()
|
|
73
|
+
raise AssertionError(f"unexpected command: {cmd}")
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
|
|
76
|
+
|
|
77
|
+
result = sudoers.enable_passwordless_sudo("s3cret", "alice", "all")
|
|
78
|
+
assert result == {
|
|
79
|
+
"user": "alice",
|
|
80
|
+
"scope": "all",
|
|
81
|
+
"path": "/etc/sudoers.d/runspec",
|
|
82
|
+
"installed": True,
|
|
83
|
+
"already_configured": False,
|
|
84
|
+
}
|
|
85
|
+
# Password rides stdin on every sudo call — never appears in any argv.
|
|
86
|
+
sudo_calls = [c for c in calls if c["cmd"][:1] == ["sudo"]]
|
|
87
|
+
assert sudo_calls and all(c["input"] == "s3cret\n" for c in sudo_calls)
|
|
88
|
+
assert not any("s3cret" in " ".join(c["cmd"]) for c in calls)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_idempotent_when_identical_drop_in_exists(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
92
|
+
content = sudoers._sudoers_content("alice", "all", "/opt/rs/bin")
|
|
93
|
+
|
|
94
|
+
def fake_run(cmd, **kw):
|
|
95
|
+
if cmd[:2] == ["visudo", "-cf"]:
|
|
96
|
+
return _ok()
|
|
97
|
+
if cmd[4:5] == ["cat"]:
|
|
98
|
+
return _ok(stdout=content) # already present, identical
|
|
99
|
+
raise AssertionError(f"should not install: {cmd}")
|
|
100
|
+
|
|
101
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
|
|
102
|
+
result = sudoers.enable_passwordless_sudo("pw", "alice", "all")
|
|
103
|
+
assert result["already_configured"] is True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_wrong_password_raises_command_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
107
|
+
def fake_run(cmd, **kw):
|
|
108
|
+
if cmd[:2] == ["visudo", "-cf"]:
|
|
109
|
+
return _ok()
|
|
110
|
+
if cmd[4:5] == ["cat"]:
|
|
111
|
+
return _ok(returncode=1)
|
|
112
|
+
if cmd[4:5] == ["install"]:
|
|
113
|
+
return _ok(returncode=1, stderr="sudo: 1 incorrect password attempt")
|
|
114
|
+
raise AssertionError(f"unexpected: {cmd}")
|
|
115
|
+
|
|
116
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
|
|
117
|
+
with pytest.raises(CommandError, match="authentication failed"):
|
|
118
|
+
sudoers.enable_passwordless_sudo("wrong", "alice", "all")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_post_validate_failure_rolls_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
122
|
+
removed: list[list[str]] = []
|
|
123
|
+
|
|
124
|
+
def fake_run(cmd, **kw):
|
|
125
|
+
if cmd[:2] == ["visudo", "-cf"]:
|
|
126
|
+
return _ok()
|
|
127
|
+
sub = cmd[4:] if cmd[:4] == ["sudo", "-S", "-p", ""] else []
|
|
128
|
+
if sub[:1] == ["cat"]:
|
|
129
|
+
return _ok(returncode=1)
|
|
130
|
+
if sub[:1] == ["install"]:
|
|
131
|
+
return _ok()
|
|
132
|
+
if sub[:1] == ["visudo"]: # whole-tree check fails
|
|
133
|
+
return _ok(returncode=1, stderr=">>> syntax error")
|
|
134
|
+
if sub[:1] == ["rm"]:
|
|
135
|
+
removed.append(sub)
|
|
136
|
+
return _ok()
|
|
137
|
+
raise AssertionError(f"unexpected: {cmd}")
|
|
138
|
+
|
|
139
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
|
|
140
|
+
with pytest.raises(CommandError, match="rolled back"):
|
|
141
|
+
sudoers.enable_passwordless_sudo("pw", "alice", "scoped")
|
|
142
|
+
assert removed and removed[0] == ["rm", "-f", "/etc/sudoers.d/runspec"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_requires_visudo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
146
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda name: None if name == "visudo" else "/usr/bin/sudo")
|
|
147
|
+
with pytest.raises(ToolNotFoundError, match="visudo"):
|
|
148
|
+
sudoers.enable_passwordless_sudo("pw", "alice", "scoped")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|