runspec-linux-core 0.3.4__tar.gz → 0.4.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 (40) hide show
  1. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/CHANGELOG.md +48 -0
  2. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/__init__.py +45 -1
  5. runspec_linux_core-0.4.0/runspec_linux_core/become.py +97 -0
  6. runspec_linux_core-0.4.0/runspec_linux_core/investigate.py +217 -0
  7. runspec_linux_core-0.4.0/runspec_linux_core/logs.py +99 -0
  8. runspec_linux_core-0.4.0/runspec_linux_core/network.py +276 -0
  9. runspec_linux_core-0.4.0/runspec_linux_core/perf.py +228 -0
  10. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/services.py +15 -11
  11. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/venvs.py +29 -38
  12. runspec_linux_core-0.4.0/tests/test_become.py +86 -0
  13. runspec_linux_core-0.4.0/tests/test_investigate.py +86 -0
  14. runspec_linux_core-0.4.0/tests/test_logs_services_runas.py +102 -0
  15. runspec_linux_core-0.4.0/tests/test_network.py +59 -0
  16. runspec_linux_core-0.4.0/tests/test_perf.py +69 -0
  17. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_venvs.py +45 -24
  18. runspec_linux_core-0.3.4/runspec_linux_core/logs.py +0 -57
  19. runspec_linux_core-0.3.4/runspec_linux_core/network.py +0 -130
  20. runspec_linux_core-0.3.4/tests/test_network.py +0 -21
  21. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/.gitignore +0 -0
  22. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/_paths.py +0 -0
  23. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/commands.py +0 -0
  24. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/containers.py +0 -0
  25. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/errors.py +0 -0
  26. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/files.py +0 -0
  27. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/nc.py +0 -0
  28. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/packages.py +0 -0
  29. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/power.py +0 -0
  30. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/security.py +0 -0
  31. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/sudoers.py +0 -0
  32. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/system.py +0 -0
  33. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/__init__.py +0 -0
  34. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_commands.py +0 -0
  35. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_nc_send.py +0 -0
  36. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_packages.py +0 -0
  37. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_paths.py +0 -0
  38. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_power.py +0 -0
  39. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_preconditions.py +0 -0
  40. {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_sudoers.py +0 -0
@@ -1,5 +1,53 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.4.0] — 2026-08-01
4
+
5
+ New shared privilege-escalation module `runspec_linux_core.become` — the single
6
+ source of truth for runspec-linux's per-invocation `run_as` escalation
7
+ (mechanism B). It exposes `build_become(argv, run_as, method)` (`sudo -n -u` /
8
+ `su -c`), `validate_user`, `check_become_error` (the passwordless-sudo hint),
9
+ `escalation_supported`, `current_user`, and `BecomeError` (a `CommandError`).
10
+ `venvs.py` now delegates to it instead of its own private `_become`/`_check`
11
+ copy — byte-identical command output, no behaviour change beyond invalid
12
+ run-as/method now raising `BecomeError` (a `CommandError`) rather than a bare
13
+ `ValueError`. This module also backs the new `--run-as` / `--sudo` mutually
14
+ exclusive args on the privileged `runspec-linux` runnables.
15
+
16
+ New system-admin helpers for corporate app-support and performance work:
17
+
18
+ - `perf.py` — `get_sysctl` / `set_sysctl` (read/tune kernel params, optional
19
+ `/etc/sysctl.d` persist), `cpu_info` (governor/frequency/topology),
20
+ `pressure` (PSI `/proc/pressure/*`).
21
+ - `investigate.py` — `service_detail` (a unit's ExecStart/env/paths/state),
22
+ `open_ports` (listening port → pid → process), `owning_package` (which
23
+ package owns a path, dpkg/rpm), `failed_units` (`systemctl --failed`).
24
+ - `network.py` additions — `trace_route`, `route_table`, `socket_stats`,
25
+ `dns_config`.
26
+
27
+ The privileged helpers (`set_sysctl` persist, `service_detail`, `open_ports`,
28
+ and the log/service helpers) take `run_as` / `become_method` params and wrap
29
+ their subprocesses with `build_become`, so a caller can run them as root or a
30
+ specific service account.
31
+
32
+ ## [0.3.5] — 2026-07-08
33
+
34
+ Apply the same umask-neutralising treatment to `install_into_venv` that `0.3.4`
35
+ added to `create_venv`. After the `pip install`, the venv tree is now
36
+ normalised with `chmod -R u=rwX,go=rX <venv>` so pip's new directories are
37
+ `755` regardless of the caller's umask.
38
+
39
+ The trigger was a `sudo pip install --upgrade runspec-linux` under a host
40
+ where sudo/PAM had switched to a `027` login umask: pip's new
41
+ `site-packages/runspec_linux/` and `site-packages/runspec_linux_core/`
42
+ directories landed at `750`, and `runspec local` (walking `.dist-info` via
43
+ `importlib.metadata`) stopped seeing the packs for any user but the owner.
44
+ The equivalent bug for the venv root was fixed at build time in `0.3.4`; this
45
+ release closes the same gap at upgrade time.
46
+
47
+ If `<venv>/logs` exists (created by `create_venv`), its `2775` setgid mode is
48
+ restored after the wide chmod — the `test -d` guard skips the restore for a
49
+ venv built some other way. When `run_as` is set the new steps escalate too.
50
+
3
51
  ## [0.3.4] — 2026-07-07
4
52
 
5
53
  Fix `create_venv` producing an unreadable `750` venv directory when the caller's
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux-core
3
- Version: 0.3.4
3
+ Version: 0.4.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.3.4"
7
+ version = "0.4.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 = []
@@ -9,6 +9,14 @@ Each function does the work and returns plain data; failures raise (see
9
9
  ``runspec_linux_core.errors``).
10
10
  """
11
11
 
12
+ from runspec_linux_core.become import (
13
+ BecomeError,
14
+ build_become,
15
+ check_become_error,
16
+ current_user,
17
+ escalation_supported,
18
+ validate_user,
19
+ )
12
20
  from runspec_linux_core.commands import which
13
21
  from runspec_linux_core.containers import (
14
22
  container_logs,
@@ -17,9 +25,23 @@ from runspec_linux_core.containers import (
17
25
  )
18
26
  from runspec_linux_core.errors import CommandError, LinuxCoreError, ToolNotFoundError
19
27
  from runspec_linux_core.files import backup_files, find_large_files
28
+ from runspec_linux_core.investigate import (
29
+ failed_units,
30
+ open_ports,
31
+ owning_package,
32
+ service_detail,
33
+ )
20
34
  from runspec_linux_core.logs import journalctl, search_log, tail_log
21
35
  from runspec_linux_core.nc import nc_send
22
- from runspec_linux_core.network import check_port, ping_host, show_connections
36
+ from runspec_linux_core.network import (
37
+ check_port,
38
+ dns_config,
39
+ ping_host,
40
+ route_table,
41
+ show_connections,
42
+ socket_stats,
43
+ trace_route,
44
+ )
23
45
  from runspec_linux_core.packages import (
24
46
  detect_manager,
25
47
  install_package,
@@ -28,6 +50,7 @@ from runspec_linux_core.packages import (
28
50
  remove_package,
29
51
  upgrade_packages,
30
52
  )
53
+ from runspec_linux_core.perf import cpu_info, get_sysctl, pressure, set_sysctl
31
54
  from runspec_linux_core.power import reboot_host
32
55
  from runspec_linux_core.security import last_logins, who
33
56
  from runspec_linux_core.services import check_service, list_services, restart_service
@@ -40,6 +63,13 @@ __all__ = [
40
63
  "LinuxCoreError",
41
64
  "ToolNotFoundError",
42
65
  "CommandError",
66
+ # become (privilege escalation)
67
+ "BecomeError",
68
+ "build_become",
69
+ "check_become_error",
70
+ "current_user",
71
+ "escalation_supported",
72
+ "validate_user",
43
73
  # system
44
74
  "system_info",
45
75
  "disk_usage",
@@ -57,6 +87,20 @@ __all__ = [
57
87
  "ping_host",
58
88
  "check_port",
59
89
  "show_connections",
90
+ "trace_route",
91
+ "route_table",
92
+ "socket_stats",
93
+ "dns_config",
94
+ # perf tuning & analysis
95
+ "get_sysctl",
96
+ "set_sysctl",
97
+ "cpu_info",
98
+ "pressure",
99
+ # app discovery & investigation
100
+ "service_detail",
101
+ "open_ports",
102
+ "owning_package",
103
+ "failed_units",
60
104
  # files
61
105
  "find_large_files",
62
106
  "backup_files",
@@ -0,0 +1,97 @@
1
+ """Privilege-escalation command builder — run a command as another OS user via
2
+ ``sudo -n -u <user>`` / ``su <user> -c``.
3
+
4
+ This is the single source of truth for runspec-linux's per-invocation ``run_as``
5
+ escalation (mechanism B): the venv runnables and the ``--run-as`` / ``--sudo``
6
+ args on the privileged runnables all build their become command here. It is a
7
+ sibling of ``runspec_fs_core.become`` (kept a separate copy because the ``*-core``
8
+ packages stay dependency-free and must not cross-import); the canonical user
9
+ regex here allows a dot (``[a-z0-9_.-]``), a superset of the fs-core form, so it
10
+ matches ``sudoers._USER_RE`` and every name runspec-linux already accepts.
11
+
12
+ Pure and stdlib-only: this *builds and validates* the become command; executing
13
+ it (and interpreting a passwordless-sudo failure) stays with the caller — see
14
+ ``_check`` in ``venvs.py`` and ``check_become_error`` below.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ import shlex
22
+
23
+ from runspec_linux_core.errors import CommandError
24
+
25
+ # A POSIX-ish user/group name, validated before it is interpolated into a become
26
+ # command (or a chgrp) so a crafted value can't inject extra tokens — everything
27
+ # still goes to argv, never a shell, but this keeps the error early and clear.
28
+ # Allows a dot, matching sudoers._USER_RE and the historical venvs._NAME_RE.
29
+ _USER_RE = re.compile(r"^[a-z_][a-z0-9_.-]*\$?$")
30
+
31
+ BECOME_METHODS = ("sudo", "su")
32
+
33
+
34
+ class BecomeError(CommandError):
35
+ """An invalid run-as user, or an unsupported / unavailable become method."""
36
+
37
+
38
+ def validate_user(name: str) -> str:
39
+ """Raise :class:`BecomeError` unless ``name`` is a well-formed OS user name;
40
+ return it unchanged on success (so it can be used inline)."""
41
+ if not name or not _USER_RE.match(name):
42
+ raise BecomeError(f"invalid run-as user: {name!r}")
43
+ return name
44
+
45
+
46
+ def build_become(argv: list[str], run_as: str | None, method: str = "sudo") -> list[str]:
47
+ """Wrap ``argv`` so it runs as ``run_as``. Returns ``argv`` unchanged when
48
+ ``run_as`` is falsy (no escalation requested).
49
+
50
+ - ``sudo`` → ``sudo -n -u <user> <argv...>`` (``-n`` never prompts, so a
51
+ missing passwordless-sudo rule fails fast instead of hanging on a
52
+ TTY-less SSH/agent channel).
53
+ - ``su`` → ``su <user> -c '<argv...>'`` (su has no ``-n``; it only works
54
+ without a password when the caller is already root).
55
+ """
56
+ if not run_as:
57
+ return argv
58
+ validate_user(run_as)
59
+ if method not in BECOME_METHODS:
60
+ raise CommandError(f"invalid become-method: {method!r} (expected sudo or su)")
61
+ if method == "su":
62
+ return ["su", run_as, "-c", shlex.join(argv)]
63
+ return ["sudo", "-n", "-u", run_as, *argv]
64
+
65
+
66
+ def check_become_error(what: str, stderr: str, run_as: str | None) -> None:
67
+ """Raise :class:`CommandError` for a failed escalated subprocess, adding a
68
+ passwordless-sudo hint on the classic "password/terminal required" cases.
69
+
70
+ ``what`` labels the operation; ``stderr`` is the subprocess's captured
71
+ stderr; ``run_as`` is the target user (``None`` when no escalation was in
72
+ play — then the hint is skipped). Shared by the venv helpers and the new
73
+ ``--run-as`` / ``--sudo`` runnables so the message is identical everywhere.
74
+ """
75
+ err = stderr.strip()
76
+ low = err.lower()
77
+ if run_as and ("password is required" in low or "a terminal is required" in low):
78
+ raise CommandError(f"{what}: passwordless sudo is not configured for run-as {run_as!r} ({err})")
79
+ raise CommandError(err or f"{what} failed")
80
+
81
+
82
+ def escalation_supported() -> bool:
83
+ """True on a POSIX host where ``sudo``/``su`` escalation makes sense."""
84
+ return hasattr(os, "geteuid")
85
+
86
+
87
+ def current_user() -> str:
88
+ """The effective OS user name — used to decide whether escalation is needed
89
+ (running as the user you already are is a no-op)."""
90
+ try:
91
+ import pwd
92
+
93
+ return pwd.getpwuid(os.geteuid()).pw_name
94
+ except (ImportError, KeyError, AttributeError):
95
+ import getpass
96
+
97
+ return getpass.getuser()
@@ -0,0 +1,217 @@
1
+ """Application discovery & investigation helpers — answer "where does this
2
+ bespoke app live, what runs it, what's it listening on, and which package owns
3
+ this file" for corporate application support.
4
+
5
+ - ``service_detail`` — a systemd unit's ExecStart / working dir / user / env
6
+ files / active state (from ``systemctl show``).
7
+ - ``open_ports`` — listening sockets mapped to pid → process (from ``ss -tlnp``);
8
+ the full pid map needs privilege, so it takes ``run_as`` / ``become_method``.
9
+ - ``owning_package`` — which package owns a path (dpkg / rpm).
10
+ - ``failed_units`` — systemd units in the failed state.
11
+
12
+ Parsers are separated from the subprocess I/O so they unit-test without the
13
+ real tools.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+
22
+ from runspec_linux_core.become import build_become, check_become_error
23
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
24
+
25
+ # systemctl show properties worth surfacing for "where does this app live".
26
+ _SERVICE_PROPS = [
27
+ "Id",
28
+ "Description",
29
+ "LoadState",
30
+ "ActiveState",
31
+ "SubState",
32
+ "UnitFileState",
33
+ "FragmentPath",
34
+ "ExecStart",
35
+ "WorkingDirectory",
36
+ "User",
37
+ "Group",
38
+ "EnvironmentFiles",
39
+ "MainPID",
40
+ ]
41
+
42
+
43
+ def _parse_show(text: str) -> dict[str, str]:
44
+ """Parse ``systemctl show`` ``key=value`` output into a dict."""
45
+ props: dict[str, str] = {}
46
+ for line in text.splitlines():
47
+ if "=" in line:
48
+ k, v = line.split("=", 1)
49
+ props[k] = v
50
+ return props
51
+
52
+
53
+ def _exec_start_path(exec_start: str) -> str:
54
+ """Pull the binary path out of a systemd ExecStart property value.
55
+
56
+ ExecStart is rendered as ``{ path=/usr/bin/foo ; argv[]=... }`` by
57
+ ``systemctl show``; fall back to the first token otherwise.
58
+ """
59
+ m = re.search(r"path=(\S+)", exec_start)
60
+ if m:
61
+ return m.group(1)
62
+ return exec_start.split(";", 1)[0].strip().split(" ", 1)[0]
63
+
64
+
65
+ def service_detail(unit: str, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
66
+ """Return where a systemd unit lives: ExecStart binary, working dir, user,
67
+ env files, unit-file path, and active state.
68
+
69
+ Some properties on protected units need privilege, so ``run_as`` /
70
+ ``become_method`` escalate the ``systemctl show``. Raises ToolNotFoundError if
71
+ systemctl is absent.
72
+ """
73
+ if not shutil.which("systemctl"):
74
+ raise ToolNotFoundError("systemctl not available — not a systemd system")
75
+
76
+ cmd = ["systemctl", "show", unit, "--property=" + ",".join(_SERVICE_PROPS)]
77
+ result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
78
+ if result.returncode != 0:
79
+ check_become_error("service detail", result.stderr, run_as)
80
+
81
+ props = _parse_show(result.stdout)
82
+ exec_start = props.get("ExecStart", "")
83
+ return {
84
+ "unit": props.get("Id", unit),
85
+ "description": props.get("Description", ""),
86
+ "load_state": props.get("LoadState", ""),
87
+ "active_state": props.get("ActiveState", ""),
88
+ "sub_state": props.get("SubState", ""),
89
+ "unit_file_state": props.get("UnitFileState", ""),
90
+ "fragment_path": props.get("FragmentPath", ""),
91
+ "exec_start": exec_start,
92
+ "exec_start_path": _exec_start_path(exec_start) if exec_start else "",
93
+ "working_directory": props.get("WorkingDirectory", ""),
94
+ "user": props.get("User", ""),
95
+ "group": props.get("Group", ""),
96
+ "environment_files": props.get("EnvironmentFiles", ""),
97
+ "main_pid": props.get("MainPID", ""),
98
+ }
99
+
100
+
101
+ _PEER_WILDCARD_RE = re.compile(r"^[\d.a-fA-F:\[\]*]+:\*$")
102
+
103
+
104
+ def _parse_ss_listening(text: str) -> list[dict]:
105
+ """Parse ``ss -tlnp`` / ``-ulnp`` / ``-tulnp`` output into listening-socket
106
+ rows with port → pid/process.
107
+
108
+ Column layout shifts with the flags (``-tu`` prepends a Netid column that
109
+ ``-t`` alone omits), so the local address is anchored on the wildcard peer
110
+ field (``*:*`` / ``0.0.0.0:*`` / ``[::]:*``) rather than a fixed index.
111
+ """
112
+ rows: list[dict] = []
113
+ lines = text.strip().splitlines()
114
+ for line in lines[1:] if lines else []: # skip header
115
+ parts = line.split()
116
+ if len(parts) < 4:
117
+ continue
118
+
119
+ peer_idx = next((i for i, p in enumerate(parts) if _PEER_WILDCARD_RE.match(p)), None)
120
+ if peer_idx is None or peer_idx == 0:
121
+ continue
122
+ local = parts[peer_idx - 1]
123
+ port = local.rsplit(":", 1)[-1]
124
+ # A leading Netid column (tcp/udp) is present with -tu but not -t alone.
125
+ proto = parts[0] if parts[0] in ("tcp", "udp", "tcp6", "udp6") else ""
126
+
127
+ procs: list[dict] = []
128
+ m = re.search(r"users:\(\((.*)\)\)", line)
129
+ if m:
130
+ for pm in re.finditer(r'"([^"]+)",pid=(\d+)', m.group(1)):
131
+ procs.append({"process": pm.group(1), "pid": int(pm.group(2))})
132
+ rows.append({"proto": proto, "local": local, "port": port, "processes": procs})
133
+ return rows
134
+
135
+
136
+ def open_ports(proto: str = "tcp", *, run_as: str | None = None, become_method: str = "sudo") -> dict:
137
+ """Return listening sockets mapped to the owning process (port → pid → name).
138
+
139
+ The full pid/process map needs privilege (``ss`` only shows other users'
140
+ processes as root), so ``run_as`` / ``become_method`` escalate the probe.
141
+ ``proto`` is ``tcp`` / ``udp`` / ``both``. Raises ToolNotFoundError if ``ss``
142
+ is absent.
143
+ """
144
+ if not shutil.which("ss"):
145
+ raise ToolNotFoundError("ss not available (iproute2)")
146
+
147
+ flags = {"tcp": "-tlnp", "udp": "-ulnp", "both": "-tulnp"}.get(proto)
148
+ if flags is None:
149
+ raise ValueError(f"invalid proto: {proto!r} (expected tcp, udp, or both)")
150
+
151
+ result = subprocess.run(build_become(["ss", flags], run_as, become_method), capture_output=True, text=True)
152
+ if result.returncode != 0:
153
+ check_become_error("open ports", result.stderr, run_as)
154
+
155
+ rows = _parse_ss_listening(result.stdout)
156
+ out = {"proto": proto, "count": len(rows), "listeners": rows}
157
+ if run_as:
158
+ out["run_as"] = run_as
159
+ return out
160
+
161
+
162
+ def owning_package(path: str) -> dict:
163
+ """Return which installed package owns ``path`` (dpkg on Debian/Ubuntu, rpm on
164
+ RHEL/SUSE). Raises ToolNotFoundError if neither dpkg nor rpm is present;
165
+ returns ``{owned: False}`` when the file belongs to no package.
166
+ """
167
+ if shutil.which("dpkg"):
168
+ result = subprocess.run(["dpkg", "-S", path], capture_output=True, text=True)
169
+ if result.returncode == 0 and ":" in result.stdout:
170
+ pkg = result.stdout.split(":", 1)[0].strip()
171
+ return {"path": path, "owned": True, "package": pkg, "manager": "dpkg"}
172
+ return {"path": path, "owned": False, "manager": "dpkg"}
173
+ if shutil.which("rpm"):
174
+ result = subprocess.run(["rpm", "-qf", path], capture_output=True, text=True)
175
+ if result.returncode == 0 and result.stdout.strip():
176
+ return {"path": path, "owned": True, "package": result.stdout.strip(), "manager": "rpm"}
177
+ return {"path": path, "owned": False, "manager": "rpm"}
178
+ raise ToolNotFoundError("neither dpkg nor rpm found — cannot resolve package ownership")
179
+
180
+
181
+ def _parse_failed_units(text: str) -> list[dict]:
182
+ """Parse ``systemctl --failed --no-legend`` output into unit rows."""
183
+ rows: list[dict] = []
184
+ for line in text.strip().splitlines():
185
+ # A leading bullet ("● ") may appear; strip non-unit prefix tokens.
186
+ parts = line.replace("●", " ").split(None, 4)
187
+ if len(parts) < 4:
188
+ continue
189
+ rows.append(
190
+ {
191
+ "unit": parts[0],
192
+ "load": parts[1],
193
+ "active": parts[2],
194
+ "sub": parts[3],
195
+ "description": parts[4] if len(parts) > 4 else "",
196
+ }
197
+ )
198
+ return rows
199
+
200
+
201
+ def failed_units() -> dict:
202
+ """Return systemd units currently in the failed state (``systemctl --failed``).
203
+
204
+ Raises ToolNotFoundError if systemctl is absent, or CommandError on failure.
205
+ """
206
+ if not shutil.which("systemctl"):
207
+ raise ToolNotFoundError("systemctl not available — not a systemd system")
208
+
209
+ result = subprocess.run(
210
+ ["systemctl", "--failed", "--type=service", "--no-legend", "--no-pager", "--plain"],
211
+ capture_output=True,
212
+ text=True,
213
+ )
214
+ if result.returncode != 0:
215
+ raise CommandError(result.stderr.strip() or "systemctl --failed failed")
216
+ rows = _parse_failed_units(result.stdout)
217
+ return {"count": len(rows), "units": rows}
@@ -0,0 +1,99 @@
1
+ """Log helpers: tail a file, search a file, read journald units.
2
+
3
+ Corporate application logs are often root-owned (or owned by a service
4
+ account), so each helper takes an optional ``run_as`` user (with a
5
+ ``become_method``). When set, the read is performed by a subprocess escalated
6
+ via ``sudo -n -u`` / ``su`` (:func:`runspec_linux_core.become.build_become`) — a
7
+ faithful escalation of the same read, bounded by that user's OS permissions.
8
+ With no ``run_as`` the read stays in-process (unchanged).
9
+ """
10
+
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+
15
+ from runspec_linux_core.become import build_become, check_become_error
16
+ from runspec_linux_core.errors import ToolNotFoundError
17
+
18
+
19
+ def tail_log(file_path: str, lines: int = 50, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
20
+ """Return the last ``lines`` lines of a text file.
21
+
22
+ With ``run_as`` set the read is escalated via ``tail -n`` under sudo/su (so a
23
+ root-owned log is readable); otherwise it reads in-process. Propagates OSError
24
+ if an in-process read fails, or CommandError if the escalated read fails.
25
+ """
26
+ if run_as:
27
+ argv = build_become(["tail", "-n", str(lines), file_path], run_as, become_method)
28
+ result = subprocess.run(argv, capture_output=True, text=True)
29
+ if result.returncode != 0:
30
+ check_become_error("tail log", result.stderr, run_as)
31
+ tail = result.stdout.splitlines()
32
+ return {"file": file_path, "lines": tail, "count": len(tail), "run_as": run_as}
33
+
34
+ with open(file_path, errors="replace") as f:
35
+ all_lines = f.readlines()
36
+ tail = [line.rstrip("\n") for line in all_lines[-lines:]]
37
+ return {"file": file_path, "lines": tail, "count": len(tail)}
38
+
39
+
40
+ def search_log(file_path: str, pattern: str, limit: int = 100, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
41
+ """Return up to ``limit`` (most recent) lines of a file matching ``pattern``.
42
+
43
+ The match is always Python ``re`` (case-insensitive). With ``run_as`` set the
44
+ file is read via ``cat`` under sudo/su (so a root-owned log is searchable) and
45
+ the same regex is applied; otherwise it streams the file in-process. Propagates
46
+ OSError / re.error in-process, or CommandError if the escalated read fails.
47
+ """
48
+ regex = re.compile(pattern, re.IGNORECASE)
49
+ matches: list[str] = []
50
+
51
+ if run_as:
52
+ argv = build_become(["cat", file_path], run_as, become_method)
53
+ result = subprocess.run(argv, capture_output=True, text=True)
54
+ if result.returncode != 0:
55
+ check_become_error("read log", result.stderr, run_as)
56
+ for line in result.stdout.splitlines():
57
+ if regex.search(line):
58
+ matches.append(line)
59
+ else:
60
+ with open(file_path, errors="replace") as f:
61
+ for line in f:
62
+ if regex.search(line):
63
+ matches.append(line.rstrip("\n"))
64
+
65
+ trimmed = matches[-limit:] if len(matches) > limit else matches
66
+ result_dict = {
67
+ "file": file_path,
68
+ "pattern": pattern,
69
+ "matches": trimmed,
70
+ "count": len(trimmed),
71
+ "total_matches": len(matches),
72
+ }
73
+ if run_as:
74
+ result_dict["run_as"] = run_as
75
+ return result_dict
76
+
77
+
78
+ def journalctl(unit: str, lines: int = 50, since: str | None = None, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
79
+ """Return recent journald log lines for a systemd unit.
80
+
81
+ With ``run_as`` set the query is escalated via sudo/su (so protected units
82
+ outside the ``systemd-journal`` group are readable). Raises ToolNotFoundError
83
+ if journalctl is not present, or CommandError if the query exits non-zero.
84
+ """
85
+ if not shutil.which("journalctl"):
86
+ raise ToolNotFoundError("journalctl not available — not a systemd system")
87
+
88
+ cmd = ["journalctl", f"-u{unit}", f"-n{lines}", "--no-pager", "--output=short"]
89
+ if since:
90
+ cmd += [f"--since={since}"]
91
+
92
+ result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
93
+ if result.returncode != 0:
94
+ check_become_error("journalctl", result.stderr, run_as)
95
+ output_lines = result.stdout.strip().splitlines()
96
+ out = {"unit": unit, "lines": output_lines, "count": len(output_lines)}
97
+ if run_as:
98
+ out["run_as"] = run_as
99
+ return out