runspec-linux-core 0.4.0__tar.gz → 0.5.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.
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/CHANGELOG.md +12 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/PKG-INFO +1 -1
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/pyproject.toml +1 -1
- runspec_linux_core-0.5.0/runspec_linux_core/containers.py +102 -0
- runspec_linux_core-0.5.0/tests/test_containers_runas.py +68 -0
- runspec_linux_core-0.4.0/runspec_linux_core/containers.py +0 -88
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/.gitignore +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/__init__.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/_paths.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/become.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/commands.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/files.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/investigate.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/logs.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/network.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/perf.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/power.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/services.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/sudoers.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/system.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/venvs.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/__init__.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_become.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_commands.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_investigate.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_logs_services_runas.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_network.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_packages.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_paths.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_perf.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_power.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_preconditions.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_sudoers.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_venvs.py +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# runspec-linux-core Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] — 2026-08-07
|
|
4
|
+
|
|
5
|
+
The Docker container helpers now take the same per-invocation `run_as` /
|
|
6
|
+
`become_method` escalation as the log/service helpers, since the Docker daemon
|
|
7
|
+
socket is typically root- or `docker`-group-owned. `list_containers`,
|
|
8
|
+
`container_logs`, and `restart_container` gain keyword-only `run_as` /
|
|
9
|
+
`become_method` params and wrap their `docker` subprocess with `build_become`;
|
|
10
|
+
a failed escalated command raises `CommandError` with the passwordless-sudo hint
|
|
11
|
+
(`container_logs` no longer returns the sudo error as log lines when `run_as` is
|
|
12
|
+
set). No behaviour change when `run_as` is unset.
|
|
13
|
+
|
|
14
|
+
|
|
3
15
|
## [0.4.0] — 2026-08-01
|
|
4
16
|
|
|
5
17
|
New shared privilege-escalation module `runspec_linux_core.become` — the single
|
|
@@ -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.5.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 = []
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Docker container helpers: list, logs, restart.
|
|
2
|
+
|
|
3
|
+
The Docker daemon socket is typically root-owned (or restricted to the ``docker``
|
|
4
|
+
group), so each helper takes an optional ``run_as`` user (with a
|
|
5
|
+
``become_method``). When set, the ``docker`` command is escalated via
|
|
6
|
+
``sudo -n -u`` / ``su`` (:func:`runspec_linux_core.become.build_become`) — a
|
|
7
|
+
``--sudo`` flag on the runnable maps to ``run_as="root"``. With no ``run_as`` the
|
|
8
|
+
command runs as the calling user (unchanged).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from runspec_linux_core.become import build_become, check_become_error
|
|
15
|
+
from runspec_linux_core.errors import ToolNotFoundError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _docker_available() -> bool:
|
|
19
|
+
return shutil.which("docker") is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_containers(include_all: bool = False, *, run_as: str | None = None, become_method: str = "sudo") -> list[dict]:
|
|
23
|
+
"""Return Docker containers (running, or all if ``include_all``).
|
|
24
|
+
|
|
25
|
+
Raises ToolNotFoundError if docker is not installed, or CommandError if the
|
|
26
|
+
``docker ps`` command returns a non-zero exit (with a passwordless-sudo hint
|
|
27
|
+
when ``run_as`` is set).
|
|
28
|
+
"""
|
|
29
|
+
if not _docker_available():
|
|
30
|
+
raise ToolNotFoundError("docker not installed or not in PATH")
|
|
31
|
+
|
|
32
|
+
cmd = [
|
|
33
|
+
"docker",
|
|
34
|
+
"ps",
|
|
35
|
+
"--format",
|
|
36
|
+
"{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Names}}",
|
|
37
|
+
]
|
|
38
|
+
if include_all:
|
|
39
|
+
cmd.append("--all")
|
|
40
|
+
|
|
41
|
+
result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
|
|
42
|
+
if result.returncode != 0:
|
|
43
|
+
check_become_error("list containers", result.stderr, run_as)
|
|
44
|
+
|
|
45
|
+
rows = []
|
|
46
|
+
for line in result.stdout.strip().splitlines():
|
|
47
|
+
parts = line.split("\t")
|
|
48
|
+
if len(parts) < 6:
|
|
49
|
+
continue
|
|
50
|
+
rows.append(
|
|
51
|
+
{
|
|
52
|
+
"id": parts[0],
|
|
53
|
+
"image": parts[1],
|
|
54
|
+
"command": parts[2].strip('"'),
|
|
55
|
+
"created": parts[3],
|
|
56
|
+
"status": parts[4],
|
|
57
|
+
"name": parts[5],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def container_logs(container: str, lines: int = 50, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
|
|
64
|
+
"""Return the last ``lines`` log lines for a container.
|
|
65
|
+
|
|
66
|
+
Raises ToolNotFoundError if docker is not installed. When ``run_as`` is set
|
|
67
|
+
and the escalated command fails, raises CommandError with a passwordless-sudo
|
|
68
|
+
hint rather than returning the sudo error as log lines.
|
|
69
|
+
"""
|
|
70
|
+
if not _docker_available():
|
|
71
|
+
raise ToolNotFoundError("docker not installed or not in PATH")
|
|
72
|
+
|
|
73
|
+
argv = build_become(["docker", "logs", "--tail", str(lines), container], run_as, become_method)
|
|
74
|
+
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)
|
|
77
|
+
# docker logs writes to stderr by default
|
|
78
|
+
output = result.stderr if result.stderr else result.stdout
|
|
79
|
+
output_lines = output.strip().splitlines()
|
|
80
|
+
out = {"container": container, "lines": output_lines, "count": len(output_lines)}
|
|
81
|
+
if run_as:
|
|
82
|
+
out["run_as"] = run_as
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def restart_container(container: str, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
|
|
87
|
+
"""Restart a container. Returns ``{container, restarted: True}`` on success.
|
|
88
|
+
|
|
89
|
+
Raises ToolNotFoundError if docker is not installed, or CommandError if the
|
|
90
|
+
restart returns a non-zero exit (with a passwordless-sudo hint when
|
|
91
|
+
``run_as`` is set).
|
|
92
|
+
"""
|
|
93
|
+
if not _docker_available():
|
|
94
|
+
raise ToolNotFoundError("docker not installed or not in PATH")
|
|
95
|
+
|
|
96
|
+
result = subprocess.run(build_become(["docker", "restart", container], run_as, become_method), capture_output=True, text=True)
|
|
97
|
+
if result.returncode != 0:
|
|
98
|
+
check_become_error("restart container", result.stderr, run_as)
|
|
99
|
+
out = {"container": container, "restarted": True}
|
|
100
|
+
if run_as:
|
|
101
|
+
out["run_as"] = run_as
|
|
102
|
+
return out
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The run_as / become_method escalation on the Docker container helpers.
|
|
2
|
+
|
|
3
|
+
The subprocess layer is stubbed so no real docker runs; the tests assert the
|
|
4
|
+
escalated argv (the ``sudo -n -u`` / ``su`` prefix) and that no escalation
|
|
5
|
+
happens when run_as is unset.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from runspec_linux_core import containers
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _stub(monkeypatch: pytest.MonkeyPatch, stdout: str = "", rc: int = 0, stderr: str = "") -> list[list[str]]:
|
|
18
|
+
calls: list[list[str]] = []
|
|
19
|
+
|
|
20
|
+
def fake_run(argv, capture_output=None, text=None): # noqa: A002
|
|
21
|
+
calls.append(argv)
|
|
22
|
+
return subprocess.CompletedProcess(argv, rc, stdout=stdout, stderr=stderr)
|
|
23
|
+
|
|
24
|
+
monkeypatch.setattr(containers.subprocess, "run", fake_run)
|
|
25
|
+
monkeypatch.setattr(containers, "_docker_available", lambda: True)
|
|
26
|
+
return calls
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_list_containers_no_run_as(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
30
|
+
calls = _stub(monkeypatch, stdout='abc\timg\t"/bin/sh"\tnow\tUp\tweb\n')
|
|
31
|
+
rows = containers.list_containers()
|
|
32
|
+
assert calls[0][0] == "docker" # no escalation prefix
|
|
33
|
+
assert rows[0]["name"] == "web"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_list_containers_run_as_escalates(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
37
|
+
calls = _stub(monkeypatch, stdout="")
|
|
38
|
+
containers.list_containers(True, run_as="root")
|
|
39
|
+
assert calls[0][:4] == ["sudo", "-n", "-u", "root"]
|
|
40
|
+
assert calls[0][4:7] == ["docker", "ps", "--format"]
|
|
41
|
+
assert "--all" in calls[0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_restart_container_run_as_escalates(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
45
|
+
calls = _stub(monkeypatch)
|
|
46
|
+
out = containers.restart_container("web", run_as="deploy")
|
|
47
|
+
assert calls[0] == ["sudo", "-n", "-u", "deploy", "docker", "restart", "web"]
|
|
48
|
+
assert out == {"container": "web", "restarted": True, "run_as": "deploy"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_container_logs_run_as_su(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
52
|
+
calls = _stub(monkeypatch, stdout="log line 1\nlog line 2\n")
|
|
53
|
+
out = containers.container_logs("web", 10, run_as="svc", become_method="su")
|
|
54
|
+
assert calls[0] == ["su", "svc", "-c", "docker logs --tail 10 web"]
|
|
55
|
+
assert out["count"] == 2
|
|
56
|
+
assert out["run_as"] == "svc"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_container_logs_run_as_passwordless_hint(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
60
|
+
_stub(monkeypatch, rc=1, stderr="sudo: a password is required")
|
|
61
|
+
with pytest.raises(Exception, match="passwordless sudo is not configured for run-as 'svc'"):
|
|
62
|
+
containers.container_logs("web", run_as="svc")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_restart_container_run_as_passwordless_hint(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
66
|
+
_stub(monkeypatch, rc=1, stderr="sudo: a terminal is required")
|
|
67
|
+
with pytest.raises(Exception, match="passwordless sudo is not configured for run-as 'svc'"):
|
|
68
|
+
containers.restart_container("web", run_as="svc")
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
"""Docker container helpers: list, logs, restart."""
|
|
2
|
-
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
|
|
6
|
-
from runspec_linux_core.errors import CommandError, ToolNotFoundError
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _docker_available() -> bool:
|
|
10
|
-
return shutil.which("docker") is not None
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def list_containers(include_all: bool = False) -> list[dict]:
|
|
14
|
-
"""Return Docker containers (running, or all if ``include_all``).
|
|
15
|
-
|
|
16
|
-
Raises ToolNotFoundError if docker is not installed, or CommandError if the
|
|
17
|
-
``docker ps`` command returns a non-zero exit.
|
|
18
|
-
"""
|
|
19
|
-
if not _docker_available():
|
|
20
|
-
raise ToolNotFoundError("docker not installed or not in PATH")
|
|
21
|
-
|
|
22
|
-
cmd = [
|
|
23
|
-
"docker",
|
|
24
|
-
"ps",
|
|
25
|
-
"--format",
|
|
26
|
-
"{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Names}}",
|
|
27
|
-
]
|
|
28
|
-
if include_all:
|
|
29
|
-
cmd.append("--all")
|
|
30
|
-
|
|
31
|
-
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
32
|
-
if result.returncode != 0:
|
|
33
|
-
raise CommandError(result.stderr.strip())
|
|
34
|
-
|
|
35
|
-
rows = []
|
|
36
|
-
for line in result.stdout.strip().splitlines():
|
|
37
|
-
parts = line.split("\t")
|
|
38
|
-
if len(parts) < 6:
|
|
39
|
-
continue
|
|
40
|
-
rows.append(
|
|
41
|
-
{
|
|
42
|
-
"id": parts[0],
|
|
43
|
-
"image": parts[1],
|
|
44
|
-
"command": parts[2].strip('"'),
|
|
45
|
-
"created": parts[3],
|
|
46
|
-
"status": parts[4],
|
|
47
|
-
"name": parts[5],
|
|
48
|
-
}
|
|
49
|
-
)
|
|
50
|
-
return rows
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def container_logs(container: str, lines: int = 50) -> dict:
|
|
54
|
-
"""Return the last ``lines`` log lines for a container.
|
|
55
|
-
|
|
56
|
-
Raises ToolNotFoundError if docker is not installed.
|
|
57
|
-
"""
|
|
58
|
-
if not _docker_available():
|
|
59
|
-
raise ToolNotFoundError("docker not installed or not in PATH")
|
|
60
|
-
|
|
61
|
-
result = subprocess.run(
|
|
62
|
-
["docker", "logs", "--tail", str(lines), container],
|
|
63
|
-
capture_output=True,
|
|
64
|
-
text=True,
|
|
65
|
-
)
|
|
66
|
-
# docker logs writes to stderr by default
|
|
67
|
-
output = result.stderr if result.stderr else result.stdout
|
|
68
|
-
output_lines = output.strip().splitlines()
|
|
69
|
-
return {"container": container, "lines": output_lines, "count": len(output_lines)}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def restart_container(container: str) -> dict:
|
|
73
|
-
"""Restart a container. Returns ``{container, restarted: True}`` on success.
|
|
74
|
-
|
|
75
|
-
Raises ToolNotFoundError if docker is not installed, or CommandError if the
|
|
76
|
-
restart returns a non-zero exit (message is the captured stderr).
|
|
77
|
-
"""
|
|
78
|
-
if not _docker_available():
|
|
79
|
-
raise ToolNotFoundError("docker not installed or not in PATH")
|
|
80
|
-
|
|
81
|
-
result = subprocess.run(
|
|
82
|
-
["docker", "restart", container],
|
|
83
|
-
capture_output=True,
|
|
84
|
-
text=True,
|
|
85
|
-
)
|
|
86
|
-
if result.returncode != 0:
|
|
87
|
-
raise CommandError(result.stderr.strip())
|
|
88
|
-
return {"container": container, "restarted": True}
|
|
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
|