runspec-linux-core 0.6.1__tar.gz → 0.7.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 (40) hide show
  1. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/CHANGELOG.md +33 -0
  2. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/__init__.py +2 -1
  5. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/venvs.py +92 -12
  6. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_venvs.py +113 -4
  7. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/.gitignore +0 -0
  8. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/_paths.py +0 -0
  9. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/become.py +0 -0
  10. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/commands.py +0 -0
  11. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/containers.py +0 -0
  12. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/cron.py +0 -0
  13. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/errors.py +0 -0
  14. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/files.py +0 -0
  15. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/investigate.py +0 -0
  16. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/logs.py +0 -0
  17. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/nc.py +0 -0
  18. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/network.py +0 -0
  19. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/packages.py +0 -0
  20. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/perf.py +0 -0
  21. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/power.py +0 -0
  22. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/security.py +0 -0
  23. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/services.py +0 -0
  24. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/sudoers.py +0 -0
  25. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/runspec_linux_core/system.py +0 -0
  26. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/__init__.py +0 -0
  27. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_become.py +0 -0
  28. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_commands.py +0 -0
  29. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_containers_runas.py +0 -0
  30. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_cron.py +0 -0
  31. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_investigate.py +0 -0
  32. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_logs_services_runas.py +0 -0
  33. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_nc_send.py +0 -0
  34. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_network.py +0 -0
  35. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_packages.py +0 -0
  36. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_paths.py +0 -0
  37. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_perf.py +0 -0
  38. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_power.py +0 -0
  39. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_preconditions.py +0 -0
  40. {runspec_linux_core-0.6.1 → runspec_linux_core-0.7.0}/tests/test_sudoers.py +0 -0
@@ -1,5 +1,38 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.7.0] — 2026-09-25
4
+
5
+ ### Added
6
+
7
+ - **`share_venv_logs(venv, *, group=None, run_as=None, become_method="sudo")`** —
8
+ the repair path for a venv **not** built by `create-venv --group` (hand-made or
9
+ legacy). Sets `<venv>/logs` to `2775` (setgid + group-writable) so new files —
10
+ including the per-venv runs index `runspec_runs.db` and the audit-log files —
11
+ inherit the directory's group and can be written across a shared console group,
12
+ and, with `group`, `chgrp`s the `logs/` **directory** to it. The chgrp is on the
13
+ directory only (not `-R`): `logs/` already holds per-run files owned by *other*
14
+ users (a `run_as="root"` runnable leaves root-owned logs there) which a non-root
15
+ caller can't chgrp, and the setgid bit means only new files need the group. This
16
+ is exactly what `create_venv` does at build time, applied to an existing venv
17
+ without recreating it. Raises `ValueError` for a bad group name or a `<venv>`
18
+ that isn't a directory, `CommandError` on a failed step. Backs the new
19
+ `share-venv-logs` runnable in `runspec-linux`.
20
+
21
+ ### Fixed
22
+
23
+ - **`install_into_venv` regression (0.6.1): `chmod: … Operation not permitted` on
24
+ a `--symlinks` venv's `bin/python*`.** 0.6.1 replaced the `chmod -R` normalise
25
+ sweep with `find … -exec chmod {} +` to prune the `logs/` dir. But `find -exec
26
+ chmod` **dereferences a symlink argument**, and a venv built with `--symlinks`
27
+ (what `create_venv` always does) has `bin/python`, `bin/python3`,
28
+ `bin/python3.X` pointing at the **root-owned system interpreter outside the
29
+ venv** — so the sweep tried to `chmod /usr/bin/python3.X` and failed for a
30
+ non-root caller (or, under sudo, would have silently repermissioned the system
31
+ Python). `chmod -R` skipped symlinks for free; the `find` sweep now prunes them
32
+ explicitly too (`-type l -prune`), matching the old behaviour. A symlink's own
33
+ mode is unused on Linux, so nothing is lost. The `logs/` prune from 0.6.1 is
34
+ unchanged. No API change.
35
+
3
36
  ## [0.6.1] — 2026-09-17
4
37
 
5
38
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspec-linux-core
3
- Version: 0.6.1
3
+ Version: 0.7.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.6.1"
7
+ version = "0.7.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 = []
@@ -63,7 +63,7 @@ from runspec_linux_core.security import last_logins, who
63
63
  from runspec_linux_core.services import check_service, list_services, restart_service
64
64
  from runspec_linux_core.sudoers import enable_passwordless_sudo
65
65
  from runspec_linux_core.system import check_memory, disk_usage, list_processes, system_info
66
- from runspec_linux_core.venvs import configure_pip, create_venv, install_into_venv
66
+ from runspec_linux_core.venvs import configure_pip, create_venv, install_into_venv, share_venv_logs
67
67
 
68
68
  __all__ = [
69
69
  # errors
@@ -139,6 +139,7 @@ __all__ = [
139
139
  "create_venv",
140
140
  "install_into_venv",
141
141
  "configure_pip",
142
+ "share_venv_logs",
142
143
  # tcp
143
144
  "nc_send",
144
145
  # commands
@@ -115,6 +115,54 @@ def create_venv(
115
115
  }
116
116
 
117
117
 
118
+ def share_venv_logs(
119
+ venv: str,
120
+ *,
121
+ group: str | None = None,
122
+ run_as: str | None = None,
123
+ become_method: str = "sudo",
124
+ ) -> dict:
125
+ """Make an existing venv's ``logs/`` dir shared — the repair path for a venv
126
+ **not** built by ``create-venv --group`` (hand-made or legacy).
127
+
128
+ Sets ``<venv>/logs`` to ``2775`` (setgid + group-writable) so new files —
129
+ including the per-venv runs index ``runspec_runs.db`` and the audit-log
130
+ files — inherit the directory's group and can be written by every console /
131
+ service account in a shared group. When ``group`` is given it also
132
+ ``chgrp``s the ``logs/`` **directory** to it.
133
+
134
+ The chgrp is on the directory only, **not** ``-R``: ``logs/`` already holds
135
+ per-run files owned by *other* users (a ``run_as="root"`` runnable leaves
136
+ root-owned logs there), which a non-root caller can't chgrp — and the setgid
137
+ bit means only *new* files need the group anyway (the DB is created fresh).
138
+ ``create-venv --group`` does exactly this at build time; this brings a
139
+ pre-existing venv up to the same state without recreating it. With
140
+ ``run_as`` every step runs as that user (the venv's owner).
141
+
142
+ Raises ValueError if ``group`` is malformed or ``<venv>`` isn't an existing
143
+ directory, CommandError on a failed step.
144
+ """
145
+ if group is not None:
146
+ _validate_name(group, "group")
147
+ if not _dir_exists_as(venv, run_as, become_method):
148
+ raise ValueError(f"not a directory: {venv!r} (point at an existing venv)")
149
+
150
+ logs_dir = os.path.join(venv, "logs")
151
+ _check(_run(build_become(["mkdir", "-p", logs_dir], run_as, become_method)), "logs dir", run_as)
152
+ if group:
153
+ _check(_run(build_become(["chgrp", group, logs_dir], run_as, become_method)), "chgrp logs", run_as)
154
+ _check(_run(build_become(["chmod", "2775", logs_dir], run_as, become_method)), "chmod logs", run_as)
155
+
156
+ return {
157
+ "venv": venv,
158
+ "logs_dir": logs_dir,
159
+ "group": group or "",
160
+ "run_as": run_as or "",
161
+ "mode": "2775",
162
+ "shared": True,
163
+ }
164
+
165
+
118
166
  def install_into_venv(
119
167
  venv: str,
120
168
  packages: str = "",
@@ -140,11 +188,15 @@ def install_into_venv(
140
188
  regardless of the caller's umask — a ``sudo`` upgrade under a PAM-set ``027``
141
189
  login umask otherwise lands site-packages entries at ``750`` and hides the
142
190
  packs from ``runspec local`` / ``importlib.metadata`` for anyone but the
143
- owner. The sweep **prunes** ``<venv>/logs``: it is the group-shared audit dir
144
- and holds per-run log files owned by other users (a ``run_as="root"`` runnable
145
- leaves root-owned logs there), so a blanket ``chmod`` as a non-root caller
146
- would fail with "Operation not permitted". Its ``2775`` setgid mode is left
147
- intact, and restored afterwards as a guard if the dir exists.
191
+ owner. The sweep **prunes** two things: ``<venv>/logs`` (the group-shared
192
+ audit dir, which holds per-run log files owned by other users — a
193
+ ``run_as="root"`` runnable leaves root-owned logs there — so its ``2775``
194
+ setgid mode is left intact and restored afterwards as a guard), and
195
+ **symlinks** (a ``--symlinks`` venv's ``bin/python*`` point at the root-owned
196
+ system interpreter *outside* the venv; ``find -exec chmod`` would dereference
197
+ them and fail — or, under sudo, silently repermission the system Python —
198
+ where ``chmod -R`` skipped symlinks for free). A symlink's own mode is unused
199
+ on Linux, so pruning them changes nothing.
148
200
  """
149
201
  from runspec_linux_core.packages import _validate_packages
150
202
 
@@ -179,17 +231,40 @@ def install_into_venv(
179
231
  # Normalise pip's freshly-created site-packages entries to u=rwX,go=rX (the
180
232
  # same step create_venv runs) so a sudo install under a restrictive login
181
233
  # umask can't land them at 750 and hide the packs from `runspec local` /
182
- # importlib.metadata. Prune <venv>/logs from the sweep: it is the group-shared
183
- # (2775) audit dir and accrues per-run log files owned by *other* users — a
184
- # run_as="root" runnable (list-upgrades / upgrade-packages) leaves root-owned
185
- # logs there, so a blanket `chmod -R` as a non-root caller dies with
186
- # "Operation not permitted" on them. `find ... -prune` skips that subtree; its
187
- # own 2775 mode is left intact (and restored below as a belt-and-braces guard).
234
+ # importlib.metadata. Two subtrees are pruned:
235
+ # * <venv>/logs — the group-shared (2775) audit dir, which accrues per-run
236
+ # log files owned by *other* users (a run_as="root" runnable such as
237
+ # list-upgrades / upgrade-packages leaves root-owned logs there), so a
238
+ # non-root caller's chmod on them dies with "Operation not permitted".
239
+ # * symlinks (-type l) — a `--symlinks` venv's bin/python* point at the
240
+ # system interpreter (root-owned, *outside* the venv). `find -exec chmod`
241
+ # dereferences a symlink argument, so without this it would chmod the
242
+ # system python: "Operation not permitted" as a normal user, or a silent
243
+ # repermission of /usr/bin/python* under sudo. `chmod -R` skipped symlinks
244
+ # for free; the find sweep must skip them explicitly to match. A symlink's
245
+ # own mode is irrelevant on Linux, so nothing is lost.
246
+ # The logs dir keeps its 2775 mode (restored below as a belt-and-braces guard).
188
247
  logs_dir = os.path.join(venv, "logs")
189
248
  _check(
190
249
  _run(
191
250
  build_become(
192
- ["find", venv, "-path", logs_dir, "-prune", "-o", "-exec", "chmod", "u=rwX,go=rX", "{}", "+"],
251
+ [
252
+ "find",
253
+ venv,
254
+ "-path",
255
+ logs_dir,
256
+ "-prune",
257
+ "-o",
258
+ "-type",
259
+ "l",
260
+ "-prune",
261
+ "-o",
262
+ "-exec",
263
+ "chmod",
264
+ "u=rwX,go=rX",
265
+ "{}",
266
+ "+",
267
+ ],
193
268
  run_as,
194
269
  become_method,
195
270
  )
@@ -317,3 +392,8 @@ def configure_pip(
317
392
  def _path_exists_as(path: str, run_as: str | None, become_method: str) -> bool:
318
393
  """True if ``path`` exists from the run-as user's view (``test -e``)."""
319
394
  return _run(build_become(["test", "-e", path], run_as, become_method)).returncode == 0
395
+
396
+
397
+ def _dir_exists_as(path: str, run_as: str | None, become_method: str) -> bool:
398
+ """True if ``path`` is a directory from the run-as user's view (``test -d``)."""
399
+ return _run(build_become(["test", "-d", path], run_as, become_method)).returncode == 0
@@ -101,6 +101,93 @@ def test_create_venv_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
101
101
  venvs.create_venv(sys.executable, "/opt/v1")
102
102
 
103
103
 
104
+ # ── share_venv_logs (repair a legacy venv's logs/ perms) ───────────────────────
105
+
106
+
107
+ def test_share_venv_logs_builds_commands(monkeypatch: pytest.MonkeyPatch) -> None:
108
+ calls = _record(monkeypatch) # default stub: `test -d` returns 0
109
+
110
+ result = venvs.share_venv_logs("/opt/v1")
111
+
112
+ # Verify the venv is a dir, then mkdir -p logs, then chmod 2775 logs. No chgrp
113
+ # without a group; the chmod is the last step.
114
+ assert calls == [
115
+ ["test", "-d", "/opt/v1"],
116
+ ["mkdir", "-p", "/opt/v1/logs"],
117
+ ["chmod", "2775", "/opt/v1/logs"],
118
+ ]
119
+ assert result == {
120
+ "venv": "/opt/v1",
121
+ "logs_dir": "/opt/v1/logs",
122
+ "group": "",
123
+ "run_as": "",
124
+ "mode": "2775",
125
+ "shared": True,
126
+ }
127
+
128
+
129
+ def test_share_venv_logs_with_group_chgrps_dir_only(monkeypatch: pytest.MonkeyPatch) -> None:
130
+ calls = _record(monkeypatch)
131
+
132
+ venvs.share_venv_logs("/opt/v1", group="consoles")
133
+
134
+ # chgrp targets the logs *directory* only — never `-R` (logs/ holds per-run
135
+ # files owned by other users a non-root caller can't chgrp; setgid covers new
136
+ # files). Order: test -d, mkdir, chgrp, chmod.
137
+ assert calls == [
138
+ ["test", "-d", "/opt/v1"],
139
+ ["mkdir", "-p", "/opt/v1/logs"],
140
+ ["chgrp", "consoles", "/opt/v1/logs"],
141
+ ["chmod", "2775", "/opt/v1/logs"],
142
+ ]
143
+ assert not any(c[:2] == ["chgrp", "-R"] for c in calls)
144
+
145
+
146
+ def test_share_venv_logs_run_as_escalates_every_step(monkeypatch: pytest.MonkeyPatch) -> None:
147
+ calls = _record(monkeypatch)
148
+
149
+ venvs.share_venv_logs("/opt/v1", group="consoles", run_as="svc")
150
+
151
+ for cmd in calls:
152
+ assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
153
+ assert ["sudo", "-n", "-u", "svc", "chgrp", "consoles", "/opt/v1/logs"] in calls
154
+ assert ["sudo", "-n", "-u", "svc", "chmod", "2775", "/opt/v1/logs"] in calls
155
+
156
+
157
+ def test_share_venv_logs_rejects_missing_venv(monkeypatch: pytest.MonkeyPatch) -> None:
158
+ calls: list[list[str]] = []
159
+
160
+ def fake_run(argv: list[str]) -> subprocess.CompletedProcess:
161
+ calls.append(argv)
162
+ return _completed(returncode=1) # `test -d` fails → not a directory
163
+
164
+ monkeypatch.setattr(venvs, "_run", fake_run)
165
+
166
+ with pytest.raises(ValueError):
167
+ venvs.share_venv_logs("/opt/nope")
168
+ # Bailed after the probe — no mkdir/chmod attempted.
169
+ assert calls == [["test", "-d", "/opt/nope"]]
170
+
171
+
172
+ def test_share_venv_logs_rejects_bad_group(monkeypatch: pytest.MonkeyPatch) -> None:
173
+ calls = _record(monkeypatch)
174
+ with pytest.raises(ValueError):
175
+ venvs.share_venv_logs("/opt/v1", group="bad group")
176
+ # Group is validated before any subprocess runs (not even the `test -d` probe).
177
+ assert calls == []
178
+
179
+
180
+ def test_share_venv_logs_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
181
+ def fake_run(argv: list[str]) -> subprocess.CompletedProcess:
182
+ if argv[:2] == ["test", "-d"]:
183
+ return _completed(returncode=0) # dir exists…
184
+ return _completed("", returncode=1, stderr="boom") # …but a step fails
185
+
186
+ monkeypatch.setattr(venvs, "_run", fake_run)
187
+ with pytest.raises(CommandError):
188
+ venvs.share_venv_logs("/opt/v1")
189
+
190
+
104
191
  # ── install_into_venv ─────────────────────────────────────────────────────────
105
192
 
106
193
 
@@ -148,9 +235,11 @@ def test_install_into_venv_normalises_perms(tmp_path, monkeypatch: pytest.Monkey
148
235
 
149
236
  venvs.install_into_venv(venv, "httpx", upgrade=True, run_as="svc")
150
237
 
151
- # The normalise sweep prunes <venv>/logs so a non-root caller never chmods the
152
- # root-owned per-run log files a run_as="root" runnable leaves there
153
- # (otherwise: chmod "Operation not permitted", and the install reports failure).
238
+ # The normalise sweep prunes <venv>/logs (root-owned per-run logs a
239
+ # run_as="root" runnable leaves there) AND symlinks (a --symlinks venv's
240
+ # bin/python* point at the root-owned system interpreter; find -exec chmod
241
+ # would dereference and fail / repermission it). Otherwise: chmod "Operation
242
+ # not permitted", and the install reports failure.
154
243
  assert [
155
244
  "sudo",
156
245
  "-n",
@@ -162,6 +251,10 @@ def test_install_into_venv_normalises_perms(tmp_path, monkeypatch: pytest.Monkey
162
251
  f"{venv}/logs",
163
252
  "-prune",
164
253
  "-o",
254
+ "-type",
255
+ "l",
256
+ "-prune",
257
+ "-o",
165
258
  "-exec",
166
259
  "chmod",
167
260
  "u=rwX,go=rX",
@@ -198,7 +291,23 @@ def test_install_into_venv_skips_logs_restore_when_absent(tmp_path, monkeypatch:
198
291
 
199
292
  venvs.install_into_venv(venv, "httpx")
200
293
 
201
- assert ["find", venv, "-path", f"{venv}/logs", "-prune", "-o", "-exec", "chmod", "u=rwX,go=rX", "{}", "+"] in calls
294
+ assert [
295
+ "find",
296
+ venv,
297
+ "-path",
298
+ f"{venv}/logs",
299
+ "-prune",
300
+ "-o",
301
+ "-type",
302
+ "l",
303
+ "-prune",
304
+ "-o",
305
+ "-exec",
306
+ "chmod",
307
+ "u=rwX,go=rX",
308
+ "{}",
309
+ "+",
310
+ ] in calls
202
311
  assert ["test", "-d", f"{venv}/logs"] in calls
203
312
  assert not any(c[:2] == ["chmod", "2775"] for c in calls)
204
313