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.
Files changed (39) hide show
  1. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/CHANGELOG.md +12 -0
  2. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/pyproject.toml +1 -1
  4. runspec_linux_core-0.5.0/runspec_linux_core/containers.py +102 -0
  5. runspec_linux_core-0.5.0/tests/test_containers_runas.py +68 -0
  6. runspec_linux_core-0.4.0/runspec_linux_core/containers.py +0 -88
  7. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/.gitignore +0 -0
  8. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/__init__.py +0 -0
  9. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/_paths.py +0 -0
  10. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/become.py +0 -0
  11. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/commands.py +0 -0
  12. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/errors.py +0 -0
  13. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/files.py +0 -0
  14. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/investigate.py +0 -0
  15. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/logs.py +0 -0
  16. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/nc.py +0 -0
  17. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/network.py +0 -0
  18. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/packages.py +0 -0
  19. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/perf.py +0 -0
  20. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/power.py +0 -0
  21. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/security.py +0 -0
  22. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/services.py +0 -0
  23. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/sudoers.py +0 -0
  24. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/system.py +0 -0
  25. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/runspec_linux_core/venvs.py +0 -0
  26. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/__init__.py +0 -0
  27. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_become.py +0 -0
  28. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_commands.py +0 -0
  29. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_investigate.py +0 -0
  30. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_logs_services_runas.py +0 -0
  31. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_nc_send.py +0 -0
  32. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_network.py +0 -0
  33. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_packages.py +0 -0
  34. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_paths.py +0 -0
  35. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_perf.py +0 -0
  36. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_power.py +0 -0
  37. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_preconditions.py +0 -0
  38. {runspec_linux_core-0.4.0 → runspec_linux_core-0.5.0}/tests/test_sudoers.py +0 -0
  39. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux-core
3
- Version: 0.4.0
3
+ Version: 0.5.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.4.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}