runspec-linux-core 0.6.2__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.2 → runspec_linux_core-0.7.0}/CHANGELOG.md +17 -1
  2. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/__init__.py +2 -1
  5. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/venvs.py +53 -0
  6. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_venvs.py +87 -0
  7. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/.gitignore +0 -0
  8. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/_paths.py +0 -0
  9. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/become.py +0 -0
  10. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/commands.py +0 -0
  11. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/containers.py +0 -0
  12. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/cron.py +0 -0
  13. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/errors.py +0 -0
  14. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/files.py +0 -0
  15. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/investigate.py +0 -0
  16. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/logs.py +0 -0
  17. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/nc.py +0 -0
  18. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/network.py +0 -0
  19. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/packages.py +0 -0
  20. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/perf.py +0 -0
  21. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/power.py +0 -0
  22. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/security.py +0 -0
  23. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/services.py +0 -0
  24. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/sudoers.py +0 -0
  25. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/runspec_linux_core/system.py +0 -0
  26. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/__init__.py +0 -0
  27. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_become.py +0 -0
  28. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_commands.py +0 -0
  29. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_containers_runas.py +0 -0
  30. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_cron.py +0 -0
  31. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_investigate.py +0 -0
  32. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_logs_services_runas.py +0 -0
  33. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_nc_send.py +0 -0
  34. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_network.py +0 -0
  35. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_packages.py +0 -0
  36. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_paths.py +0 -0
  37. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_perf.py +0 -0
  38. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_power.py +0 -0
  39. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_preconditions.py +0 -0
  40. {runspec_linux_core-0.6.2 → runspec_linux_core-0.7.0}/tests/test_sudoers.py +0 -0
@@ -1,6 +1,22 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
- ## [0.6.2] — 2026-09-17
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`.
4
20
 
5
21
  ### Fixed
6
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspec-linux-core
3
- Version: 0.6.2
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.2"
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 = "",
@@ -344,3 +392,8 @@ def configure_pip(
344
392
  def _path_exists_as(path: str, run_as: str | None, become_method: str) -> bool:
345
393
  """True if ``path`` exists from the run-as user's view (``test -e``)."""
346
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