runspec-linux-core 0.1.0__tar.gz → 0.3.0__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 (25) hide show
  1. runspec_linux_core-0.3.0/CHANGELOG.md +80 -0
  2. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/__init__.py +21 -0
  5. runspec_linux_core-0.3.0/runspec_linux_core/packages.py +289 -0
  6. runspec_linux_core-0.3.0/runspec_linux_core/power.py +35 -0
  7. runspec_linux_core-0.3.0/runspec_linux_core/sudoers.py +129 -0
  8. runspec_linux_core-0.3.0/tests/test_packages.py +137 -0
  9. runspec_linux_core-0.3.0/tests/test_power.py +58 -0
  10. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/tests/test_preconditions.py +42 -0
  11. runspec_linux_core-0.3.0/tests/test_sudoers.py +147 -0
  12. runspec_linux_core-0.1.0/CHANGELOG.md +0 -28
  13. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/.gitignore +0 -0
  14. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/containers.py +0 -0
  15. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/errors.py +0 -0
  16. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/files.py +0 -0
  17. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/logs.py +0 -0
  18. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/nc.py +0 -0
  19. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/network.py +0 -0
  20. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/security.py +0 -0
  21. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/services.py +0 -0
  22. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/runspec_linux_core/system.py +0 -0
  23. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/tests/__init__.py +0 -0
  24. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/tests/test_nc_send.py +0 -0
  25. {runspec_linux_core-0.1.0 → runspec_linux_core-0.3.0}/tests/test_network.py +0 -0
@@ -0,0 +1,80 @@
1
+ # runspec-linux-core Changelog
2
+
3
+ ## [0.3.0] — 2026-06-20
4
+
5
+ Add `enable_passwordless_sudo()` (new `sudoers.py`) — the one-time bootstrap that
6
+ makes the `run_as = "root"` package/power runnables work without a password.
7
+
8
+ - Installs a validated drop-in at `/etc/sudoers.d/runspec`. Two scopes:
9
+ `"scoped"` → `NOPASSWD` for the runspec venv's runnable scripts only
10
+ (`{venv_bin}/`); `"all"` → `NOPASSWD: ALL`.
11
+ - The sudo password is read from **stdin via `sudo -S`** — never on argv, in the
12
+ process table, shell history, or the audit log.
13
+ - Validates with `visudo -cf` before install *and* `visudo -c` after; rolls the
14
+ drop-in back if the post-install check fails, so a typo can't lock sudo out.
15
+ - The target user name is validated against a strict pattern before it reaches
16
+ the file (no rule injection); idempotent when an identical drop-in exists.
17
+ - Raises `ValueError` (bad scope/user), `ToolNotFoundError` (no `sudo`/`visudo`),
18
+ or `CommandError` (auth failure / validation failure).
19
+
20
+ Note: the console's *agent* path wraps the command in `env(1)`, so a `"scoped"`
21
+ grant covers manual/CLI runs; agent-driven runs need `"all"`.
22
+
23
+ ## [0.2.0] — 2026-06-20
24
+
25
+ Add package-manager and host-power helpers so a fleet can be patched and
26
+ rebooted through `runspec-linux`.
27
+
28
+ New in `packages.py` — detect the distro's manager (`apt-get`/`dnf`/`yum`/
29
+ `zypper`/`pacman`) and operate over it:
30
+
31
+ - `detect_manager()` — first supported manager on PATH (raises
32
+ `ToolNotFoundError` if none).
33
+ - `list_packages(name_filter=None, limit=100)` — installed packages
34
+ (name + version), filtered + sorted + capped; `total` is the pre-cap count.
35
+ - `list_upgrades(refresh=True)` — packages with an available upgrade
36
+ (name/current/candidate). `refresh` updates the index first (needs root);
37
+ `refresh=False` reads the cache as any user.
38
+ - `upgrade_packages()` — refresh the index and upgrade everything (needs root).
39
+ - `install_package(packages)` / `remove_package(packages)` — install/remove one
40
+ or more space-separated packages (needs root).
41
+
42
+ New in `power.py`:
43
+
44
+ - `reboot_host(delay_minutes=0)` — reboot now or schedule with `shutdown -r +N`,
45
+ falling back to `systemctl reboot` for an immediate reboot.
46
+
47
+ Each helper returns plain data and raises `ToolNotFoundError` (no manager/tool)
48
+ or `CommandError` (non-zero exit, e.g. run unprivileged), matching the existing
49
+ contract. Package names are validated against a conservative token shape before
50
+ reaching argv (rejects leading dashes and shell metacharacters), raising
51
+ `ValueError` on a bad name. The write commands run non-interactively
52
+ (`-y`/`--non-interactive`/`--noconfirm`, `DEBIAN_FRONTEND=noninteractive`) and
53
+ return a bounded tail of their output.
54
+
55
+ ## [0.1.0] — 2026-06-09
56
+
57
+ Initial release.
58
+
59
+ The pure-Python logic core extracted from `runspec-linux`. Provides the
60
+ system-admin helper functions as plain importable functions — **no dependency on
61
+ `runspec`, no `runspec.toml`, no console-script entry points** — so a package can
62
+ `from runspec_linux_core import nc_send` (and the rest) without surfacing any
63
+ runnables in the venv or in `runspec local` / `runspec serve` discovery.
64
+
65
+ Each function returns plain data and *raises* on failure (`ToolNotFoundError`
66
+ when a required system tool is absent, `CommandError` when a subprocess reports
67
+ a non-zero exit); the runnable wrappers in `runspec-linux` catch these and render
68
+ the JSON/exit behaviour.
69
+
70
+ Exports:
71
+
72
+ - **System** — `system_info`, `disk_usage`, `check_memory`, `list_processes`
73
+ - **Services** — `check_service`, `list_services`, `restart_service`
74
+ - **Logs** — `tail_log`, `search_log`, `journalctl`
75
+ - **Network** — `ping_host`, `check_port`, `show_connections`
76
+ - **Files** — `find_large_files`, `backup_files`
77
+ - **Security** — `last_logins`, `who`
78
+ - **Containers** — `list_containers`, `container_logs`, `restart_container`
79
+ - **TCP** — `nc_send`
80
+ - **Errors** — `LinuxCoreError`, `ToolNotFoundError`, `CommandError`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux-core
3
- Version: 0.1.0
3
+ Version: 0.3.0
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.1.0"
7
+ version = "0.3.0"
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 = []
@@ -19,8 +19,18 @@ from runspec_linux_core.files import backup_files, find_large_files
19
19
  from runspec_linux_core.logs import journalctl, search_log, tail_log
20
20
  from runspec_linux_core.nc import nc_send
21
21
  from runspec_linux_core.network import check_port, ping_host, show_connections
22
+ from runspec_linux_core.packages import (
23
+ detect_manager,
24
+ install_package,
25
+ list_packages,
26
+ list_upgrades,
27
+ remove_package,
28
+ upgrade_packages,
29
+ )
30
+ from runspec_linux_core.power import reboot_host
22
31
  from runspec_linux_core.security import last_logins, who
23
32
  from runspec_linux_core.services import check_service, list_services, restart_service
33
+ from runspec_linux_core.sudoers import enable_passwordless_sudo
24
34
  from runspec_linux_core.system import check_memory, disk_usage, list_processes, system_info
25
35
 
26
36
  __all__ = [
@@ -55,6 +65,17 @@ __all__ = [
55
65
  "list_containers",
56
66
  "container_logs",
57
67
  "restart_container",
68
+ # packages
69
+ "detect_manager",
70
+ "list_packages",
71
+ "list_upgrades",
72
+ "upgrade_packages",
73
+ "install_package",
74
+ "remove_package",
75
+ # power
76
+ "reboot_host",
77
+ # sudoers
78
+ "enable_passwordless_sudo",
58
79
  # tcp
59
80
  "nc_send",
60
81
  ]
@@ -0,0 +1,289 @@
1
+ """Package-manager helpers: detect the distro's manager, then list/upgrade/install/remove.
2
+
3
+ Supports the common Linux families: Debian/Ubuntu (``apt-get`` + ``dpkg``),
4
+ RHEL/Fedora (``dnf``/``yum`` + ``rpm``), SUSE (``zypper`` + ``rpm``), and Arch
5
+ (``pacman``). The read operations (list installed, list upgrades) return plain
6
+ data; the write operations (upgrade, install, remove) need root and raise
7
+ ``CommandError`` when the underlying command exits non-zero (for example when
8
+ run as an unprivileged user). ``ToolNotFoundError`` is raised when no supported
9
+ package manager is on PATH.
10
+ """
11
+
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+
17
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
18
+
19
+ # Ordered by preference; the first manager found on PATH wins.
20
+ _MANAGERS = ("apt-get", "dnf", "yum", "zypper", "pacman")
21
+
22
+ # The argv prefix for each manager's write operations. ``-y`` /
23
+ # ``--non-interactive`` / ``--noconfirm`` keep them unattended (these run over
24
+ # SSH with no TTY). Package names are appended after validation.
25
+ _UPGRADE_CMDS = {
26
+ "apt-get": ["apt-get", "-y", "upgrade"],
27
+ "dnf": ["dnf", "-y", "upgrade"],
28
+ "yum": ["yum", "-y", "update"],
29
+ "zypper": ["zypper", "--non-interactive", "update"],
30
+ "pacman": ["pacman", "-Syu", "--noconfirm"],
31
+ }
32
+ _INSTALL_CMDS = {
33
+ "apt-get": ["apt-get", "install", "-y"],
34
+ "dnf": ["dnf", "install", "-y"],
35
+ "yum": ["yum", "install", "-y"],
36
+ "zypper": ["zypper", "--non-interactive", "install"],
37
+ "pacman": ["pacman", "-S", "--noconfirm"],
38
+ }
39
+ _REMOVE_CMDS = {
40
+ "apt-get": ["apt-get", "remove", "-y"],
41
+ "dnf": ["dnf", "remove", "-y"],
42
+ "yum": ["yum", "remove", "-y"],
43
+ "zypper": ["zypper", "--non-interactive", "remove"],
44
+ "pacman": ["pacman", "-R", "--noconfirm"],
45
+ }
46
+
47
+ # A conservative package-token shape: starts alphanumeric, then the punctuation
48
+ # real package names use. Rejects whitespace, shell metacharacters, and leading
49
+ # dashes (which a manager would parse as an option) before anything reaches argv.
50
+ _PKG_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+:@-]*$")
51
+
52
+
53
+ def detect_manager() -> str:
54
+ """Return the name of the first supported package manager found on PATH.
55
+
56
+ Raises ToolNotFoundError if none of apt-get/dnf/yum/zypper/pacman are present.
57
+ """
58
+ for mgr in _MANAGERS:
59
+ if shutil.which(mgr):
60
+ return mgr
61
+ raise ToolNotFoundError("No supported package manager found (looked for: " + ", ".join(_MANAGERS) + ")")
62
+
63
+
64
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
65
+ # DEBIAN_FRONTEND keeps apt from trying to prompt over a non-interactive
66
+ # SSH channel; it is harmless for the other managers.
67
+ env = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
68
+ return subprocess.run(cmd, capture_output=True, text=True, env=env)
69
+
70
+
71
+ def _tail(output: str, limit: int = 40) -> list[str]:
72
+ """Return the last ``limit`` non-empty-trimmed lines so payloads stay bounded."""
73
+ lines = output.strip().splitlines()
74
+ return lines[-limit:]
75
+
76
+
77
+ def _validate_packages(packages: str) -> list[str]:
78
+ """Split a space-separated package string into validated tokens.
79
+
80
+ Raises ValueError if the string is empty or any token is not a plausible
81
+ package name (this blocks option injection and shell metacharacters before
82
+ the names reach argv).
83
+ """
84
+ names = packages.split()
85
+ if not names:
86
+ raise ValueError("no package name given")
87
+ for name in names:
88
+ if not _PKG_TOKEN.match(name):
89
+ raise ValueError(f"invalid package name: {name!r}")
90
+ return names
91
+
92
+
93
+ def list_packages(name_filter: str | None = None, limit: int = 100) -> dict:
94
+ """Return installed packages (name + version), optionally filtered by substring.
95
+
96
+ Read-only. Results are sorted by name and capped at ``limit``; ``total`` is
97
+ the count before the cap. Raises ToolNotFoundError if no package manager is
98
+ present, or CommandError if the query command fails.
99
+ """
100
+ mgr = detect_manager()
101
+ if mgr == "apt-get":
102
+ result = _run(["dpkg-query", "-W", "-f=${Package}\t${Version}\n"])
103
+ rows = _parse_two_col(result.stdout)
104
+ elif mgr == "pacman":
105
+ result = _run(["pacman", "-Q"])
106
+ rows = _parse_pacman_q(result.stdout)
107
+ else: # dnf / yum / zypper are all rpm-based
108
+ result = _run(["rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\n"])
109
+ rows = _parse_two_col(result.stdout)
110
+
111
+ if not rows and result.returncode != 0:
112
+ raise CommandError(result.stderr.strip() or "package query failed")
113
+
114
+ if name_filter:
115
+ needle = name_filter.lower()
116
+ rows = [r for r in rows if needle in r["name"].lower()]
117
+ rows.sort(key=lambda r: r["name"])
118
+ total = len(rows)
119
+ return {"manager": mgr, "total": total, "count": min(total, limit), "packages": rows[:limit]}
120
+
121
+
122
+ def list_upgrades(refresh: bool = True) -> dict:
123
+ """Return packages with an available upgrade (name, current, candidate).
124
+
125
+ When ``refresh`` is true the package index is updated first (this needs root
126
+ on most systems); pass ``refresh=False`` to read the cached index as any
127
+ user. Raises ToolNotFoundError if no package manager is present, or
128
+ CommandError if a refresh or query command fails.
129
+ """
130
+ mgr = detect_manager()
131
+ if mgr == "apt-get":
132
+ if refresh:
133
+ _check(_run(["apt-get", "update", "-q"]), "apt-get update")
134
+ # `apt list --upgradable` reads the cache without root (it warns about an
135
+ # unstable CLI on stderr, which we ignore).
136
+ result = _run(["apt", "list", "--upgradable"])
137
+ upgrades = _parse_apt_list(result.stdout)
138
+ elif mgr in ("dnf", "yum"):
139
+ cmd = [mgr, "check-update"]
140
+ if not refresh:
141
+ cmd.append("-C") # cache-only
142
+ result = _run(cmd)
143
+ # check-update exits 100 when updates exist, 0 when none — neither is an error.
144
+ if result.returncode not in (0, 100):
145
+ raise CommandError(result.stderr.strip() or f"{mgr} check-update failed")
146
+ upgrades = _parse_dnf_check(result.stdout)
147
+ elif mgr == "zypper":
148
+ if refresh:
149
+ _check(_run(["zypper", "--non-interactive", "refresh"]), "zypper refresh")
150
+ result = _run(["zypper", "--non-interactive", "list-updates"])
151
+ upgrades = _parse_zypper_updates(result.stdout)
152
+ else: # pacman
153
+ if refresh:
154
+ _check(_run(["pacman", "-Sy"]), "pacman -Sy")
155
+ # `pacman -Qu` exits 1 when there are simply no updates; that is not an error.
156
+ result = _run(["pacman", "-Qu"])
157
+ upgrades = _parse_pacman_qu(result.stdout)
158
+
159
+ return {"manager": mgr, "refreshed": refresh, "count": len(upgrades), "upgrades": upgrades}
160
+
161
+
162
+ def upgrade_packages() -> dict:
163
+ """Refresh the package index and upgrade all installed packages.
164
+
165
+ Needs root. Returns ``{manager, command, upgraded: True, output}`` where
166
+ ``output`` is a bounded tail of the command's stdout. Raises
167
+ ToolNotFoundError if no package manager is present, or CommandError if the
168
+ refresh or upgrade command fails.
169
+ """
170
+ mgr = detect_manager()
171
+ if mgr == "apt-get":
172
+ _check(_run(["apt-get", "update", "-q"]), "apt-get update")
173
+ cmd = _UPGRADE_CMDS[mgr]
174
+ result = _run(cmd)
175
+ if result.returncode != 0:
176
+ raise CommandError(result.stderr.strip() or " ".join(cmd) + " failed")
177
+ return {"manager": mgr, "command": " ".join(cmd), "upgraded": True, "output": _tail(result.stdout)}
178
+
179
+
180
+ def install_package(packages: str) -> dict:
181
+ """Install one or more packages (space-separated). Needs root.
182
+
183
+ Returns ``{manager, packages, installed: True, output}``. Raises ValueError
184
+ on an empty/invalid name, ToolNotFoundError if no package manager is present,
185
+ or CommandError if the install command fails.
186
+ """
187
+ names = _validate_packages(packages)
188
+ mgr = detect_manager()
189
+ cmd = [*_INSTALL_CMDS[mgr], *names]
190
+ result = _run(cmd)
191
+ if result.returncode != 0:
192
+ raise CommandError(result.stderr.strip() or " ".join(cmd) + " failed")
193
+ return {"manager": mgr, "packages": names, "installed": True, "output": _tail(result.stdout)}
194
+
195
+
196
+ def remove_package(packages: str) -> dict:
197
+ """Remove one or more packages (space-separated). Needs root.
198
+
199
+ Returns ``{manager, packages, removed: True, output}``. Raises ValueError on
200
+ an empty/invalid name, ToolNotFoundError if no package manager is present, or
201
+ CommandError if the remove command fails.
202
+ """
203
+ names = _validate_packages(packages)
204
+ mgr = detect_manager()
205
+ cmd = [*_REMOVE_CMDS[mgr], *names]
206
+ result = _run(cmd)
207
+ if result.returncode != 0:
208
+ raise CommandError(result.stderr.strip() or " ".join(cmd) + " failed")
209
+ return {"manager": mgr, "packages": names, "removed": True, "output": _tail(result.stdout)}
210
+
211
+
212
+ def _check(result: subprocess.CompletedProcess[str], label: str) -> None:
213
+ if result.returncode != 0:
214
+ raise CommandError(result.stderr.strip() or f"{label} failed")
215
+
216
+
217
+ def _parse_two_col(output: str) -> list[dict]:
218
+ """Parse tab-separated ``name<TAB>version`` lines (dpkg-query / rpm output)."""
219
+ rows = []
220
+ for line in output.strip().splitlines():
221
+ if "\t" not in line:
222
+ continue
223
+ name, version = line.split("\t", 1)
224
+ name = name.strip()
225
+ if name:
226
+ rows.append({"name": name, "version": version.strip()})
227
+ return rows
228
+
229
+
230
+ def _parse_pacman_q(output: str) -> list[dict]:
231
+ rows = []
232
+ for line in output.strip().splitlines():
233
+ parts = line.split(None, 1)
234
+ if parts:
235
+ rows.append({"name": parts[0], "version": parts[1] if len(parts) > 1 else ""})
236
+ return rows
237
+
238
+
239
+ def _parse_apt_list(output: str) -> list[dict]:
240
+ """Parse ``apt list --upgradable`` lines: ``name/repo candidate arch [upgradable from: current]``."""
241
+ rows = []
242
+ for line in output.splitlines():
243
+ if "/" not in line or "[upgradable from:" not in line:
244
+ continue
245
+ name = line.split("/", 1)[0].strip()
246
+ parts = line.split()
247
+ candidate = parts[1] if len(parts) > 1 else ""
248
+ m = re.search(r"\[upgradable from:\s*([^\]]+)\]", line)
249
+ current = m.group(1).strip() if m else ""
250
+ rows.append({"name": name, "current": current, "candidate": candidate})
251
+ return rows
252
+
253
+
254
+ def _parse_dnf_check(output: str) -> list[dict]:
255
+ """Parse ``dnf/yum check-update`` rows: ``name.arch candidate repo`` (no current version)."""
256
+ rows = []
257
+ for raw in output.splitlines():
258
+ line = raw.rstrip()
259
+ if not line or line.startswith(("Last metadata", "Obsoleting", "Security:")):
260
+ continue
261
+ parts = line.split()
262
+ if len(parts) != 3 or "." not in parts[0]:
263
+ continue
264
+ name = parts[0].rsplit(".", 1)[0]
265
+ rows.append({"name": name, "current": "", "candidate": parts[1]})
266
+ return rows
267
+
268
+
269
+ def _parse_zypper_updates(output: str) -> list[dict]:
270
+ """Parse the ``v |`` rows of ``zypper list-updates`` (pipe-separated columns)."""
271
+ rows = []
272
+ for line in output.splitlines():
273
+ if "|" not in line:
274
+ continue
275
+ cols = [c.strip() for c in line.split("|")]
276
+ if len(cols) < 6 or cols[0] != "v":
277
+ continue
278
+ rows.append({"name": cols[2], "current": cols[3], "candidate": cols[4]})
279
+ return rows
280
+
281
+
282
+ def _parse_pacman_qu(output: str) -> list[dict]:
283
+ """Parse ``pacman -Qu`` lines: ``name current -> candidate``."""
284
+ rows = []
285
+ for line in output.strip().splitlines():
286
+ m = re.match(r"(\S+)\s+(\S+)\s+->\s+(\S+)", line)
287
+ if m:
288
+ rows.append({"name": m.group(1), "current": m.group(2), "candidate": m.group(3)})
289
+ return rows
@@ -0,0 +1,35 @@
1
+ """Host power helpers: reboot."""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
7
+
8
+
9
+ def reboot_host(delay_minutes: int = 0) -> dict:
10
+ """Reboot the host, optionally after ``delay_minutes`` minutes. Needs root.
11
+
12
+ A delay of 0 (or less) reboots immediately (``shutdown -r now``); a positive
13
+ delay schedules it (``shutdown -r +N``) and returns straight away. Prefers
14
+ ``shutdown``, falling back to ``systemctl reboot`` for an immediate reboot if
15
+ ``shutdown`` is absent. Returns ``{scheduled: True, delay_minutes, command}``.
16
+
17
+ Raises ToolNotFoundError if no usable tool is present, or CommandError if the
18
+ command exits non-zero (for example when run as an unprivileged user).
19
+ """
20
+ if shutil.which("shutdown"):
21
+ when = "now" if delay_minutes <= 0 else f"+{delay_minutes}"
22
+ cmd = ["shutdown", "-r", when]
23
+ result = subprocess.run(cmd, capture_output=True, text=True)
24
+ if result.returncode != 0:
25
+ raise CommandError(result.stderr.strip() or "shutdown failed")
26
+ return {"scheduled": True, "delay_minutes": max(delay_minutes, 0), "command": " ".join(cmd)}
27
+
28
+ # 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)
31
+ if result.returncode != 0:
32
+ raise CommandError(result.stderr.strip() or "systemctl reboot failed")
33
+ return {"scheduled": True, "delay_minutes": 0, "command": "systemctl reboot"}
34
+
35
+ raise ToolNotFoundError("shutdown not available (and systemctl cannot schedule a delayed reboot)")
@@ -0,0 +1,129 @@
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 shutil
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+
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(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", "-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
+ if not shutil.which("sudo"):
94
+ raise ToolNotFoundError("sudo is not installed")
95
+ if not shutil.which("visudo"):
96
+ raise ToolNotFoundError("visudo is not installed (needed to validate the sudoers file)")
97
+
98
+ content = _sudoers_content(user, scope, _runspec_bin_dir())
99
+
100
+ with tempfile.NamedTemporaryFile("w", suffix=".sudoers", delete=False) as tf:
101
+ tf.write(content)
102
+ tmp_path = tf.name
103
+ try:
104
+ # 1) Validate the generated syntax locally before touching the system.
105
+ check = subprocess.run(["visudo", "-cf", tmp_path], capture_output=True, text=True)
106
+ if check.returncode != 0:
107
+ detail = check.stdout.strip() or check.stderr.strip()
108
+ raise CommandError(f"generated sudoers failed validation: {detail}")
109
+
110
+ # 2) Idempotent: identical drop-in already present? (reading it needs root)
111
+ existing = _sudo(password, ["cat", _DROP_IN])
112
+ if existing.returncode == 0 and existing.stdout == content:
113
+ return {"user": user, "scope": scope, "path": _DROP_IN, "installed": True, "already_configured": True}
114
+
115
+ # 3) Install atomically as root, 0440 root:root.
116
+ install = _sudo(password, ["install", "-m", "0440", "-o", "root", "-g", "root", tmp_path, _DROP_IN])
117
+ if install.returncode != 0:
118
+ raise CommandError(_auth_hint(install))
119
+
120
+ # 4) Re-validate the whole sudoers tree; roll our drop-in back if broken.
121
+ verify = _sudo(password, ["visudo", "-c"])
122
+ if verify.returncode != 0:
123
+ _sudo(password, ["rm", "-f", _DROP_IN])
124
+ detail = verify.stdout.strip() or verify.stderr.strip()
125
+ raise CommandError(f"sudoers validation failed after install — rolled back: {detail}")
126
+
127
+ return {"user": user, "scope": scope, "path": _DROP_IN, "installed": True, "already_configured": False}
128
+ finally:
129
+ os.unlink(tmp_path)
@@ -0,0 +1,137 @@
1
+ """Package-manager detection, name validation, and output parsing.
2
+
3
+ The parsers are pure (string in, list of dicts out) so they are tested directly;
4
+ ``detect_manager`` and ``list_packages`` are exercised with the subprocess layer
5
+ stubbed so no real package manager is touched.
6
+ """
7
+
8
+ import subprocess
9
+
10
+ import pytest
11
+
12
+ import runspec_linux_core as core
13
+ from runspec_linux_core import packages as pkg
14
+ from runspec_linux_core.errors import CommandError
15
+
16
+
17
+ def _completed(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess:
18
+ return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
19
+
20
+
21
+ # ── detect_manager ────────────────────────────────────────────────────────────
22
+
23
+
24
+ def test_detect_manager_prefers_first_found(monkeypatch: pytest.MonkeyPatch) -> None:
25
+ # Only dnf present (apt-get absent) → dnf wins over later yum/zypper/pacman.
26
+ monkeypatch.setattr("runspec_linux_core.packages.shutil.which", lambda name: name == "dnf")
27
+ assert pkg.detect_manager() == "dnf"
28
+
29
+
30
+ def test_detect_manager_apt_wins(monkeypatch: pytest.MonkeyPatch) -> None:
31
+ monkeypatch.setattr("runspec_linux_core.packages.shutil.which", lambda name: name in ("apt-get", "dnf"))
32
+ assert pkg.detect_manager() == "apt-get"
33
+
34
+
35
+ # ── name validation ───────────────────────────────────────────────────────────
36
+
37
+
38
+ def test_validate_packages_splits_multiple() -> None:
39
+ assert pkg._validate_packages("htop vim curl") == ["htop", "vim", "curl"]
40
+
41
+
42
+ def test_validate_packages_allows_real_names() -> None:
43
+ assert pkg._validate_packages("g++ lib32z1 python3.10 foo:amd64") == [
44
+ "g++",
45
+ "lib32z1",
46
+ "python3.10",
47
+ "foo:amd64",
48
+ ]
49
+
50
+
51
+ def test_validate_packages_rejects_empty() -> None:
52
+ with pytest.raises(ValueError):
53
+ pkg._validate_packages(" ")
54
+
55
+
56
+ @pytest.mark.parametrize("bad", ["-rf", "foo;rm", "$(whoami)", "a b&"])
57
+ def test_validate_packages_rejects_injection(bad: str) -> None:
58
+ # The "a b&" case splits into ["a", "b&"]; the "b&" token must be rejected.
59
+ with pytest.raises(ValueError):
60
+ pkg._validate_packages(bad)
61
+
62
+
63
+ # ── parsers ───────────────────────────────────────────────────────────────────
64
+
65
+
66
+ def test_parse_two_col() -> None:
67
+ out = "htop\t3.0.5-7\nvim\t2:8.2\nbroken-line-no-tab\n"
68
+ assert pkg._parse_two_col(out) == [
69
+ {"name": "htop", "version": "3.0.5-7"},
70
+ {"name": "vim", "version": "2:8.2"},
71
+ ]
72
+
73
+
74
+ def test_parse_pacman_q() -> None:
75
+ assert pkg._parse_pacman_q("htop 3.0.5-1\nvim 8.2.0-1\n") == [
76
+ {"name": "htop", "version": "3.0.5-1"},
77
+ {"name": "vim", "version": "8.2.0-1"},
78
+ ]
79
+
80
+
81
+ def test_parse_apt_list() -> None:
82
+ out = "Listing...\nhtop/focal-updates 3.0.5-1 amd64 [upgradable from: 2.2.0-2]\nvim/focal 2:8.2 amd64 [upgradable from: 2:8.1]\n"
83
+ assert pkg._parse_apt_list(out) == [
84
+ {"name": "htop", "current": "2.2.0-2", "candidate": "3.0.5-1"},
85
+ {"name": "vim", "current": "2:8.1", "candidate": "2:8.2"},
86
+ ]
87
+
88
+
89
+ def test_parse_dnf_check() -> None:
90
+ out = "Last metadata expiration check: 0:01 ago.\n\nhttpd.x86_64 2.4.53-7.el9 appstream\nkernel.x86_64 5.14.0-70 baseos\n"
91
+ assert pkg._parse_dnf_check(out) == [
92
+ {"name": "httpd", "current": "", "candidate": "2.4.53-7.el9"},
93
+ {"name": "kernel", "current": "", "candidate": "5.14.0-70"},
94
+ ]
95
+
96
+
97
+ def test_parse_zypper_updates() -> None:
98
+ out = (
99
+ "S | Repository | Name | Current Version | Available Version | Arch\n"
100
+ "--+------------+------+-----------------+-------------------+------\n"
101
+ "v | repo-oss | curl | 7.79.1-1 | 7.79.1-2 | x86_64\n"
102
+ )
103
+ assert pkg._parse_zypper_updates(out) == [
104
+ {"name": "curl", "current": "7.79.1-1", "candidate": "7.79.1-2"},
105
+ ]
106
+
107
+
108
+ def test_parse_pacman_qu() -> None:
109
+ out = "htop 3.0.5-1 -> 3.1.0-1\nvim 8.2.0-1 -> 8.2.1-1\n"
110
+ assert pkg._parse_pacman_qu(out) == [
111
+ {"name": "htop", "current": "3.0.5-1", "candidate": "3.1.0-1"},
112
+ {"name": "vim", "current": "8.2.0-1", "candidate": "8.2.1-1"},
113
+ ]
114
+
115
+
116
+ # ── list_packages (filter + limit + sort) ─────────────────────────────────────
117
+
118
+
119
+ def test_list_packages_filters_sorts_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
120
+ monkeypatch.setattr(pkg, "detect_manager", lambda: "apt-get")
121
+ monkeypatch.setattr(
122
+ pkg,
123
+ "_run",
124
+ lambda cmd: _completed("vim\t2\nlibvirt\t8\nhtop\t3\nzlib\t1\n"),
125
+ )
126
+ result = core.list_packages(name_filter="li", limit=1)
127
+ assert result["manager"] == "apt-get"
128
+ assert result["total"] == 2 # libvirt, zlib both contain "li"
129
+ assert result["count"] == 1
130
+ assert result["packages"] == [{"name": "libvirt", "version": "8"}] # sorted, capped
131
+
132
+
133
+ def test_list_packages_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
134
+ monkeypatch.setattr(pkg, "detect_manager", lambda: "apt-get")
135
+ monkeypatch.setattr(pkg, "_run", lambda cmd: _completed("", returncode=2, stderr="boom"))
136
+ with pytest.raises(CommandError):
137
+ core.list_packages()
@@ -0,0 +1,58 @@
1
+ """reboot_host builds the right command and surfaces failures as CommandError."""
2
+
3
+ import subprocess
4
+
5
+ import pytest
6
+
7
+ from runspec_linux_core import power
8
+ from runspec_linux_core.errors import CommandError
9
+
10
+
11
+ def _completed(returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess:
12
+ return subprocess.CompletedProcess(args=[], returncode=returncode, stdout="", stderr=stderr)
13
+
14
+
15
+ def test_reboot_now_uses_shutdown(monkeypatch: pytest.MonkeyPatch) -> None:
16
+ seen: list[list[str]] = []
17
+ monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "shutdown")
18
+ monkeypatch.setattr(
19
+ "runspec_linux_core.power.subprocess.run",
20
+ lambda cmd, **kw: seen.append(cmd) or _completed(),
21
+ )
22
+ result = power.reboot_host(0)
23
+ assert seen == [["shutdown", "-r", "now"]]
24
+ assert result == {"scheduled": True, "delay_minutes": 0, "command": "shutdown -r now"}
25
+
26
+
27
+ def test_reboot_with_delay_schedules(monkeypatch: pytest.MonkeyPatch) -> None:
28
+ seen: list[list[str]] = []
29
+ monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "shutdown")
30
+ monkeypatch.setattr(
31
+ "runspec_linux_core.power.subprocess.run",
32
+ lambda cmd, **kw: seen.append(cmd) or _completed(),
33
+ )
34
+ result = power.reboot_host(5)
35
+ assert seen == [["shutdown", "-r", "+5"]]
36
+ assert result["delay_minutes"] == 5
37
+
38
+
39
+ def test_reboot_falls_back_to_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
40
+ seen: list[list[str]] = []
41
+ monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "systemctl")
42
+ monkeypatch.setattr(
43
+ "runspec_linux_core.power.subprocess.run",
44
+ lambda cmd, **kw: seen.append(cmd) or _completed(),
45
+ )
46
+ result = power.reboot_host(0)
47
+ assert seen == [["systemctl", "reboot"]]
48
+ assert result["command"] == "systemctl reboot"
49
+
50
+
51
+ def test_reboot_non_zero_raises(monkeypatch: pytest.MonkeyPatch) -> None:
52
+ monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda name: name == "shutdown")
53
+ monkeypatch.setattr(
54
+ "runspec_linux_core.power.subprocess.run",
55
+ lambda cmd, **kw: _completed(returncode=1, stderr="Operation not permitted"),
56
+ )
57
+ with pytest.raises(CommandError):
58
+ power.reboot_host(0)
@@ -68,3 +68,45 @@ def test_restart_container_requires_docker(monkeypatch: pytest.MonkeyPatch) -> N
68
68
  monkeypatch.setattr("runspec_linux_core.containers.shutil.which", lambda _: None)
69
69
  with pytest.raises(ToolNotFoundError):
70
70
  core.restart_container("web")
71
+
72
+
73
+ def test_list_packages_requires_a_manager(monkeypatch: pytest.MonkeyPatch) -> None:
74
+ monkeypatch.setattr("runspec_linux_core.packages.shutil.which", lambda _: None)
75
+ with pytest.raises(ToolNotFoundError):
76
+ core.list_packages()
77
+
78
+
79
+ def test_list_upgrades_requires_a_manager(monkeypatch: pytest.MonkeyPatch) -> None:
80
+ monkeypatch.setattr("runspec_linux_core.packages.shutil.which", lambda _: None)
81
+ with pytest.raises(ToolNotFoundError):
82
+ core.list_upgrades()
83
+
84
+
85
+ def test_upgrade_packages_requires_a_manager(monkeypatch: pytest.MonkeyPatch) -> None:
86
+ monkeypatch.setattr("runspec_linux_core.packages.shutil.which", lambda _: None)
87
+ with pytest.raises(ToolNotFoundError):
88
+ core.upgrade_packages()
89
+
90
+
91
+ def test_install_package_requires_a_manager(monkeypatch: pytest.MonkeyPatch) -> None:
92
+ monkeypatch.setattr("runspec_linux_core.packages.shutil.which", lambda _: None)
93
+ with pytest.raises(ToolNotFoundError):
94
+ core.install_package("htop")
95
+
96
+
97
+ def test_remove_package_requires_a_manager(monkeypatch: pytest.MonkeyPatch) -> None:
98
+ monkeypatch.setattr("runspec_linux_core.packages.shutil.which", lambda _: None)
99
+ with pytest.raises(ToolNotFoundError):
100
+ core.remove_package("htop")
101
+
102
+
103
+ def test_reboot_host_requires_a_tool(monkeypatch: pytest.MonkeyPatch) -> None:
104
+ monkeypatch.setattr("runspec_linux_core.power.shutil.which", lambda _: None)
105
+ with pytest.raises(ToolNotFoundError):
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.shutil.which", lambda _: None)
111
+ with pytest.raises(ToolNotFoundError):
112
+ core.enable_passwordless_sudo("pw", "alice", "scoped")
@@ -0,0 +1,147 @@
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
+ monkeypatch.setattr("runspec_linux_core.sudoers.shutil.which", lambda name: f"/usr/sbin/{name}")
23
+ monkeypatch.setattr("runspec_linux_core.sudoers._runspec_bin_dir", lambda: "/opt/rs/bin")
24
+
25
+
26
+ # ── content ───────────────────────────────────────────────────────────────────
27
+
28
+
29
+ def test_scoped_content_targets_venv_bin() -> None:
30
+ out = sudoers._sudoers_content("alice", "scoped", "/opt/rs/bin")
31
+ assert "alice ALL=(root) NOPASSWD: /opt/rs/bin/\n" in out
32
+ assert out.startswith("# Managed by runspec-linux")
33
+
34
+
35
+ def test_all_content_is_nopasswd_all() -> None:
36
+ out = sudoers._sudoers_content("alice", "all", "/opt/rs/bin")
37
+ assert "alice ALL=(ALL) NOPASSWD: ALL\n" in out
38
+
39
+
40
+ # ── input validation ──────────────────────────────────────────────────────────
41
+
42
+
43
+ def test_rejects_bad_scope() -> None:
44
+ with pytest.raises(ValueError):
45
+ sudoers.enable_passwordless_sudo("pw", "alice", "everything")
46
+
47
+
48
+ @pytest.mark.parametrize("bad", ["root\nbad ALL=(ALL) NOPASSWD: ALL", "al ice", "-x", "a;b"])
49
+ def test_rejects_injection_in_user(bad: str) -> None:
50
+ with pytest.raises(ValueError):
51
+ sudoers.enable_passwordless_sudo("pw", bad, "scoped")
52
+
53
+
54
+ # ── install flow ──────────────────────────────────────────────────────────────
55
+
56
+
57
+ def test_install_happy_path_pipes_password_via_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
58
+ calls: list[dict] = []
59
+
60
+ def fake_run(cmd, **kw):
61
+ calls.append({"cmd": cmd, "input": kw.get("input")})
62
+ if cmd[:2] == ["visudo", "-cf"]: # local pre-validate
63
+ return _ok()
64
+ if cmd[:4] == ["sudo", "-S", "-p", ""]:
65
+ sub = cmd[4:] # args after the `sudo -S -p ''` prefix
66
+ if sub[0] == "cat": # idempotency probe: not yet installed
67
+ return _ok(returncode=1, stderr="No such file")
68
+ if sub[0] == "install":
69
+ return _ok()
70
+ if sub[0] == "visudo": # post-validate
71
+ return _ok()
72
+ raise AssertionError(f"unexpected command: {cmd}")
73
+
74
+ monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
75
+
76
+ result = sudoers.enable_passwordless_sudo("s3cret", "alice", "all")
77
+ assert result == {
78
+ "user": "alice",
79
+ "scope": "all",
80
+ "path": "/etc/sudoers.d/runspec",
81
+ "installed": True,
82
+ "already_configured": False,
83
+ }
84
+ # Password rides stdin on every sudo call — never appears in any argv.
85
+ sudo_calls = [c for c in calls if c["cmd"][:1] == ["sudo"]]
86
+ assert sudo_calls and all(c["input"] == "s3cret\n" for c in sudo_calls)
87
+ assert not any("s3cret" in " ".join(c["cmd"]) for c in calls)
88
+
89
+
90
+ def test_idempotent_when_identical_drop_in_exists(monkeypatch: pytest.MonkeyPatch) -> None:
91
+ content = sudoers._sudoers_content("alice", "all", "/opt/rs/bin")
92
+
93
+ def fake_run(cmd, **kw):
94
+ if cmd[:2] == ["visudo", "-cf"]:
95
+ return _ok()
96
+ if cmd[4:5] == ["cat"]:
97
+ return _ok(stdout=content) # already present, identical
98
+ raise AssertionError(f"should not install: {cmd}")
99
+
100
+ monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
101
+ result = sudoers.enable_passwordless_sudo("pw", "alice", "all")
102
+ assert result["already_configured"] is True
103
+
104
+
105
+ def test_wrong_password_raises_command_error(monkeypatch: pytest.MonkeyPatch) -> None:
106
+ def fake_run(cmd, **kw):
107
+ if cmd[:2] == ["visudo", "-cf"]:
108
+ return _ok()
109
+ if cmd[4:5] == ["cat"]:
110
+ return _ok(returncode=1)
111
+ if cmd[4:5] == ["install"]:
112
+ return _ok(returncode=1, stderr="sudo: 1 incorrect password attempt")
113
+ raise AssertionError(f"unexpected: {cmd}")
114
+
115
+ monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
116
+ with pytest.raises(CommandError, match="authentication failed"):
117
+ sudoers.enable_passwordless_sudo("wrong", "alice", "all")
118
+
119
+
120
+ def test_post_validate_failure_rolls_back(monkeypatch: pytest.MonkeyPatch) -> None:
121
+ removed: list[list[str]] = []
122
+
123
+ def fake_run(cmd, **kw):
124
+ if cmd[:2] == ["visudo", "-cf"]:
125
+ return _ok()
126
+ sub = cmd[4:] if cmd[:4] == ["sudo", "-S", "-p", ""] else []
127
+ if sub[:1] == ["cat"]:
128
+ return _ok(returncode=1)
129
+ if sub[:1] == ["install"]:
130
+ return _ok()
131
+ if sub[:1] == ["visudo"]: # whole-tree check fails
132
+ return _ok(returncode=1, stderr=">>> syntax error")
133
+ if sub[:1] == ["rm"]:
134
+ removed.append(sub)
135
+ return _ok()
136
+ raise AssertionError(f"unexpected: {cmd}")
137
+
138
+ monkeypatch.setattr("runspec_linux_core.sudoers.subprocess.run", fake_run)
139
+ with pytest.raises(CommandError, match="rolled back"):
140
+ sudoers.enable_passwordless_sudo("pw", "alice", "scoped")
141
+ assert removed and removed[0] == ["rm", "-f", "/etc/sudoers.d/runspec"]
142
+
143
+
144
+ 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
+ with pytest.raises(ToolNotFoundError, match="visudo"):
147
+ sudoers.enable_passwordless_sudo("pw", "alice", "scoped")
@@ -1,28 +0,0 @@
1
- # runspec-linux-core Changelog
2
-
3
- ## [0.1.0] — 2026-06-09
4
-
5
- Initial release.
6
-
7
- The pure-Python logic core extracted from `runspec-linux`. Provides the
8
- system-admin helper functions as plain importable functions — **no dependency on
9
- `runspec`, no `runspec.toml`, no console-script entry points** — so a package can
10
- `from runspec_linux_core import nc_send` (and the rest) without surfacing any
11
- runnables in the venv or in `runspec local` / `runspec serve` discovery.
12
-
13
- Each function returns plain data and *raises* on failure (`ToolNotFoundError`
14
- when a required system tool is absent, `CommandError` when a subprocess reports
15
- a non-zero exit); the runnable wrappers in `runspec-linux` catch these and render
16
- the JSON/exit behaviour.
17
-
18
- Exports:
19
-
20
- - **System** — `system_info`, `disk_usage`, `check_memory`, `list_processes`
21
- - **Services** — `check_service`, `list_services`, `restart_service`
22
- - **Logs** — `tail_log`, `search_log`, `journalctl`
23
- - **Network** — `ping_host`, `check_port`, `show_connections`
24
- - **Files** — `find_large_files`, `backup_files`
25
- - **Security** — `last_logins`, `who`
26
- - **Containers** — `list_containers`, `container_logs`, `restart_container`
27
- - **TCP** — `nc_send`
28
- - **Errors** — `LinuxCoreError`, `ToolNotFoundError`, `CommandError`