runspec-linux-core 0.3.2__tar.gz → 0.3.4__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.3.2 → runspec_linux_core-0.3.4}/CHANGELOG.md +32 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/PKG-INFO +2 -2
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/pyproject.toml +2 -2
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/__init__.py +3 -0
- runspec_linux_core-0.3.4/runspec_linux_core/commands.py +76 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/venvs.py +13 -6
- runspec_linux_core-0.3.4/tests/test_commands.py +88 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_venvs.py +7 -3
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/.gitignore +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/_paths.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/containers.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/files.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/logs.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/network.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/power.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/services.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/sudoers.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/runspec_linux_core/system.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/__init__.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_network.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_packages.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_paths.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_power.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_preconditions.py +0 -0
- {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.4}/tests/test_sudoers.py +0 -0
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# runspec-linux-core Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.4] — 2026-07-07
|
|
4
|
+
|
|
5
|
+
Fix `create_venv` producing an unreadable `750` venv directory when the caller's
|
|
6
|
+
umask is `027` (typical under `sudo -n -u <svc>` with a PAM-set login umask).
|
|
7
|
+
`python -m venv` honours the current umask, so a shared service-account venv
|
|
8
|
+
was landing owner-only instead of the `755` a stock `python -m venv <dest>`
|
|
9
|
+
produces under the default `022` umask, locking every non-owner console out —
|
|
10
|
+
contrary to the "shared between several consoles" use case the helper is built
|
|
11
|
+
for.
|
|
12
|
+
|
|
13
|
+
After the venv-creation step, `create_venv` now runs
|
|
14
|
+
`chmod -R u=rwX,go=rX <dest>` to normalise the tree to `755` on directories and
|
|
15
|
+
`644` on files while preserving the `+x` on real executables in `bin/` (capital
|
|
16
|
+
`X` only sets execute on directories and files that already have some execute
|
|
17
|
+
bit). The subsequent `chmod 2775 <dest>/logs` still gets the last word on the
|
|
18
|
+
logs directory, and `chgrp -R <group>` (when passed) still runs. When `run_as`
|
|
19
|
+
is set the new chmod runs as that user too.
|
|
20
|
+
|
|
21
|
+
## [0.3.3] — 2026-06-22
|
|
22
|
+
|
|
23
|
+
Add `commands.py` — the pure logic behind the new `runspec-linux` `which`
|
|
24
|
+
runnable:
|
|
25
|
+
|
|
26
|
+
- **`which(names, *, all_matches=False, path=None)`** — locate executables on the
|
|
27
|
+
search PATH, like the `which` command. `names` is a single command or a list
|
|
28
|
+
(a string is split on whitespace, so `"python3 pip3"` resolves both).
|
|
29
|
+
`all_matches` returns every match across the PATH (not just the first that
|
|
30
|
+
would run); `path` overrides the search path (defaults to `$PATH`, falling back
|
|
31
|
+
to `os.defpath`). A name containing a path separator is checked directly rather
|
|
32
|
+
than searched. Returns `{path, all_found, results: [{name, found, path,
|
|
33
|
+
paths}]}` and raises `ValueError` when no name is given.
|
|
34
|
+
|
|
3
35
|
## [0.3.2] — 2026-06-21
|
|
4
36
|
|
|
5
37
|
Add `venvs.py` — the pure logic behind three new `runspec-linux` runnables for
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runspec-linux-core
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
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
|
|
7
7
|
Requires-Dist: mypy; extra == 'dev'
|
|
8
8
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
9
|
-
Requires-Dist: ruff; extra == 'dev'
|
|
9
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-linux-core"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
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 = []
|
|
11
11
|
|
|
12
12
|
[project.optional-dependencies]
|
|
13
13
|
dev = [
|
|
14
|
-
"ruff",
|
|
14
|
+
"ruff==0.15.20",
|
|
15
15
|
"mypy",
|
|
16
16
|
"pytest>=8.0",
|
|
17
17
|
]
|
|
@@ -9,6 +9,7 @@ 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.commands import which
|
|
12
13
|
from runspec_linux_core.containers import (
|
|
13
14
|
container_logs,
|
|
14
15
|
list_containers,
|
|
@@ -83,4 +84,6 @@ __all__ = [
|
|
|
83
84
|
"configure_pip",
|
|
84
85
|
# tcp
|
|
85
86
|
"nc_send",
|
|
87
|
+
# commands
|
|
88
|
+
"which",
|
|
86
89
|
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command-location helpers — find executables on the search PATH."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _is_executable(path: str) -> bool:
|
|
7
|
+
return os.path.isfile(path) and os.access(path, os.X_OK)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve(name: str, dirs: list[str]) -> list[str]:
|
|
11
|
+
"""Return every executable match for ``name`` across ``dirs`` (in order)."""
|
|
12
|
+
# A name with a path separator is a path, not a bare command: check it
|
|
13
|
+
# directly rather than searching the PATH (mirrors `which`/`shutil.which`).
|
|
14
|
+
if os.sep in name or (os.altsep and os.altsep in name):
|
|
15
|
+
return [os.path.abspath(name)] if _is_executable(name) else []
|
|
16
|
+
seen: set[str] = set()
|
|
17
|
+
matches: list[str] = []
|
|
18
|
+
for d in dirs:
|
|
19
|
+
full = os.path.join(d, name)
|
|
20
|
+
if full in seen:
|
|
21
|
+
continue
|
|
22
|
+
seen.add(full)
|
|
23
|
+
if _is_executable(full):
|
|
24
|
+
matches.append(full)
|
|
25
|
+
return matches
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def which(
|
|
29
|
+
names: str | list[str],
|
|
30
|
+
*,
|
|
31
|
+
all_matches: bool = False,
|
|
32
|
+
path: str | None = None,
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Locate executables on the search PATH, like the ``which`` command.
|
|
35
|
+
|
|
36
|
+
``names`` is a single command name or a list of them; a string is split on
|
|
37
|
+
whitespace so ``"python3 pip3"`` resolves both. ``all_matches`` returns every
|
|
38
|
+
match found across the PATH (not just the first one that would run). ``path``
|
|
39
|
+
overrides the search path (defaults to ``$PATH``, falling back to
|
|
40
|
+
``os.defpath``).
|
|
41
|
+
|
|
42
|
+
Returns::
|
|
43
|
+
|
|
44
|
+
{"path": "<search path used>",
|
|
45
|
+
"all_found": bool, # every requested name resolved
|
|
46
|
+
"results": [{"name": str, "found": bool,
|
|
47
|
+
"path": str | None, # first match, else None
|
|
48
|
+
"paths": [str, ...]}]} # all matches if all_matches
|
|
49
|
+
|
|
50
|
+
Raises ``ValueError`` when no name is given.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(names, str):
|
|
53
|
+
names = names.split()
|
|
54
|
+
names = [n for n in names if n]
|
|
55
|
+
if not names:
|
|
56
|
+
raise ValueError("no command name given")
|
|
57
|
+
|
|
58
|
+
search = os.environ.get("PATH", os.defpath) if path is None else path
|
|
59
|
+
dirs = [d or os.curdir for d in search.split(os.pathsep)]
|
|
60
|
+
|
|
61
|
+
results = []
|
|
62
|
+
for name in names:
|
|
63
|
+
matches = _resolve(name, dirs)
|
|
64
|
+
results.append(
|
|
65
|
+
{
|
|
66
|
+
"name": name,
|
|
67
|
+
"found": bool(matches),
|
|
68
|
+
"path": matches[0] if matches else None,
|
|
69
|
+
"paths": matches if all_matches else matches[:1],
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
return {
|
|
73
|
+
"path": search,
|
|
74
|
+
"all_found": all(r["found"] for r in results),
|
|
75
|
+
"results": results,
|
|
76
|
+
}
|
|
@@ -92,12 +92,17 @@ def create_venv(
|
|
|
92
92
|
) -> dict:
|
|
93
93
|
"""Create a virtualenv at ``dest`` from a specific ``python`` interpreter.
|
|
94
94
|
|
|
95
|
-
Always uses ``--symlinks``. Then
|
|
96
|
-
|
|
97
|
-
``
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
Always uses ``--symlinks``. Then normalises the tree to ``u=rwX,go=rX`` so
|
|
96
|
+
directories are ``755`` and executables in ``bin/`` keep their ``+x`` — matching
|
|
97
|
+
what a plain ``python -m venv`` produces under a default ``022`` umask, regardless
|
|
98
|
+
of the caller's umask (``sudo -n -u <svc>`` often lands at ``027``, which would
|
|
99
|
+
otherwise leave the venv at ``750`` and lock other consoles out). Then creates
|
|
100
|
+
``<dest>/logs`` mode ``2775`` (setgid, so new audit-log files inherit the
|
|
101
|
+
directory's group) and, if ``group`` is set, ``chgrp -R`` the venv to it —
|
|
102
|
+
enabling a shared service-account venv that several consoles read/write. With
|
|
103
|
+
``run_as`` set every step runs as that user (so the venv is owned by it). Raises
|
|
104
|
+
ValueError on a non-executable interpreter / bad name, or CommandError on a
|
|
105
|
+
failed step.
|
|
101
106
|
"""
|
|
102
107
|
if not (os.path.isfile(python) and os.access(python, os.X_OK)):
|
|
103
108
|
raise ValueError(f"not an executable file: {python!r} (pick a real interpreter, not the system default)")
|
|
@@ -114,6 +119,8 @@ def create_venv(
|
|
|
114
119
|
|
|
115
120
|
_check(_run(_become([python, "-m", "venv", *flags, dest], run_as, become_method)), "venv creation", run_as)
|
|
116
121
|
|
|
122
|
+
_check(_run(_become(["chmod", "-R", "u=rwX,go=rX", dest], run_as, become_method)), "chmod venv", run_as)
|
|
123
|
+
|
|
117
124
|
logs_dir = os.path.join(dest, "logs")
|
|
118
125
|
_check(_run(_become(["mkdir", "-p", logs_dir], run_as, become_method)), "logs dir", run_as)
|
|
119
126
|
if group:
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from runspec_linux_core import which
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_exec(path):
|
|
10
|
+
path.write_text("#!/bin/sh\n")
|
|
11
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_which_finds_executable(tmp_path):
|
|
15
|
+
binp = tmp_path / "mytool"
|
|
16
|
+
_make_exec(binp)
|
|
17
|
+
res = which("mytool", path=str(tmp_path))
|
|
18
|
+
assert res["all_found"] is True
|
|
19
|
+
assert res["path"] == str(tmp_path)
|
|
20
|
+
(r,) = res["results"]
|
|
21
|
+
assert r == {
|
|
22
|
+
"name": "mytool",
|
|
23
|
+
"found": True,
|
|
24
|
+
"path": str(binp),
|
|
25
|
+
"paths": [str(binp)],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_which_not_found(tmp_path):
|
|
30
|
+
res = which("nope", path=str(tmp_path))
|
|
31
|
+
assert res["all_found"] is False
|
|
32
|
+
assert res["results"][0] == {
|
|
33
|
+
"name": "nope",
|
|
34
|
+
"found": False,
|
|
35
|
+
"path": None,
|
|
36
|
+
"paths": [],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_which_skips_non_executable(tmp_path):
|
|
41
|
+
(tmp_path / "plain").write_text("data")
|
|
42
|
+
res = which("plain", path=str(tmp_path))
|
|
43
|
+
assert res["results"][0]["found"] is False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_which_multiple_names_space_separated(tmp_path):
|
|
47
|
+
_make_exec(tmp_path / "a")
|
|
48
|
+
res = which("a b", path=str(tmp_path))
|
|
49
|
+
assert [r["name"] for r in res["results"]] == ["a", "b"]
|
|
50
|
+
assert res["results"][0]["found"] is True
|
|
51
|
+
assert res["results"][1]["found"] is False
|
|
52
|
+
assert res["all_found"] is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_which_accepts_list(tmp_path):
|
|
56
|
+
_make_exec(tmp_path / "a")
|
|
57
|
+
res = which(["a"], path=str(tmp_path))
|
|
58
|
+
assert res["all_found"] is True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_which_first_match_wins_all_matches_lists_every_dir(tmp_path):
|
|
62
|
+
d1 = tmp_path / "d1"
|
|
63
|
+
d2 = tmp_path / "d2"
|
|
64
|
+
d1.mkdir()
|
|
65
|
+
d2.mkdir()
|
|
66
|
+
_make_exec(d1 / "dup")
|
|
67
|
+
_make_exec(d2 / "dup")
|
|
68
|
+
search = os.pathsep.join([str(d1), str(d2)])
|
|
69
|
+
|
|
70
|
+
first = which("dup", path=search)
|
|
71
|
+
assert first["results"][0]["path"] == str(d1 / "dup")
|
|
72
|
+
assert first["results"][0]["paths"] == [str(d1 / "dup")]
|
|
73
|
+
|
|
74
|
+
allm = which("dup", all_matches=True, path=search)
|
|
75
|
+
assert allm["results"][0]["paths"] == [str(d1 / "dup"), str(d2 / "dup")]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_which_name_with_path_separator_checked_directly(tmp_path):
|
|
79
|
+
binp = tmp_path / "tool"
|
|
80
|
+
_make_exec(binp)
|
|
81
|
+
res = which(str(binp))
|
|
82
|
+
assert res["results"][0]["found"] is True
|
|
83
|
+
assert res["results"][0]["path"] == str(binp)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_which_empty_raises():
|
|
87
|
+
with pytest.raises(ValueError):
|
|
88
|
+
which(" ")
|
|
@@ -69,9 +69,12 @@ def test_create_venv_uses_symlinks_and_logs(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
69
69
|
venv_cmd = calls[0]
|
|
70
70
|
assert venv_cmd[:4] == [sys.executable, "-m", "venv", "--symlinks"]
|
|
71
71
|
assert venv_cmd[-1] == "/opt/v1"
|
|
72
|
+
# chmod -R u=rwX,go=rX normalises the tree to 755-dirs/644-files regardless of
|
|
73
|
+
# the caller's umask (a sudo -u <svc> shell often lands at 027 → 750).
|
|
74
|
+
assert calls[1] == ["chmod", "-R", "u=rwX,go=rX", "/opt/v1"]
|
|
72
75
|
# mkdir -p logs, then chmod 2775 logs (no group → no chgrp step).
|
|
73
|
-
assert calls[
|
|
74
|
-
assert calls[
|
|
76
|
+
assert calls[2] == ["mkdir", "-p", "/opt/v1/logs"]
|
|
77
|
+
assert calls[3] == ["chmod", "2775", "/opt/v1/logs"]
|
|
75
78
|
assert result == {
|
|
76
79
|
"python": sys.executable,
|
|
77
80
|
"dest": "/opt/v1",
|
|
@@ -100,7 +103,8 @@ def test_create_venv_run_as_escalates_every_step(monkeypatch: pytest.MonkeyPatch
|
|
|
100
103
|
venvs.create_venv(sys.executable, "/opt/v1", run_as="svc", group="consoles")
|
|
101
104
|
for cmd in calls:
|
|
102
105
|
assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
|
|
103
|
-
#
|
|
106
|
+
# The umask-normalising chmod and the chgrp both run as svc too.
|
|
107
|
+
assert ["sudo", "-n", "-u", "svc", "chmod", "-R", "u=rwX,go=rX", "/opt/v1"] in calls
|
|
104
108
|
assert ["sudo", "-n", "-u", "svc", "chgrp", "-R", "consoles", "/opt/v1"] in calls
|
|
105
109
|
|
|
106
110
|
|
|
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
|