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.
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/CHANGELOG.md +19 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/PKG-INFO +1 -1
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/pyproject.toml +1 -1
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/containers.py +21 -5
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/investigate.py +30 -1
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_containers_runas.py +36 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_investigate.py +37 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/.gitignore +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/__init__.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/_mounts.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/_paths.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/become.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/commands.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/cron.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/files.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/logs.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/network.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/perf.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/power.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/services.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/sudoers.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/system.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/runspec_linux_core/venvs.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/__init__.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_become.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_commands.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_cron.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_files.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_inventory.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_logs_services_runas.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_mounts.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_network.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_packages.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_paths.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_perf.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_power.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_preconditions.py +0 -0
- {runspec_linux_core-0.8.0 → runspec_linux_core-0.9.0}/tests/test_sudoers.py +0 -0
- {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
|
|
@@ -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.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
|
-
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|