runspec-linux-core 0.3.0__tar.gz → 0.3.2__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.0 → runspec_linux_core-0.3.2}/CHANGELOG.md +41 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/PKG-INFO +1 -1
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/pyproject.toml +1 -1
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/__init__.py +5 -0
- runspec_linux_core-0.3.2/runspec_linux_core/_paths.py +33 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/power.py +11 -7
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/sudoers.py +14 -10
- runspec_linux_core-0.3.2/runspec_linux_core/venvs.py +299 -0
- runspec_linux_core-0.3.2/tests/test_paths.py +24 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/tests/test_power.py +4 -4
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/tests/test_preconditions.py +2 -2
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/tests/test_sudoers.py +3 -2
- runspec_linux_core-0.3.2/tests/test_venvs.py +249 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/.gitignore +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/containers.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/files.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/logs.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/network.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/services.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/runspec_linux_core/system.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/tests/__init__.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/tests/test_network.py +0 -0
- {runspec_linux_core-0.3.0 → runspec_linux_core-0.3.2}/tests/test_packages.py +0 -0
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# runspec-linux-core Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.2] — 2026-06-21
|
|
4
|
+
|
|
5
|
+
Add `venvs.py` — the pure logic behind three new `runspec-linux` runnables for
|
|
6
|
+
Python virtualenv management:
|
|
7
|
+
|
|
8
|
+
- **`create_venv(python, dest, *, run_as=None, become_method="sudo", group=None,
|
|
9
|
+
system_site_packages=False, upgrade_deps=False, clear=False, prompt=None)`** —
|
|
10
|
+
build a venv at `dest` from a *specific* interpreter (no system default
|
|
11
|
+
fallback; `python` must be an executable file), always with `--symlinks`. Then
|
|
12
|
+
creates `<dest>/logs` mode `2775` (setgid, so audit logs inherit the group) and
|
|
13
|
+
optionally `chgrp -R`s the venv to a shared `group` — the shape a service
|
|
14
|
+
account needs for a venv shared between several consoles.
|
|
15
|
+
- **`install_into_venv(venv, packages="", *, project=None, editable=False,
|
|
16
|
+
upgrade=False, no_cache_dir=False, force_reinstall=False, run_as=None,
|
|
17
|
+
become_method="sudo")`** — `pip install` named packages and/or a local `project`
|
|
18
|
+
(a `pyproject.toml` directory or a wheel/sdist; `editable` for `-e`) into the
|
|
19
|
+
venv's bundled python. Package names reuse the shared injection guard.
|
|
20
|
+
- **`configure_pip(index_url, *, extra_index_url=None, trusted_host=None,
|
|
21
|
+
cert=None, timeout=None, retries=None, proxy=None, require_virtualenv=False,
|
|
22
|
+
scope="user", venv=None, run_as=None, become_method="sudo")`** — write a
|
|
23
|
+
validated `pip.conf` (`[global]`) at user (`~/.config/pip/pip.conf`) or venv
|
|
24
|
+
(`<venv>/pip.conf`) scope for private-PyPI access.
|
|
25
|
+
|
|
26
|
+
All three take a **runtime** `run_as` user (the service account is chosen
|
|
27
|
+
per-invocation, so escalation is internal — `sudo -n -u <user>` or, via
|
|
28
|
+
`become_method`, `su` — not a declarative `run_as`) and raise `ValueError` on bad
|
|
29
|
+
input / `CommandError` on a failed step.
|
|
30
|
+
|
|
31
|
+
## [0.3.1] — 2026-06-20
|
|
32
|
+
|
|
33
|
+
Fix `enable_passwordless_sudo` failing with "visudo not installed" (and the same
|
|
34
|
+
latent bug in `reboot_host`'s `shutdown` lookup). The console runs runnables over
|
|
35
|
+
a non-login, non-interactive SSH shell whose `PATH` usually omits `/usr/sbin` and
|
|
36
|
+
`/sbin`, so `shutil.which()` couldn't find these sbin-resident admin tools even
|
|
37
|
+
when installed.
|
|
38
|
+
|
|
39
|
+
New `_paths.which()` searches `PATH` first, then the standard sbin/bin dirs
|
|
40
|
+
(`/usr/local/sbin`, `/usr/sbin`, `/sbin`, `/usr/local/bin`, `/usr/bin`, `/bin`),
|
|
41
|
+
and returns an absolute path. `sudoers.py` and `power.py` resolve and invoke
|
|
42
|
+
`visudo` / `shutdown` / `systemctl` through it, so they work regardless of `PATH`.
|
|
43
|
+
|
|
3
44
|
## [0.3.0] — 2026-06-20
|
|
4
45
|
|
|
5
46
|
Add `enable_passwordless_sudo()` (new `sudoers.py`) — the one-time bootstrap that
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-linux-core"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
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 = []
|
|
@@ -32,6 +32,7 @@ from runspec_linux_core.security import last_logins, who
|
|
|
32
32
|
from runspec_linux_core.services import check_service, list_services, restart_service
|
|
33
33
|
from runspec_linux_core.sudoers import enable_passwordless_sudo
|
|
34
34
|
from runspec_linux_core.system import check_memory, disk_usage, list_processes, system_info
|
|
35
|
+
from runspec_linux_core.venvs import configure_pip, create_venv, install_into_venv
|
|
35
36
|
|
|
36
37
|
__all__ = [
|
|
37
38
|
# errors
|
|
@@ -76,6 +77,10 @@ __all__ = [
|
|
|
76
77
|
"reboot_host",
|
|
77
78
|
# sudoers
|
|
78
79
|
"enable_passwordless_sudo",
|
|
80
|
+
# venvs
|
|
81
|
+
"create_venv",
|
|
82
|
+
"install_into_venv",
|
|
83
|
+
"configure_pip",
|
|
79
84
|
# tcp
|
|
80
85
|
"nc_send",
|
|
81
86
|
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Resolve system tools that may live in sbin dirs not on a non-login PATH.
|
|
2
|
+
|
|
3
|
+
The console runs runnables over a non-login, non-interactive SSH shell whose
|
|
4
|
+
``PATH`` often omits ``/usr/sbin`` and ``/sbin`` — so ``shutil.which()`` can't
|
|
5
|
+
find admin tools like ``visudo`` and ``shutdown`` even when they are installed.
|
|
6
|
+
``which()`` searches ``PATH`` first, then the standard sbin/bin locations, and
|
|
7
|
+
returns an absolute path so the caller can invoke the tool regardless of PATH.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
|
|
13
|
+
# Searched in order after PATH — the standard admin-tool locations.
|
|
14
|
+
_FALLBACK_DIRS = (
|
|
15
|
+
"/usr/local/sbin",
|
|
16
|
+
"/usr/sbin",
|
|
17
|
+
"/sbin",
|
|
18
|
+
"/usr/local/bin",
|
|
19
|
+
"/usr/bin",
|
|
20
|
+
"/bin",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def which(name: str) -> str | None:
|
|
25
|
+
"""Return the absolute path to ``name`` (PATH first, then sbin/bin), or None."""
|
|
26
|
+
found = shutil.which(name)
|
|
27
|
+
if found:
|
|
28
|
+
return found
|
|
29
|
+
for directory in _FALLBACK_DIRS:
|
|
30
|
+
candidate = os.path.join(directory, name)
|
|
31
|
+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
32
|
+
return candidate
|
|
33
|
+
return None
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Host power helpers: reboot."""
|
|
2
2
|
|
|
3
|
-
import shutil
|
|
4
3
|
import subprocess
|
|
5
4
|
|
|
5
|
+
from runspec_linux_core._paths import which
|
|
6
6
|
from runspec_linux_core.errors import CommandError, ToolNotFoundError
|
|
7
7
|
|
|
8
8
|
|
|
@@ -14,22 +14,26 @@ def reboot_host(delay_minutes: int = 0) -> dict:
|
|
|
14
14
|
``shutdown``, falling back to ``systemctl reboot`` for an immediate reboot if
|
|
15
15
|
``shutdown`` is absent. Returns ``{scheduled: True, delay_minutes, command}``.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
``shutdown`` lives in /usr/sbin, often absent from a non-login SSH PATH, so
|
|
18
|
+
it is resolved to an absolute path (see ``_paths.which``). Raises
|
|
19
|
+
ToolNotFoundError if no usable tool is present, or CommandError if the
|
|
18
20
|
command exits non-zero (for example when run as an unprivileged user).
|
|
19
21
|
"""
|
|
20
|
-
|
|
22
|
+
shutdown = which("shutdown")
|
|
23
|
+
if shutdown:
|
|
21
24
|
when = "now" if delay_minutes <= 0 else f"+{delay_minutes}"
|
|
22
|
-
cmd = [
|
|
25
|
+
cmd = [shutdown, "-r", when]
|
|
23
26
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
24
27
|
if result.returncode != 0:
|
|
25
28
|
raise CommandError(result.stderr.strip() or "shutdown failed")
|
|
26
29
|
return {"scheduled": True, "delay_minutes": max(delay_minutes, 0), "command": " ".join(cmd)}
|
|
27
30
|
|
|
28
31
|
# No `shutdown` (unusual). systemctl can only do an immediate reboot.
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
systemctl = which("systemctl")
|
|
33
|
+
if systemctl and delay_minutes <= 0:
|
|
34
|
+
result = subprocess.run([systemctl, "reboot"], capture_output=True, text=True)
|
|
31
35
|
if result.returncode != 0:
|
|
32
36
|
raise CommandError(result.stderr.strip() or "systemctl reboot failed")
|
|
33
|
-
return {"scheduled": True, "delay_minutes": 0, "command": "systemctl reboot"}
|
|
37
|
+
return {"scheduled": True, "delay_minutes": 0, "command": f"{systemctl} reboot"}
|
|
34
38
|
|
|
35
39
|
raise ToolNotFoundError("shutdown not available (and systemctl cannot schedule a delayed reboot)")
|
|
@@ -24,11 +24,11 @@ Two grant scopes:
|
|
|
24
24
|
import getpass
|
|
25
25
|
import os
|
|
26
26
|
import re
|
|
27
|
-
import shutil
|
|
28
27
|
import subprocess
|
|
29
28
|
import sys
|
|
30
29
|
import tempfile
|
|
31
30
|
|
|
31
|
+
from runspec_linux_core._paths import which
|
|
32
32
|
from runspec_linux_core.errors import CommandError, ToolNotFoundError
|
|
33
33
|
|
|
34
34
|
_DROP_IN = "/etc/sudoers.d/runspec"
|
|
@@ -56,10 +56,10 @@ def _sudoers_content(user: str, scope: str, bin_dir: str) -> str:
|
|
|
56
56
|
return f"{_MANAGED_HEADER}{rule}\n"
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def _sudo(password: str, argv: list[str]) -> subprocess.CompletedProcess:
|
|
59
|
+
def _sudo(sudo_path: str, password: str, argv: list[str]) -> subprocess.CompletedProcess:
|
|
60
60
|
"""Run ``sudo -S -p '' argv``, feeding ``password`` on stdin (never on argv)."""
|
|
61
61
|
return subprocess.run(
|
|
62
|
-
[
|
|
62
|
+
[sudo_path, "-S", "-p", "", *argv],
|
|
63
63
|
input=password + "\n",
|
|
64
64
|
capture_output=True,
|
|
65
65
|
text=True,
|
|
@@ -90,9 +90,13 @@ def enable_passwordless_sudo(password: str, user: str | None = None, scope: str
|
|
|
90
90
|
user = user or getpass.getuser()
|
|
91
91
|
if not _USER_RE.match(user):
|
|
92
92
|
raise ValueError(f"invalid user name: {user!r}")
|
|
93
|
-
|
|
93
|
+
# visudo lives in /usr/sbin, which is often absent from a non-login SSH PATH;
|
|
94
|
+
# resolve to absolute paths so the lookups (and invocations) work anyway.
|
|
95
|
+
sudo_path = which("sudo")
|
|
96
|
+
if not sudo_path:
|
|
94
97
|
raise ToolNotFoundError("sudo is not installed")
|
|
95
|
-
|
|
98
|
+
visudo_path = which("visudo")
|
|
99
|
+
if not visudo_path:
|
|
96
100
|
raise ToolNotFoundError("visudo is not installed (needed to validate the sudoers file)")
|
|
97
101
|
|
|
98
102
|
content = _sudoers_content(user, scope, _runspec_bin_dir())
|
|
@@ -102,25 +106,25 @@ def enable_passwordless_sudo(password: str, user: str | None = None, scope: str
|
|
|
102
106
|
tmp_path = tf.name
|
|
103
107
|
try:
|
|
104
108
|
# 1) Validate the generated syntax locally before touching the system.
|
|
105
|
-
check = subprocess.run([
|
|
109
|
+
check = subprocess.run([visudo_path, "-cf", tmp_path], capture_output=True, text=True)
|
|
106
110
|
if check.returncode != 0:
|
|
107
111
|
detail = check.stdout.strip() or check.stderr.strip()
|
|
108
112
|
raise CommandError(f"generated sudoers failed validation: {detail}")
|
|
109
113
|
|
|
110
114
|
# 2) Idempotent: identical drop-in already present? (reading it needs root)
|
|
111
|
-
existing = _sudo(password, ["cat", _DROP_IN])
|
|
115
|
+
existing = _sudo(sudo_path, password, ["cat", _DROP_IN])
|
|
112
116
|
if existing.returncode == 0 and existing.stdout == content:
|
|
113
117
|
return {"user": user, "scope": scope, "path": _DROP_IN, "installed": True, "already_configured": True}
|
|
114
118
|
|
|
115
119
|
# 3) Install atomically as root, 0440 root:root.
|
|
116
|
-
install = _sudo(password, ["install", "-m", "0440", "-o", "root", "-g", "root", tmp_path, _DROP_IN])
|
|
120
|
+
install = _sudo(sudo_path, password, ["install", "-m", "0440", "-o", "root", "-g", "root", tmp_path, _DROP_IN])
|
|
117
121
|
if install.returncode != 0:
|
|
118
122
|
raise CommandError(_auth_hint(install))
|
|
119
123
|
|
|
120
124
|
# 4) Re-validate the whole sudoers tree; roll our drop-in back if broken.
|
|
121
|
-
verify = _sudo(password, [
|
|
125
|
+
verify = _sudo(sudo_path, password, [visudo_path, "-c"])
|
|
122
126
|
if verify.returncode != 0:
|
|
123
|
-
_sudo(password, ["rm", "-f", _DROP_IN])
|
|
127
|
+
_sudo(sudo_path, password, ["rm", "-f", _DROP_IN])
|
|
124
128
|
detail = verify.stdout.strip() or verify.stderr.strip()
|
|
125
129
|
raise CommandError(f"sudoers validation failed after install — rolled back: {detail}")
|
|
126
130
|
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Python virtualenv management: create venvs, install into them, write pip.conf.
|
|
2
|
+
|
|
3
|
+
All three helpers operate in user space (no root): a venv, a pip install into it,
|
|
4
|
+
and a ``pip.conf`` under a home or a venv. The driving use case is a **service
|
|
5
|
+
account that owns a venv shared between several consoles**, so each helper takes an
|
|
6
|
+
optional ``run_as`` user and escalates *internally* — ``sudo -n -u <user>`` (or
|
|
7
|
+
``su``) wrapping every command — rather than the console's declarative ``run_as``
|
|
8
|
+
(the account is chosen per-invocation, not fixed in ``runspec.toml``). ``-n`` makes
|
|
9
|
+
sudo fail fast instead of hanging on the TTY-less SSH channel; passwordless sudo is
|
|
10
|
+
assumed (see ``enable-passwordless-sudo``).
|
|
11
|
+
|
|
12
|
+
Each function returns plain data and raises on failure (``CommandError`` for a
|
|
13
|
+
non-zero subprocess, ``ValueError`` for bad input). The thin ``runspec-linux``
|
|
14
|
+
wrappers render the JSON/exit.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import configparser
|
|
18
|
+
import contextlib
|
|
19
|
+
import io
|
|
20
|
+
import os
|
|
21
|
+
import pwd
|
|
22
|
+
import re
|
|
23
|
+
import shlex
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
|
|
28
|
+
from runspec_linux_core.errors import CommandError
|
|
29
|
+
|
|
30
|
+
# Mirrors sudoers._USER_RE: a POSIX-ish user/group name. Validated before it is
|
|
31
|
+
# interpolated into a become command or a chgrp, so a crafted value can't inject
|
|
32
|
+
# extra tokens (everything still goes to argv, never a shell, but this keeps the
|
|
33
|
+
# error early and clear).
|
|
34
|
+
_NAME_RE = re.compile(r"^[a-z_][a-z0-9_.-]*\$?$")
|
|
35
|
+
|
|
36
|
+
_BECOME_METHODS = ("sudo", "su")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _tail(output: str, limit: int = 40) -> list[str]:
|
|
40
|
+
"""The last ``limit`` non-empty-trimmed lines, so payloads stay bounded."""
|
|
41
|
+
return output.strip().splitlines()[-limit:]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_name(value: str, what: str) -> str:
|
|
45
|
+
if not _NAME_RE.match(value):
|
|
46
|
+
raise ValueError(f"invalid {what}: {value!r}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _become(argv: list[str], run_as: str | None, method: str = "sudo") -> list[str]:
|
|
51
|
+
"""Wrap ``argv`` so it runs as ``run_as`` via ``method`` (no escalation if empty).
|
|
52
|
+
|
|
53
|
+
``sudo`` → ``sudo -n -u <user> <argv...>`` (``-n`` never prompts).
|
|
54
|
+
``su`` → ``su <user> -c "<argv joined+quoted>"`` (su has no ``-n``; it only
|
|
55
|
+
works without a password when the caller is already root).
|
|
56
|
+
"""
|
|
57
|
+
if not run_as:
|
|
58
|
+
return argv
|
|
59
|
+
_validate_name(run_as, "run-as user")
|
|
60
|
+
if method not in _BECOME_METHODS:
|
|
61
|
+
raise ValueError(f"invalid become-method: {method!r} (expected sudo or su)")
|
|
62
|
+
if method == "su":
|
|
63
|
+
return ["su", run_as, "-c", shlex.join(argv)]
|
|
64
|
+
return ["sudo", "-n", "-u", run_as, *argv]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
68
|
+
return subprocess.run(argv, capture_output=True, text=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check(result: subprocess.CompletedProcess[str], what: str, run_as: str | None) -> None:
|
|
72
|
+
"""Raise CommandError on non-zero, with a passwordless-sudo hint when relevant."""
|
|
73
|
+
if result.returncode == 0:
|
|
74
|
+
return
|
|
75
|
+
err = result.stderr.strip()
|
|
76
|
+
if run_as and ("password is required" in err.lower() or "a terminal is required" in err.lower()):
|
|
77
|
+
raise CommandError(f"{what}: passwordless sudo is not configured for run-as {run_as!r} ({err})")
|
|
78
|
+
raise CommandError(err or f"{what} failed")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_venv(
|
|
82
|
+
python: str,
|
|
83
|
+
dest: str,
|
|
84
|
+
*,
|
|
85
|
+
run_as: str | None = None,
|
|
86
|
+
become_method: str = "sudo",
|
|
87
|
+
group: str | None = None,
|
|
88
|
+
system_site_packages: bool = False,
|
|
89
|
+
upgrade_deps: bool = False,
|
|
90
|
+
clear: bool = False,
|
|
91
|
+
prompt: str | None = None,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""Create a virtualenv at ``dest`` from a specific ``python`` interpreter.
|
|
94
|
+
|
|
95
|
+
Always uses ``--symlinks``. Then creates ``<dest>/logs`` mode ``2775`` (setgid,
|
|
96
|
+
so new audit-log files inherit the directory's group) and, if ``group`` is set,
|
|
97
|
+
``chgrp -R`` the venv to it — enabling a shared service-account venv that several
|
|
98
|
+
consoles read/write. With ``run_as`` set every step runs as that user (so the
|
|
99
|
+
venv is owned by it). Raises ValueError on a non-executable interpreter / bad
|
|
100
|
+
name, or CommandError on a failed step.
|
|
101
|
+
"""
|
|
102
|
+
if not (os.path.isfile(python) and os.access(python, os.X_OK)):
|
|
103
|
+
raise ValueError(f"not an executable file: {python!r} (pick a real interpreter, not the system default)")
|
|
104
|
+
|
|
105
|
+
flags: list[str] = ["--symlinks"]
|
|
106
|
+
if system_site_packages:
|
|
107
|
+
flags.append("--system-site-packages")
|
|
108
|
+
if upgrade_deps:
|
|
109
|
+
flags.append("--upgrade-deps")
|
|
110
|
+
if clear:
|
|
111
|
+
flags.append("--clear")
|
|
112
|
+
if prompt:
|
|
113
|
+
flags += ["--prompt", prompt]
|
|
114
|
+
|
|
115
|
+
_check(_run(_become([python, "-m", "venv", *flags, dest], run_as, become_method)), "venv creation", run_as)
|
|
116
|
+
|
|
117
|
+
logs_dir = os.path.join(dest, "logs")
|
|
118
|
+
_check(_run(_become(["mkdir", "-p", logs_dir], run_as, become_method)), "logs dir", run_as)
|
|
119
|
+
if group:
|
|
120
|
+
_validate_name(group, "group")
|
|
121
|
+
_check(_run(_become(["chgrp", "-R", group, dest], run_as, become_method)), "chgrp", run_as)
|
|
122
|
+
_check(_run(_become(["chmod", "2775", logs_dir], run_as, become_method)), "chmod logs", run_as)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"python": python,
|
|
126
|
+
"dest": dest,
|
|
127
|
+
"run_as": run_as or "",
|
|
128
|
+
"group": group or "",
|
|
129
|
+
"logs_dir": logs_dir,
|
|
130
|
+
"created": True,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def install_into_venv(
|
|
135
|
+
venv: str,
|
|
136
|
+
packages: str = "",
|
|
137
|
+
*,
|
|
138
|
+
project: str | None = None,
|
|
139
|
+
editable: bool = False,
|
|
140
|
+
upgrade: bool = False,
|
|
141
|
+
no_cache_dir: bool = False,
|
|
142
|
+
force_reinstall: bool = False,
|
|
143
|
+
run_as: str | None = None,
|
|
144
|
+
become_method: str = "sudo",
|
|
145
|
+
) -> dict:
|
|
146
|
+
"""Install named packages and/or a local project into an existing venv's pip.
|
|
147
|
+
|
|
148
|
+
Targets ``<venv>/bin/python -m pip install``. ``packages`` is space-separated
|
|
149
|
+
named specs (validated against the shared package-token guard); ``project`` is a
|
|
150
|
+
path to a directory with a ``pyproject.toml`` (or a wheel/sdist), installed
|
|
151
|
+
editable when ``editable`` is set. At least one of ``packages``/``project`` is
|
|
152
|
+
required. Raises ValueError on bad input, CommandError on a failed install.
|
|
153
|
+
"""
|
|
154
|
+
from runspec_linux_core.packages import _validate_packages
|
|
155
|
+
|
|
156
|
+
venv_python = os.path.join(venv, "bin", "python")
|
|
157
|
+
if not (os.path.isfile(venv_python) and os.access(venv_python, os.X_OK)):
|
|
158
|
+
raise ValueError(f"no python in venv: {venv_python!r}")
|
|
159
|
+
|
|
160
|
+
names = _validate_packages(packages) if packages.strip() else []
|
|
161
|
+
if project is not None and (project.startswith("-") or not os.path.exists(project)):
|
|
162
|
+
raise ValueError(f"project path not found: {project!r}")
|
|
163
|
+
if editable and project is None:
|
|
164
|
+
raise ValueError("--editable requires a project path")
|
|
165
|
+
if not names and project is None:
|
|
166
|
+
raise ValueError("nothing to install: give a package and/or a project")
|
|
167
|
+
|
|
168
|
+
opts: list[str] = []
|
|
169
|
+
if upgrade:
|
|
170
|
+
opts.append("-U")
|
|
171
|
+
if no_cache_dir:
|
|
172
|
+
opts.append("--no-cache-dir")
|
|
173
|
+
if force_reinstall:
|
|
174
|
+
opts.append("--force-reinstall")
|
|
175
|
+
|
|
176
|
+
target: list[str] = list(names)
|
|
177
|
+
if project is not None:
|
|
178
|
+
target += ["-e", project] if editable else [project]
|
|
179
|
+
|
|
180
|
+
cmd = _become([venv_python, "-m", "pip", "install", *opts, *target], run_as, become_method)
|
|
181
|
+
result = _run(cmd)
|
|
182
|
+
_check(result, "pip install", run_as)
|
|
183
|
+
return {
|
|
184
|
+
"venv": venv,
|
|
185
|
+
"packages": names,
|
|
186
|
+
"project": project or "",
|
|
187
|
+
"editable": editable,
|
|
188
|
+
"installed": True,
|
|
189
|
+
"output": _tail(result.stdout),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def configure_pip(
|
|
194
|
+
index_url: str,
|
|
195
|
+
*,
|
|
196
|
+
extra_index_url: str | None = None,
|
|
197
|
+
trusted_host: str | None = None,
|
|
198
|
+
cert: str | None = None,
|
|
199
|
+
timeout: int | None = None,
|
|
200
|
+
retries: int | None = None,
|
|
201
|
+
proxy: str | None = None,
|
|
202
|
+
require_virtualenv: bool = False,
|
|
203
|
+
scope: str = "user",
|
|
204
|
+
venv: str | None = None,
|
|
205
|
+
run_as: str | None = None,
|
|
206
|
+
become_method: str = "sudo",
|
|
207
|
+
) -> dict:
|
|
208
|
+
"""Write a ``pip.conf`` at user or venv scope for private-PyPI access.
|
|
209
|
+
|
|
210
|
+
Builds a ``[global]`` section from the given settings (``index-url`` is the only
|
|
211
|
+
required one) and, when ``require_virtualenv`` is set, also pins
|
|
212
|
+
``require-virtualenv`` + ``disable-pip-version-check``. The file is validated by
|
|
213
|
+
re-parsing before it is installed. ``scope="user"`` targets
|
|
214
|
+
``<home>/.config/pip/pip.conf`` (the ``run_as`` user's home when escalating, else
|
|
215
|
+
the caller's); ``scope="venv"`` targets ``<venv>/pip.conf`` and requires ``venv``.
|
|
216
|
+
With ``run_as`` the file is installed via ``install -D -m 0644`` as that user (so
|
|
217
|
+
it is owned by it); otherwise it is written atomically with a ``.bak`` of any
|
|
218
|
+
existing file. Raises ValueError on bad input, CommandError on a failed step.
|
|
219
|
+
"""
|
|
220
|
+
if scope not in ("user", "venv"):
|
|
221
|
+
raise ValueError(f"invalid scope: {scope!r} (expected user or venv)")
|
|
222
|
+
if scope == "venv":
|
|
223
|
+
if not venv:
|
|
224
|
+
raise ValueError("scope=venv requires a venv path")
|
|
225
|
+
path = os.path.join(venv, "pip.conf")
|
|
226
|
+
else:
|
|
227
|
+
if run_as:
|
|
228
|
+
_validate_name(run_as, "run-as user")
|
|
229
|
+
home = pwd.getpwnam(run_as).pw_dir
|
|
230
|
+
else:
|
|
231
|
+
home = os.path.expanduser("~")
|
|
232
|
+
path = os.path.join(home, ".config", "pip", "pip.conf")
|
|
233
|
+
|
|
234
|
+
cfg = configparser.ConfigParser()
|
|
235
|
+
section: dict[str, str] = {"index-url": index_url}
|
|
236
|
+
if extra_index_url:
|
|
237
|
+
section["extra-index-url"] = extra_index_url
|
|
238
|
+
if trusted_host:
|
|
239
|
+
section["trusted-host"] = trusted_host
|
|
240
|
+
if cert:
|
|
241
|
+
section["cert"] = cert
|
|
242
|
+
if timeout is not None:
|
|
243
|
+
section["timeout"] = str(timeout)
|
|
244
|
+
if retries is not None:
|
|
245
|
+
section["retries"] = str(retries)
|
|
246
|
+
if proxy:
|
|
247
|
+
section["proxy"] = proxy
|
|
248
|
+
if require_virtualenv:
|
|
249
|
+
section["require-virtualenv"] = "true"
|
|
250
|
+
section["disable-pip-version-check"] = "true"
|
|
251
|
+
cfg["global"] = section
|
|
252
|
+
|
|
253
|
+
buf = io.StringIO()
|
|
254
|
+
cfg.write(buf)
|
|
255
|
+
content = buf.getvalue()
|
|
256
|
+
|
|
257
|
+
# Validate it re-parses before we install it anywhere.
|
|
258
|
+
check = configparser.ConfigParser()
|
|
259
|
+
check.read_string(content)
|
|
260
|
+
|
|
261
|
+
# Write to a temp file the run-as user can read (0644 — pip.conf is non-secret),
|
|
262
|
+
# then install it atomically into place as that user (creating parent dirs).
|
|
263
|
+
fd, tmp = tempfile.mkstemp(prefix="pip.conf.", suffix=".tmp")
|
|
264
|
+
try:
|
|
265
|
+
with os.fdopen(fd, "w") as fh:
|
|
266
|
+
fh.write(content)
|
|
267
|
+
os.chmod(tmp, 0o644)
|
|
268
|
+
if run_as:
|
|
269
|
+
if _path_exists_as(path, run_as, become_method):
|
|
270
|
+
_check(
|
|
271
|
+
_run(_become(["cp", path, f"{path}.bak"], run_as, become_method)),
|
|
272
|
+
"backup pip.conf",
|
|
273
|
+
run_as,
|
|
274
|
+
)
|
|
275
|
+
_check(
|
|
276
|
+
_run(_become(["install", "-D", "-m", "0644", tmp, path], run_as, become_method)),
|
|
277
|
+
"install pip.conf",
|
|
278
|
+
run_as,
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
282
|
+
if os.path.exists(path):
|
|
283
|
+
shutil.copy2(path, f"{path}.bak")
|
|
284
|
+
shutil.copy2(tmp, path)
|
|
285
|
+
finally:
|
|
286
|
+
with contextlib.suppress(OSError):
|
|
287
|
+
os.unlink(tmp)
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
"path": path,
|
|
291
|
+
"scope": scope,
|
|
292
|
+
"run_as": run_as or "",
|
|
293
|
+
"configured": True,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _path_exists_as(path: str, run_as: str | None, become_method: str) -> bool:
|
|
298
|
+
"""True if ``path`` exists from the run-as user's view (``test -e``)."""
|
|
299
|
+
return _run(_become(["test", "-e", path], run_as, become_method)).returncode == 0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""which() falls back to sbin/bin dirs when a tool isn't on PATH."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from runspec_linux_core._paths import which
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_which_prefers_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
9
|
+
monkeypatch.setattr("runspec_linux_core._paths.shutil.which", lambda n: "/usr/bin/" + n)
|
|
10
|
+
assert which("sudo") == "/usr/bin/sudo"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_which_falls_back_to_sbin(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
14
|
+
# Not on PATH (the non-login SSH case), but present in /usr/sbin.
|
|
15
|
+
monkeypatch.setattr("runspec_linux_core._paths.shutil.which", lambda n: None)
|
|
16
|
+
monkeypatch.setattr("runspec_linux_core._paths.os.path.isfile", lambda p: p == "/usr/sbin/visudo")
|
|
17
|
+
monkeypatch.setattr("runspec_linux_core._paths.os.access", lambda p, mode: True)
|
|
18
|
+
assert which("visudo") == "/usr/sbin/visudo"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_which_none_when_absent(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
22
|
+
monkeypatch.setattr("runspec_linux_core._paths.shutil.which", lambda n: None)
|
|
23
|
+
monkeypatch.setattr("runspec_linux_core._paths.os.path.isfile", lambda p: False)
|
|
24
|
+
assert which("nope") is None
|
|
@@ -14,7 +14,7 @@ def _completed(returncode: int = 0, stderr: str = "") -> subprocess.CompletedPro
|
|
|
14
14
|
|
|
15
15
|
def test_reboot_now_uses_shutdown(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
16
16
|
seen: list[list[str]] = []
|
|
17
|
-
monkeypatch.setattr("runspec_linux_core.power.
|
|
17
|
+
monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "shutdown" else None)
|
|
18
18
|
monkeypatch.setattr(
|
|
19
19
|
"runspec_linux_core.power.subprocess.run",
|
|
20
20
|
lambda cmd, **kw: seen.append(cmd) or _completed(),
|
|
@@ -26,7 +26,7 @@ def test_reboot_now_uses_shutdown(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
26
26
|
|
|
27
27
|
def test_reboot_with_delay_schedules(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
28
28
|
seen: list[list[str]] = []
|
|
29
|
-
monkeypatch.setattr("runspec_linux_core.power.
|
|
29
|
+
monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "shutdown" else None)
|
|
30
30
|
monkeypatch.setattr(
|
|
31
31
|
"runspec_linux_core.power.subprocess.run",
|
|
32
32
|
lambda cmd, **kw: seen.append(cmd) or _completed(),
|
|
@@ -38,7 +38,7 @@ def test_reboot_with_delay_schedules(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
38
38
|
|
|
39
39
|
def test_reboot_falls_back_to_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
40
40
|
seen: list[list[str]] = []
|
|
41
|
-
monkeypatch.setattr("runspec_linux_core.power.
|
|
41
|
+
monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "systemctl" else None)
|
|
42
42
|
monkeypatch.setattr(
|
|
43
43
|
"runspec_linux_core.power.subprocess.run",
|
|
44
44
|
lambda cmd, **kw: seen.append(cmd) or _completed(),
|
|
@@ -49,7 +49,7 @@ def test_reboot_falls_back_to_systemctl(monkeypatch: pytest.MonkeyPatch) -> None
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def test_reboot_non_zero_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
52
|
-
monkeypatch.setattr("runspec_linux_core.power.
|
|
52
|
+
monkeypatch.setattr("runspec_linux_core.power.which", lambda name: name if name == "shutdown" else None)
|
|
53
53
|
monkeypatch.setattr(
|
|
54
54
|
"runspec_linux_core.power.subprocess.run",
|
|
55
55
|
lambda cmd, **kw: _completed(returncode=1, stderr="Operation not permitted"),
|
|
@@ -101,12 +101,12 @@ def test_remove_package_requires_a_manager(monkeypatch: pytest.MonkeyPatch) -> N
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
def test_reboot_host_requires_a_tool(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
104
|
-
monkeypatch.setattr("runspec_linux_core.power.
|
|
104
|
+
monkeypatch.setattr("runspec_linux_core.power.which", lambda _: None)
|
|
105
105
|
with pytest.raises(ToolNotFoundError):
|
|
106
106
|
core.reboot_host()
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
def test_enable_passwordless_sudo_requires_sudo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
110
|
-
monkeypatch.setattr("runspec_linux_core.sudoers.
|
|
110
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda _: None)
|
|
111
111
|
with pytest.raises(ToolNotFoundError):
|
|
112
112
|
core.enable_passwordless_sudo("pw", "alice", "scoped")
|
|
@@ -19,7 +19,8 @@ def _ok(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.C
|
|
|
19
19
|
|
|
20
20
|
@pytest.fixture(autouse=True)
|
|
21
21
|
def _tools_present(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
22
|
-
|
|
22
|
+
# Resolver returns the bare name so the stubbed commands read cleanly.
|
|
23
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda name: name)
|
|
23
24
|
monkeypatch.setattr("runspec_linux_core.sudoers._runspec_bin_dir", lambda: "/opt/rs/bin")
|
|
24
25
|
|
|
25
26
|
|
|
@@ -142,6 +143,6 @@ def test_post_validate_failure_rolls_back(monkeypatch: pytest.MonkeyPatch) -> No
|
|
|
142
143
|
|
|
143
144
|
|
|
144
145
|
def test_requires_visudo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
145
|
-
monkeypatch.setattr("runspec_linux_core.sudoers.
|
|
146
|
+
monkeypatch.setattr("runspec_linux_core.sudoers.which", lambda name: None if name == "visudo" else "/usr/bin/sudo")
|
|
146
147
|
with pytest.raises(ToolNotFoundError, match="visudo"):
|
|
147
148
|
sudoers.enable_passwordless_sudo("pw", "alice", "scoped")
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""venv creation, install-into-venv, and pip.conf writing.
|
|
2
|
+
|
|
3
|
+
The subprocess layer (``_run``) is stubbed so no real venv/pip is touched; the
|
|
4
|
+
tests assert on the argv that *would* run (escalation prefix, flags, ordering)
|
|
5
|
+
and on the input validation that happens before any subprocess.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import configparser
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from runspec_linux_core import venvs
|
|
15
|
+
from runspec_linux_core.errors import CommandError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _completed(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess:
|
|
19
|
+
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _record(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
|
23
|
+
"""Stub ``venvs._run`` to record every argv and always succeed."""
|
|
24
|
+
calls: list[list[str]] = []
|
|
25
|
+
|
|
26
|
+
def fake_run(argv: list[str]) -> subprocess.CompletedProcess:
|
|
27
|
+
calls.append(argv)
|
|
28
|
+
return _completed("Successfully installed foo\n")
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(venvs, "_run", fake_run)
|
|
31
|
+
return calls
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── _become ───────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_become_no_user_is_noop() -> None:
|
|
38
|
+
assert venvs._become(["echo", "hi"], None) == ["echo", "hi"]
|
|
39
|
+
assert venvs._become(["echo", "hi"], "") == ["echo", "hi"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_become_sudo_prefix() -> None:
|
|
43
|
+
assert venvs._become(["echo", "hi"], "svc") == ["sudo", "-n", "-u", "svc", "echo", "hi"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_become_su_quotes() -> None:
|
|
47
|
+
assert venvs._become(["pip", "install", "a b"], "svc", "su") == ["su", "svc", "-c", "pip install 'a b'"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_become_rejects_bad_user() -> None:
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
venvs._become(["echo"], "bad;user")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_become_rejects_bad_method() -> None:
|
|
56
|
+
with pytest.raises(ValueError):
|
|
57
|
+
venvs._become(["echo"], "svc", "doas")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── create_venv ───────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_create_venv_uses_symlinks_and_logs(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
64
|
+
calls = _record(monkeypatch)
|
|
65
|
+
import sys
|
|
66
|
+
|
|
67
|
+
result = venvs.create_venv(sys.executable, "/opt/v1")
|
|
68
|
+
|
|
69
|
+
venv_cmd = calls[0]
|
|
70
|
+
assert venv_cmd[:4] == [sys.executable, "-m", "venv", "--symlinks"]
|
|
71
|
+
assert venv_cmd[-1] == "/opt/v1"
|
|
72
|
+
# mkdir -p logs, then chmod 2775 logs (no group → no chgrp step).
|
|
73
|
+
assert calls[1] == ["mkdir", "-p", "/opt/v1/logs"]
|
|
74
|
+
assert calls[2] == ["chmod", "2775", "/opt/v1/logs"]
|
|
75
|
+
assert result == {
|
|
76
|
+
"python": sys.executable,
|
|
77
|
+
"dest": "/opt/v1",
|
|
78
|
+
"run_as": "",
|
|
79
|
+
"group": "",
|
|
80
|
+
"logs_dir": "/opt/v1/logs",
|
|
81
|
+
"created": True,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_create_venv_only_sets_chosen_flags(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
86
|
+
calls = _record(monkeypatch)
|
|
87
|
+
import sys
|
|
88
|
+
|
|
89
|
+
venvs.create_venv(sys.executable, "/opt/v1", system_site_packages=True, clear=True)
|
|
90
|
+
venv_cmd = calls[0]
|
|
91
|
+
assert "--system-site-packages" in venv_cmd
|
|
92
|
+
assert "--clear" in venv_cmd
|
|
93
|
+
assert "--upgrade-deps" not in venv_cmd
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_create_venv_run_as_escalates_every_step(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
97
|
+
calls = _record(monkeypatch)
|
|
98
|
+
import sys
|
|
99
|
+
|
|
100
|
+
venvs.create_venv(sys.executable, "/opt/v1", run_as="svc", group="consoles")
|
|
101
|
+
for cmd in calls:
|
|
102
|
+
assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
|
|
103
|
+
# chgrp -R consoles /opt/v1 runs between mkdir and chmod.
|
|
104
|
+
assert ["sudo", "-n", "-u", "svc", "chgrp", "-R", "consoles", "/opt/v1"] in calls
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_create_venv_rejects_non_executable(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
108
|
+
_record(monkeypatch)
|
|
109
|
+
with pytest.raises(ValueError):
|
|
110
|
+
venvs.create_venv("/no/such/python", "/opt/v1")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_create_venv_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
114
|
+
import sys
|
|
115
|
+
|
|
116
|
+
monkeypatch.setattr(venvs, "_run", lambda argv: _completed("", returncode=1, stderr="boom"))
|
|
117
|
+
with pytest.raises(CommandError):
|
|
118
|
+
venvs.create_venv(sys.executable, "/opt/v1")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── install_into_venv ─────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fake_venv(tmp_path, monkeypatch: pytest.MonkeyPatch) -> str:
|
|
125
|
+
"""A venv dir whose bin/python passes the executable check."""
|
|
126
|
+
import sys
|
|
127
|
+
|
|
128
|
+
venv = tmp_path / "venv"
|
|
129
|
+
(venv / "bin").mkdir(parents=True)
|
|
130
|
+
py = venv / "bin" / "python"
|
|
131
|
+
os.symlink(sys.executable, py)
|
|
132
|
+
return str(venv)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_install_into_venv_builds_pip_argv(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
136
|
+
venv = _fake_venv(tmp_path, monkeypatch)
|
|
137
|
+
calls = _record(monkeypatch)
|
|
138
|
+
|
|
139
|
+
result = venvs.install_into_venv(venv, "httpx rich", upgrade=True)
|
|
140
|
+
cmd = calls[0]
|
|
141
|
+
assert cmd[:4] == [f"{venv}/bin/python", "-m", "pip", "install"]
|
|
142
|
+
assert "-U" in cmd
|
|
143
|
+
assert cmd[-2:] == ["httpx", "rich"]
|
|
144
|
+
assert result["installed"] is True
|
|
145
|
+
assert result["output"] == ["Successfully installed foo"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_install_into_venv_editable_project(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
149
|
+
venv = _fake_venv(tmp_path, monkeypatch)
|
|
150
|
+
proj = tmp_path / "proj"
|
|
151
|
+
proj.mkdir()
|
|
152
|
+
calls = _record(monkeypatch)
|
|
153
|
+
|
|
154
|
+
venvs.install_into_venv(venv, project=str(proj), editable=True, run_as="svc")
|
|
155
|
+
cmd = calls[0]
|
|
156
|
+
assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
|
|
157
|
+
assert cmd[-2:] == ["-e", str(proj)]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_install_into_venv_requires_a_target(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
161
|
+
venv = _fake_venv(tmp_path, monkeypatch)
|
|
162
|
+
_record(monkeypatch)
|
|
163
|
+
with pytest.raises(ValueError):
|
|
164
|
+
venvs.install_into_venv(venv)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_install_into_venv_editable_needs_project(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
168
|
+
venv = _fake_venv(tmp_path, monkeypatch)
|
|
169
|
+
_record(monkeypatch)
|
|
170
|
+
with pytest.raises(ValueError):
|
|
171
|
+
venvs.install_into_venv(venv, "httpx", editable=True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_install_into_venv_rejects_missing_venv(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
175
|
+
_record(monkeypatch)
|
|
176
|
+
with pytest.raises(ValueError):
|
|
177
|
+
venvs.install_into_venv(str(tmp_path / "nope"), "httpx")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_install_into_venv_rejects_injection(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
181
|
+
venv = _fake_venv(tmp_path, monkeypatch)
|
|
182
|
+
_record(monkeypatch)
|
|
183
|
+
with pytest.raises(ValueError):
|
|
184
|
+
venvs.install_into_venv(venv, "httpx; rm -rf /")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── configure_pip ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_configure_pip_user_scope_writes_ini(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
191
|
+
monkeypatch.setattr(os.path, "expanduser", lambda p: str(tmp_path) if p == "~" else p)
|
|
192
|
+
result = venvs.configure_pip(
|
|
193
|
+
"https://nexus/repository/pypi/simple",
|
|
194
|
+
trusted_host="nexus",
|
|
195
|
+
timeout=60,
|
|
196
|
+
retries=5,
|
|
197
|
+
require_virtualenv=True,
|
|
198
|
+
)
|
|
199
|
+
path = result["path"]
|
|
200
|
+
assert path == str(tmp_path / ".config" / "pip" / "pip.conf")
|
|
201
|
+
|
|
202
|
+
cfg = configparser.ConfigParser()
|
|
203
|
+
cfg.read(path)
|
|
204
|
+
assert cfg["global"]["index-url"] == "https://nexus/repository/pypi/simple"
|
|
205
|
+
assert cfg["global"]["trusted-host"] == "nexus"
|
|
206
|
+
assert cfg["global"]["timeout"] == "60"
|
|
207
|
+
assert cfg["global"]["retries"] == "5"
|
|
208
|
+
assert cfg["global"]["require-virtualenv"] == "true"
|
|
209
|
+
assert cfg["global"]["disable-pip-version-check"] == "true"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_configure_pip_venv_scope(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
213
|
+
venv = tmp_path / "venv"
|
|
214
|
+
venv.mkdir()
|
|
215
|
+
result = venvs.configure_pip("https://nexus/simple", scope="venv", venv=str(venv))
|
|
216
|
+
assert result["path"] == str(venv / "pip.conf")
|
|
217
|
+
cfg = configparser.ConfigParser()
|
|
218
|
+
cfg.read(result["path"])
|
|
219
|
+
assert cfg["global"]["index-url"] == "https://nexus/simple"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_configure_pip_venv_scope_requires_venv() -> None:
|
|
223
|
+
with pytest.raises(ValueError):
|
|
224
|
+
venvs.configure_pip("https://nexus/simple", scope="venv")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_configure_pip_bad_scope() -> None:
|
|
228
|
+
with pytest.raises(ValueError):
|
|
229
|
+
venvs.configure_pip("https://nexus/simple", scope="global")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_configure_pip_run_as_installs_via_become(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
233
|
+
calls = _record(monkeypatch)
|
|
234
|
+
# run_as path resolves the user's home via pwd; stub it to tmp_path.
|
|
235
|
+
monkeypatch.setattr(
|
|
236
|
+
venvs.pwd,
|
|
237
|
+
"getpwnam",
|
|
238
|
+
lambda u: type("PW", (), {"pw_dir": str(tmp_path)})(),
|
|
239
|
+
)
|
|
240
|
+
result = venvs.configure_pip("https://nexus/simple", run_as="svc")
|
|
241
|
+
target = str(tmp_path / ".config" / "pip" / "pip.conf")
|
|
242
|
+
assert result["path"] == target
|
|
243
|
+
# An install -D -m 0644 <tmp> <target> ran as svc.
|
|
244
|
+
install_cmds = [c for c in calls if "install" in c and "-D" in c]
|
|
245
|
+
assert install_cmds, calls
|
|
246
|
+
cmd = install_cmds[0]
|
|
247
|
+
assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
|
|
248
|
+
assert cmd[4:7] == ["install", "-D", "-m"]
|
|
249
|
+
assert cmd[-1] == target
|
|
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
|