runspec-linux-core 0.8.0__tar.gz → 0.9.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.

Potentially problematic release.


This version of runspec-linux-core might be problematic. Click here for more details.

Files changed (44) hide show
  1. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/CHANGELOG.md +19 -0
  2. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/containers.py +21 -5
  5. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/investigate.py +30 -1
  6. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_containers_runas.py +36 -0
  7. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_investigate.py +37 -0
  8. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/.gitignore +0 -0
  9. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/__init__.py +0 -0
  10. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/_mounts.py +0 -0
  11. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/_paths.py +0 -0
  12. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/become.py +0 -0
  13. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/commands.py +0 -0
  14. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/cron.py +0 -0
  15. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/errors.py +0 -0
  16. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/files.py +0 -0
  17. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/logs.py +0 -0
  18. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/nc.py +0 -0
  19. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/network.py +0 -0
  20. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/packages.py +0 -0
  21. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/perf.py +0 -0
  22. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/power.py +0 -0
  23. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/security.py +0 -0
  24. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/services.py +0 -0
  25. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/sudoers.py +0 -0
  26. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/system.py +0 -0
  27. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/venvs.py +0 -0
  28. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/__init__.py +0 -0
  29. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_become.py +0 -0
  30. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_commands.py +0 -0
  31. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_cron.py +0 -0
  32. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_files.py +0 -0
  33. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_inventory.py +0 -0
  34. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_logs_services_runas.py +0 -0
  35. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_mounts.py +0 -0
  36. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_nc_send.py +0 -0
  37. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_network.py +0 -0
  38. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_packages.py +0 -0
  39. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_paths.py +0 -0
  40. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_perf.py +0 -0
  41. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_power.py +0 -0
  42. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_preconditions.py +0 -0
  43. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_sudoers.py +0 -0
  44. {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_venvs.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.9.0] — 2026-09-26
4
+
5
+ ### Added
6
+
7
+ - **`open_ports` flags owners it couldn't see.** `ss -p` only reports other
8
+ users' processes to root, so an unprivileged probe returned most listeners
9
+ with an empty `processes` list, which is easy to misread as "nothing owns this
10
+ port". When the probe didn't run as root and any owner is missing, the
11
+ result now carries a `hint` ("N of M listeners have no owning process …
12
+ Re-run with --sudo"). No hint as root, via `run_as="root"`, or when every
13
+ owner is known.
14
+ - **Docker socket permission errors name the fix.** `list_containers`,
15
+ `container_logs` and `restart_container` turn an unescalated "permission
16
+ denied … docker.sock" into a `CommandError` that says to re-run with
17
+ `--sudo`, use `--run-as` with a docker-group user, or add the user to the
18
+ docker group. `container_logs` now raises on that error instead of returning
19
+ the daemon's refusal as log lines. Escalated failures keep the existing
20
+ passwordless-sudo message.
21
+
3
22
  ## [0.8.0] — 2026-09-26
4
23
 
5
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspec-linux-core
3
- Version: 0.8.0
3
+ Version: 0.9.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.8.0"
7
+ version = "0.9.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 = []
@@ -12,13 +12,28 @@ import shutil
12
12
  import subprocess
13
13
 
14
14
  from runspec_linux_core.become import build_become, check_become_error
15
- from runspec_linux_core.errors import ToolNotFoundError
15
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
16
16
 
17
17
 
18
18
  def _docker_available() -> bool:
19
19
  return shutil.which("docker") is not None
20
20
 
21
21
 
22
+ def _check_docker_error(what: str, stderr: str, run_as: str | None) -> None:
23
+ """Raise :class:`CommandError` for a failed ``docker`` command.
24
+
25
+ An unescalated call refused by the daemon socket (root-owned, or limited to
26
+ the ``docker`` group) gets an actionable message naming the fix, so the
27
+ caller retries escalated instead of reporting "no containers". Anything else
28
+ goes through :func:`check_become_error` (passwordless-sudo hint when escalated).
29
+ """
30
+ err = stderr.strip()
31
+ low = err.lower()
32
+ if not run_as and "permission denied" in low and "docker" in low:
33
+ raise CommandError(f"{what}: {err} — this user can't reach the Docker socket. Re-run with --sudo (or --run-as a user in the docker group), or add this user to the docker group.")
34
+ check_become_error(what, stderr, run_as)
35
+
36
+
22
37
  def list_containers(include_all: bool = False, *, run_as: str | None = None, become_method: str = "sudo") -> list[dict]:
23
38
  """Return Docker containers (running, or all if ``include_all``).
24
39
 
@@ -40,7 +55,7 @@ def list_containers(include_all: bool = False, *, run_as: str | None = None, bec
40
55
 
41
56
  result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
42
57
  if result.returncode != 0:
43
- check_become_error("list containers", result.stderr, run_as)
58
+ _check_docker_error("list containers", result.stderr, run_as)
44
59
 
45
60
  rows = []
46
61
  for line in result.stdout.strip().splitlines():
@@ -72,8 +87,9 @@ def container_logs(container: str, lines: int = 50, *, run_as: str | None = None
72
87
 
73
88
  argv = build_become(["docker", "logs", "--tail", str(lines), container], run_as, become_method)
74
89
  result = subprocess.run(argv, capture_output=True, text=True)
75
- if run_as and result.returncode != 0:
76
- check_become_error("container logs", result.stderr, run_as)
90
+ if result.returncode != 0 and (run_as or "permission denied" in result.stderr.lower()):
91
+ # Escalation failures, and a refused Docker socket, are errors — not log lines.
92
+ _check_docker_error("container logs", result.stderr, run_as)
77
93
  # docker logs writes to stderr by default
78
94
  output = result.stderr if result.stderr else result.stdout
79
95
  output_lines = output.strip().splitlines()
@@ -95,7 +111,7 @@ def restart_container(container: str, *, run_as: str | None = None, become_metho
95
111
 
96
112
  result = subprocess.run(build_become(["docker", "restart", container], run_as, become_method), capture_output=True, text=True)
97
113
  if result.returncode != 0:
98
- check_become_error("restart container", result.stderr, run_as)
114
+ _check_docker_error("restart container", result.stderr, run_as)
99
115
  out = {"container": container, "restarted": True}
100
116
  if run_as:
101
117
  out["run_as"] = run_as
@@ -15,6 +15,7 @@ real tools.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import os
18
19
  import re
19
20
  import shutil
20
21
  import subprocess
@@ -153,12 +154,40 @@ def open_ports(proto: str = "tcp", *, run_as: str | None = None, become_method:
153
154
  check_become_error("open ports", result.stderr, run_as)
154
155
 
155
156
  rows = _parse_ss_listening(result.stdout)
156
- out = {"proto": proto, "count": len(rows), "listeners": rows}
157
+ out: dict = {"proto": proto, "count": len(rows), "listeners": rows}
157
158
  if run_as:
158
159
  out["run_as"] = run_as
160
+ hint = _ports_hint(rows, run_as)
161
+ if hint:
162
+ out["hint"] = hint
159
163
  return out
160
164
 
161
165
 
166
+ def _probe_is_root(run_as: str | None) -> bool:
167
+ if run_as:
168
+ return run_as == "root"
169
+ geteuid = getattr(os, "geteuid", None)
170
+ return geteuid is not None and geteuid() == 0
171
+
172
+
173
+ def _ports_hint(rows: list[dict], run_as: str | None) -> str | None:
174
+ """Say so when owning processes are missing because the probe wasn't root.
175
+
176
+ ``ss -p`` only reports processes the caller may inspect, so an unprivileged
177
+ probe returns most listeners with an empty ``processes`` list — easy to
178
+ misread as "nothing owns this port" and to guess the owner from the number.
179
+ """
180
+ if _probe_is_root(run_as):
181
+ return None
182
+ missing = sum(1 for r in rows if not r["processes"])
183
+ if not missing:
184
+ return None
185
+ return (
186
+ f"{missing} of {len(rows)} listeners have no owning process because ss only shows other users' "
187
+ "processes to root — an empty list does not mean nothing owns the port. Re-run with --sudo to see them."
188
+ )
189
+
190
+
162
191
  def owning_package(path: str) -> dict:
163
192
  """Return which installed package owns ``path`` (dpkg on Debian/Ubuntu, rpm on
164
193
  RHEL/SUSE). Raises ToolNotFoundError if neither dpkg nor rpm is present;
@@ -66,3 +66,39 @@ def test_restart_container_run_as_passwordless_hint(monkeypatch: pytest.MonkeyPa
66
66
  _stub(monkeypatch, rc=1, stderr="sudo: a terminal is required")
67
67
  with pytest.raises(Exception, match="passwordless sudo is not configured for run-as 'svc'"):
68
68
  containers.restart_container("web", run_as="svc")
69
+
70
+
71
+ _SOCKET_DENIED = "permission denied while trying to connect to the docker API at unix:///var/run/docker.sock"
72
+
73
+
74
+ def test_list_containers_socket_denied_suggests_sudo(monkeypatch: pytest.MonkeyPatch) -> None:
75
+ _stub(monkeypatch, rc=1, stderr=_SOCKET_DENIED)
76
+ with pytest.raises(containers.CommandError, match="Re-run with --sudo") as err:
77
+ containers.list_containers(include_all=True)
78
+ assert "docker.sock" in str(err.value)
79
+
80
+
81
+ def test_container_logs_socket_denied_is_an_error_not_log_lines(monkeypatch: pytest.MonkeyPatch) -> None:
82
+ _stub(monkeypatch, rc=1, stderr=_SOCKET_DENIED)
83
+ with pytest.raises(containers.CommandError, match="--sudo"):
84
+ containers.container_logs("web")
85
+
86
+
87
+ def test_container_logs_other_failure_still_returned_as_lines(monkeypatch: pytest.MonkeyPatch) -> None:
88
+ # unchanged: an unescalated non-permission failure keeps the old behaviour
89
+ _stub(monkeypatch, rc=1, stderr="Error: No such container: web")
90
+ assert containers.container_logs("web")["lines"] == ["Error: No such container: web"]
91
+
92
+
93
+ def test_restart_container_socket_denied_suggests_sudo(monkeypatch: pytest.MonkeyPatch) -> None:
94
+ _stub(monkeypatch, rc=1, stderr=_SOCKET_DENIED)
95
+ with pytest.raises(containers.CommandError, match="docker group"):
96
+ containers.restart_container("web")
97
+
98
+
99
+ def test_escalated_socket_denied_keeps_the_generic_error(monkeypatch: pytest.MonkeyPatch) -> None:
100
+ # escalated already → "re-run with --sudo" would be wrong advice
101
+ _stub(monkeypatch, rc=1, stderr=_SOCKET_DENIED)
102
+ with pytest.raises(containers.CommandError) as err:
103
+ containers.list_containers(run_as="deploy")
104
+ assert "--sudo" not in str(err.value)
@@ -84,3 +84,40 @@ def test_owning_package_dpkg(monkeypatch: pytest.MonkeyPatch) -> None:
84
84
  )
85
85
  out = investigate.owning_package("/usr/bin/ls")
86
86
  assert out == {"path": "/usr/bin/ls", "owned": True, "package": "coreutils", "manager": "dpkg"}
87
+
88
+
89
+ _SS_MIXED = (
90
+ "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\n"
91
+ "LISTEN 0 511 0.0.0.0:80 0.0.0.0:*\n"
92
+ "LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\n"
93
+ 'LISTEN 0 128 127.0.0.1:8765 0.0.0.0:* users:(("console-room",pid=991,fd=5))\n'
94
+ )
95
+
96
+
97
+ def _stub_ss(monkeypatch: pytest.MonkeyPatch, stdout: str) -> None:
98
+ monkeypatch.setattr(investigate.shutil, "which", lambda _: "/usr/bin/ss")
99
+ monkeypatch.setattr(investigate.subprocess, "run", lambda argv, **kw: subprocess.CompletedProcess(argv, 0, stdout=stdout, stderr=""))
100
+
101
+
102
+ def test_open_ports_hints_when_unprivileged_probe_misses_owners(monkeypatch: pytest.MonkeyPatch) -> None:
103
+ _stub_ss(monkeypatch, _SS_MIXED)
104
+ monkeypatch.setattr(investigate.os, "geteuid", lambda: 1000, raising=False)
105
+ out = investigate.open_ports("tcp")
106
+ assert out["hint"].startswith("2 of 3 listeners have no owning process")
107
+ assert "--sudo" in out["hint"]
108
+
109
+
110
+ def test_open_ports_no_hint_as_root_or_escalated(monkeypatch: pytest.MonkeyPatch) -> None:
111
+ _stub_ss(monkeypatch, _SS_MIXED)
112
+ monkeypatch.setattr(investigate.os, "geteuid", lambda: 0, raising=False)
113
+ assert "hint" not in investigate.open_ports("tcp")
114
+ monkeypatch.setattr(investigate.os, "geteuid", lambda: 1000, raising=False)
115
+ assert "hint" not in investigate.open_ports("tcp", run_as="root")
116
+ # escalating to a non-root user still can't see everyone's processes
117
+ assert "hint" in investigate.open_ports("tcp", run_as="deploy")
118
+
119
+
120
+ def test_open_ports_no_hint_when_every_owner_is_known(monkeypatch: pytest.MonkeyPatch) -> None:
121
+ _stub_ss(monkeypatch, 'State Recv-Q Send-Q Local Peer Process\nLISTEN 0 1 127.0.0.1:8765 0.0.0.0:* users:(("console-room",pid=991,fd=5))\n')
122
+ monkeypatch.setattr(investigate.os, "geteuid", lambda: 1000, raising=False)
123
+ assert "hint" not in investigate.open_ports("tcp")