runspec-linux-root 0.1.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.
- runspec_linux_root-0.1.0/.gitignore +61 -0
- runspec_linux_root-0.1.0/CHANGELOG.md +21 -0
- runspec_linux_root-0.1.0/PKG-INFO +70 -0
- runspec_linux_root-0.1.0/README.md +52 -0
- runspec_linux_root-0.1.0/pyproject.toml +51 -0
- runspec_linux_root-0.1.0/runspec_linux_root/__init__.py +7 -0
- runspec_linux_root-0.1.0/runspec_linux_root/packages.py +71 -0
- runspec_linux_root-0.1.0/runspec_linux_root/perf.py +23 -0
- runspec_linux_root-0.1.0/runspec_linux_root/power.py +20 -0
- runspec_linux_root-0.1.0/runspec_linux_root/runspec.toml +145 -0
- runspec_linux_root-0.1.0/runspec_linux_root/sudoers.py +23 -0
- runspec_linux_root-0.1.0/tests/__init__.py +0 -0
- runspec_linux_root-0.1.0/tests/test_spec.py +65 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
.env
|
|
16
|
+
pip-wheel-metadata/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
htmlcov/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
*.cover
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
*.js.map
|
|
29
|
+
.npm
|
|
30
|
+
|
|
31
|
+
# Go
|
|
32
|
+
*.exe
|
|
33
|
+
*.test
|
|
34
|
+
*.out
|
|
35
|
+
vendor/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.iml
|
|
41
|
+
*.iws
|
|
42
|
+
*.ipr
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
# Docs
|
|
47
|
+
site/
|
|
48
|
+
|
|
49
|
+
# Misc
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
|
|
53
|
+
# External reference repos (cloned locally, not committed)
|
|
54
|
+
chainlit-docs/
|
|
55
|
+
.chainlit/
|
|
56
|
+
|
|
57
|
+
# Claude Code local config (machine-specific)
|
|
58
|
+
.claude/launch.json
|
|
59
|
+
|
|
60
|
+
# Stray committed test venv (removed from tracking)
|
|
61
|
+
.venv-test/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-linux-root` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-09-30
|
|
6
|
+
|
|
7
|
+
First release. The Linux tools that need root, split out of `runspec-linux`
|
|
8
|
+
(0.19.0) so that package never runs anything as root:
|
|
9
|
+
`list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`,
|
|
10
|
+
`autoremove-packages`, `set-sysctl`, `reboot-host` and
|
|
11
|
+
`enable-passwordless-sudo`.
|
|
12
|
+
|
|
13
|
+
- Every tool declares `run_as = "root"` (except `enable-passwordless-sudo`,
|
|
14
|
+
which runs as the invoker and escalates with the password it's given).
|
|
15
|
+
`set-sysctl` loses its `--run-as`/`--sudo`/`--become-method` args: it always
|
|
16
|
+
runs as root.
|
|
17
|
+
- Meant for a root-owned venv, so only root can change code that root runs.
|
|
18
|
+
- `enable-passwordless-sudo`'s `scoped` option is documented honestly: it covers
|
|
19
|
+
direct `sudo <tool>` runs only, not runs from runspec-console or the MCP
|
|
20
|
+
gateway.
|
|
21
|
+
- The helpers stay in `runspec-linux-core` (>= 0.13.0).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runspec-linux-root
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Linux admin runnables that need root (packages, reboot, sysctl) for runspec
|
|
5
|
+
Project-URL: Documentation, https://runspec.app/
|
|
6
|
+
Project-URL: Source, https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-linux-root
|
|
7
|
+
Project-URL: Changelog, https://github.com/jasonfinestone/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/jasonfinestone/runspec/issues
|
|
9
|
+
Keywords: linux,packages,root,runnable,runspec,sysadmin
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: runspec-linux-core>=0.13.0
|
|
12
|
+
Requires-Dist: runspec>=0.68.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# runspec-linux-root
|
|
20
|
+
|
|
21
|
+
Linux admin tools that need **root**, as [runspec](https://runspec.app/) runnables:
|
|
22
|
+
|
|
23
|
+
| Tool | What it does | Autonomy |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `list-upgrades` | List packages with an available upgrade (refreshes the index) | autonomous |
|
|
26
|
+
| `upgrade-packages` | Refresh the index and upgrade every package | confirm |
|
|
27
|
+
| `install-package` | Install packages by name | confirm |
|
|
28
|
+
| `remove-package` | Remove packages (`--purge` for config files too) | confirm |
|
|
29
|
+
| `autoremove-packages` | Remove packages nothing depends on (`--dry-run` to preview) | confirm |
|
|
30
|
+
| `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
|
|
31
|
+
| `reboot-host` | Reboot, optionally after a delay | confirm |
|
|
32
|
+
| `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
|
|
33
|
+
|
|
34
|
+
They work across apt, dnf, yum, zypper and pacman. The logic lives in
|
|
35
|
+
[`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
|
|
36
|
+
everyday tools that never need root are in
|
|
37
|
+
[`runspec-linux`](https://pypi.org/project/runspec-linux/).
|
|
38
|
+
|
|
39
|
+
## Install into a root-owned venv
|
|
40
|
+
|
|
41
|
+
Every tool here declares `run_as = "root"`: root runs this code. So install it
|
|
42
|
+
into a venv **only root can change**. A venv owned by a service account would
|
|
43
|
+
let that account edit code root later runs.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
sudo python3 -m venv /opt/venvs/admin
|
|
47
|
+
sudo /opt/venvs/admin/bin/pip install runspec-linux-root
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Who can run them
|
|
51
|
+
|
|
52
|
+
sudoers decides. An admin runs a tool with sudo:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sudo /opt/venvs/admin/bin/upgrade-packages
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
runspec checks the process really is root (`enforce_run_as`) and refuses
|
|
59
|
+
otherwise, naming the account to `sudo -u` to.
|
|
60
|
+
|
|
61
|
+
**From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
|
|
62
|
+
which never prompts for a password, so the admin needs a passwordless sudo rule.
|
|
63
|
+
`enable-passwordless-sudo` installs one:
|
|
64
|
+
|
|
65
|
+
- `--scope scoped` allows only this venv's tools, run directly
|
|
66
|
+
(`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron). It does **not**
|
|
67
|
+
cover the console or the gateway, which pass variables to the tool through a
|
|
68
|
+
`sh` prelude so that secrets never go on a command line; sudo then sees `sh`,
|
|
69
|
+
not the tool.
|
|
70
|
+
- `--scope all` grants `NOPASSWD: ALL`, which the console and the gateway need.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# runspec-linux-root
|
|
2
|
+
|
|
3
|
+
Linux admin tools that need **root**, as [runspec](https://runspec.app/) runnables:
|
|
4
|
+
|
|
5
|
+
| Tool | What it does | Autonomy |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `list-upgrades` | List packages with an available upgrade (refreshes the index) | autonomous |
|
|
8
|
+
| `upgrade-packages` | Refresh the index and upgrade every package | confirm |
|
|
9
|
+
| `install-package` | Install packages by name | confirm |
|
|
10
|
+
| `remove-package` | Remove packages (`--purge` for config files too) | confirm |
|
|
11
|
+
| `autoremove-packages` | Remove packages nothing depends on (`--dry-run` to preview) | confirm |
|
|
12
|
+
| `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
|
|
13
|
+
| `reboot-host` | Reboot, optionally after a delay | confirm |
|
|
14
|
+
| `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
|
|
15
|
+
|
|
16
|
+
They work across apt, dnf, yum, zypper and pacman. The logic lives in
|
|
17
|
+
[`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
|
|
18
|
+
everyday tools that never need root are in
|
|
19
|
+
[`runspec-linux`](https://pypi.org/project/runspec-linux/).
|
|
20
|
+
|
|
21
|
+
## Install into a root-owned venv
|
|
22
|
+
|
|
23
|
+
Every tool here declares `run_as = "root"`: root runs this code. So install it
|
|
24
|
+
into a venv **only root can change**. A venv owned by a service account would
|
|
25
|
+
let that account edit code root later runs.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
sudo python3 -m venv /opt/venvs/admin
|
|
29
|
+
sudo /opt/venvs/admin/bin/pip install runspec-linux-root
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Who can run them
|
|
33
|
+
|
|
34
|
+
sudoers decides. An admin runs a tool with sudo:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
sudo /opt/venvs/admin/bin/upgrade-packages
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
runspec checks the process really is root (`enforce_run_as`) and refuses
|
|
41
|
+
otherwise, naming the account to `sudo -u` to.
|
|
42
|
+
|
|
43
|
+
**From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
|
|
44
|
+
which never prompts for a password, so the admin needs a passwordless sudo rule.
|
|
45
|
+
`enable-passwordless-sudo` installs one:
|
|
46
|
+
|
|
47
|
+
- `--scope scoped` allows only this venv's tools, run directly
|
|
48
|
+
(`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron). It does **not**
|
|
49
|
+
cover the console or the gateway, which pass variables to the tool through a
|
|
50
|
+
`sh` prelude so that secrets never go on a command line; sudo then sees `sh`,
|
|
51
|
+
not the tool.
|
|
52
|
+
- `--scope all` grants `NOPASSWD: ALL`, which the console and the gateway need.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-linux-root"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
description = "Linux admin runnables that need root (packages, reboot, sysctl) for runspec"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
keywords = ["runspec", "runnable", "linux", "sysadmin", "root", "packages"]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"runspec>=0.68.0",
|
|
14
|
+
"runspec-linux-core>=0.13.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Documentation = "https://runspec.app/"
|
|
19
|
+
Source = "https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-linux-root"
|
|
20
|
+
Changelog = "https://github.com/jasonfinestone/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md"
|
|
21
|
+
Issues = "https://github.com/jasonfinestone/runspec/issues"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
list-upgrades = "runspec_linux_root.packages:main_list_upgrades"
|
|
25
|
+
upgrade-packages = "runspec_linux_root.packages:main_upgrade_packages"
|
|
26
|
+
install-package = "runspec_linux_root.packages:main_install_package"
|
|
27
|
+
remove-package = "runspec_linux_root.packages:main_remove_package"
|
|
28
|
+
autoremove-packages = "runspec_linux_root.packages:main_autoremove_packages"
|
|
29
|
+
set-sysctl = "runspec_linux_root.perf:main_set_sysctl"
|
|
30
|
+
reboot-host = "runspec_linux_root.power:main_reboot_host"
|
|
31
|
+
enable-passwordless-sudo = "runspec_linux_root.sudoers:main_enable_passwordless_sudo"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"ruff==0.15.20",
|
|
36
|
+
"mypy",
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
python_version = "3.10"
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 200
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""runspec-linux-root — Linux admin runnables that need root.
|
|
2
|
+
|
|
3
|
+
Package management, reboot and kernel tuning, split out of ``runspec-linux`` so
|
|
4
|
+
that package never runs anything as root. Every tool here declares
|
|
5
|
+
``run_as = "root"``. Install it into a **root-owned** venv: root runs this code,
|
|
6
|
+
so only root may be able to change it. The logic lives in ``runspec-linux-core``.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Package-manager runnables that need root (apt/dnf/yum/zypper/pacman)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import runspec as rs
|
|
7
|
+
from runspec_linux_core import autoremove_packages, install_package, list_upgrades, remove_package, upgrade_packages
|
|
8
|
+
from runspec_linux_core.errors import CommandError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main_list_upgrades() -> None:
|
|
12
|
+
spec = rs.parse("list-upgrades")
|
|
13
|
+
try:
|
|
14
|
+
print(json.dumps(list_upgrades(refresh=not spec.no_refresh.value)))
|
|
15
|
+
except Exception as e:
|
|
16
|
+
print(json.dumps({"error": str(e)}))
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main_upgrade_packages() -> None:
|
|
21
|
+
rs.parse("upgrade-packages")
|
|
22
|
+
try:
|
|
23
|
+
print(json.dumps(upgrade_packages()))
|
|
24
|
+
except CommandError as e:
|
|
25
|
+
print(json.dumps({"upgraded": False, "error": str(e)}))
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
# Includes ToolNotFoundError (no package manager).
|
|
29
|
+
print(json.dumps({"error": str(e)}))
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main_install_package() -> None:
|
|
34
|
+
spec = rs.parse("install-package")
|
|
35
|
+
package = spec.package.value
|
|
36
|
+
try:
|
|
37
|
+
print(json.dumps(install_package(package)))
|
|
38
|
+
except CommandError as e:
|
|
39
|
+
print(json.dumps({"package": package, "installed": False, "error": str(e)}))
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
# Includes ValueError (bad name) and ToolNotFoundError (no package manager).
|
|
43
|
+
print(json.dumps({"error": str(e)}))
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main_remove_package() -> None:
|
|
48
|
+
spec = rs.parse("remove-package")
|
|
49
|
+
package = spec.package.value
|
|
50
|
+
try:
|
|
51
|
+
print(json.dumps(remove_package(package, purge=spec.purge.value)))
|
|
52
|
+
except CommandError as e:
|
|
53
|
+
print(json.dumps({"package": package, "removed": False, "error": str(e)}))
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
# Includes ValueError (bad name) and ToolNotFoundError (no package manager).
|
|
57
|
+
print(json.dumps({"error": str(e)}))
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main_autoremove_packages() -> None:
|
|
62
|
+
spec = rs.parse("autoremove-packages")
|
|
63
|
+
try:
|
|
64
|
+
print(json.dumps(autoremove_packages(purge=spec.purge.value, dry_run=spec.dry_run.value)))
|
|
65
|
+
except CommandError as e:
|
|
66
|
+
print(json.dumps({"removed": False, "error": str(e)}))
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
# Includes ToolNotFoundError (no package manager).
|
|
70
|
+
print(json.dumps({"error": str(e)}))
|
|
71
|
+
sys.exit(1)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Kernel tuning that needs root."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import runspec as rs
|
|
7
|
+
from runspec_linux_core import set_sysctl
|
|
8
|
+
from runspec_linux_core.errors import CommandError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main_set_sysctl() -> None:
|
|
12
|
+
spec = rs.parse("set-sysctl")
|
|
13
|
+
key = spec.key.value
|
|
14
|
+
value = spec.value.value
|
|
15
|
+
persist = spec.persist.value
|
|
16
|
+
try:
|
|
17
|
+
print(json.dumps(set_sysctl(key, value, persist=persist)))
|
|
18
|
+
except CommandError as e:
|
|
19
|
+
print(json.dumps({"key": key, "applied": False, "error": str(e)}))
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
print(json.dumps({"error": str(e)}))
|
|
23
|
+
sys.exit(1)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import runspec as rs
|
|
5
|
+
from runspec_linux_core import reboot_host
|
|
6
|
+
from runspec_linux_core.errors import CommandError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main_reboot_host() -> None:
|
|
10
|
+
spec = rs.parse("reboot-host")
|
|
11
|
+
delay_minutes = spec.delay_minutes.value
|
|
12
|
+
try:
|
|
13
|
+
print(json.dumps(reboot_host(delay_minutes)))
|
|
14
|
+
except CommandError as e:
|
|
15
|
+
print(json.dumps({"scheduled": False, "error": str(e)}))
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
except Exception as e:
|
|
18
|
+
# Includes ToolNotFoundError (no shutdown/systemctl).
|
|
19
|
+
print(json.dumps({"error": str(e)}))
|
|
20
|
+
sys.exit(1)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# runspec-linux-root — Linux admin tools that need root.
|
|
2
|
+
#
|
|
3
|
+
# Install into a ROOT-OWNED venv (e.g. sudo python3 -m venv /opt/venvs/admin):
|
|
4
|
+
# root runs this code, so only root may be able to change it. Every tool declares
|
|
5
|
+
# run_as = "root"; sudoers decides who may run them.
|
|
6
|
+
|
|
7
|
+
[config]
|
|
8
|
+
autonomy-default = "confirm"
|
|
9
|
+
|
|
10
|
+
[config.logging]
|
|
11
|
+
store = "per-run"
|
|
12
|
+
|
|
13
|
+
# ── Packages ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
[list-upgrades]
|
|
16
|
+
serve = ["local"]
|
|
17
|
+
description = "List packages with an available upgrade (refreshes the package index first)"
|
|
18
|
+
autonomy = "autonomous"
|
|
19
|
+
output = "json"
|
|
20
|
+
run_as = "root"
|
|
21
|
+
|
|
22
|
+
[list-upgrades.args.no-refresh]
|
|
23
|
+
type = "flag"
|
|
24
|
+
description = "Skip refreshing the package index — list from the cached index (faster)"
|
|
25
|
+
default = false
|
|
26
|
+
|
|
27
|
+
[upgrade-packages]
|
|
28
|
+
serve = ["local"]
|
|
29
|
+
description = "Refresh the package index and upgrade all installed packages (needs root)"
|
|
30
|
+
autonomy = "confirm"
|
|
31
|
+
output = "json"
|
|
32
|
+
run_as = "root"
|
|
33
|
+
|
|
34
|
+
[install-package]
|
|
35
|
+
serve = ["local"]
|
|
36
|
+
description = "Install one or more packages by name (needs root)"
|
|
37
|
+
autonomy = "confirm"
|
|
38
|
+
output = "json"
|
|
39
|
+
run_as = "root"
|
|
40
|
+
|
|
41
|
+
[install-package.args.package]
|
|
42
|
+
type = "str"
|
|
43
|
+
short = "-p"
|
|
44
|
+
description = "Package name to install (space-separated for several)"
|
|
45
|
+
|
|
46
|
+
[remove-package]
|
|
47
|
+
serve = ["local"]
|
|
48
|
+
description = "Remove one or more packages by name (needs root)"
|
|
49
|
+
autonomy = "confirm"
|
|
50
|
+
output = "json"
|
|
51
|
+
run_as = "root"
|
|
52
|
+
|
|
53
|
+
[remove-package.args.package]
|
|
54
|
+
type = "str"
|
|
55
|
+
short = "-p"
|
|
56
|
+
description = "Package name to remove (space-separated for several)"
|
|
57
|
+
|
|
58
|
+
[remove-package.args.purge]
|
|
59
|
+
type = "flag"
|
|
60
|
+
description = "Also delete the packages' configuration files (apt purge / pacman -Rn; a plain remove on dnf/yum/zypper)"
|
|
61
|
+
default = false
|
|
62
|
+
|
|
63
|
+
[autoremove-packages]
|
|
64
|
+
serve = ["local"]
|
|
65
|
+
description = "Remove packages nothing depends on any more, e.g. old kernels (apt autoremove and equivalents; needs root)"
|
|
66
|
+
autonomy = "confirm"
|
|
67
|
+
output = "json"
|
|
68
|
+
run_as = "root"
|
|
69
|
+
|
|
70
|
+
[autoremove-packages.args.purge]
|
|
71
|
+
type = "flag"
|
|
72
|
+
description = "Also delete the removed packages' configuration files (apt --purge / pacman -n)"
|
|
73
|
+
default = false
|
|
74
|
+
|
|
75
|
+
[autoremove-packages.args.dry-run]
|
|
76
|
+
type = "flag"
|
|
77
|
+
description = "Only list what would be removed; remove nothing (not supported on yum). list-autoremovable does the same without needing approval"
|
|
78
|
+
default = false
|
|
79
|
+
|
|
80
|
+
# ── Kernel ────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
[set-sysctl]
|
|
83
|
+
serve = ["local"]
|
|
84
|
+
description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d (needs root)"
|
|
85
|
+
autonomy = "confirm"
|
|
86
|
+
output = "json"
|
|
87
|
+
run_as = "root"
|
|
88
|
+
|
|
89
|
+
[set-sysctl.args.key]
|
|
90
|
+
type = "str"
|
|
91
|
+
short = "-k"
|
|
92
|
+
description = "Dotted sysctl key (e.g. net.core.somaxconn)"
|
|
93
|
+
|
|
94
|
+
[set-sysctl.args.value]
|
|
95
|
+
type = "str"
|
|
96
|
+
description = "New value to set"
|
|
97
|
+
|
|
98
|
+
[set-sysctl.args.persist]
|
|
99
|
+
type = "flag"
|
|
100
|
+
description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
|
|
101
|
+
default = false
|
|
102
|
+
|
|
103
|
+
# ── Power ─────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
[reboot-host]
|
|
106
|
+
serve = ["local"]
|
|
107
|
+
description = "Reboot the host, optionally after a delay in minutes (needs root)"
|
|
108
|
+
autonomy = "confirm"
|
|
109
|
+
output = "json"
|
|
110
|
+
run_as = "root"
|
|
111
|
+
|
|
112
|
+
[reboot-host.args.delay-minutes]
|
|
113
|
+
type = "int"
|
|
114
|
+
short = "-d"
|
|
115
|
+
description = "Minutes to wait before rebooting (0 = immediately)"
|
|
116
|
+
default = 0
|
|
117
|
+
|
|
118
|
+
# ── Privilege bootstrap ───────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
# One-time setup so the run_as="root" runnables above need no password. Itself
|
|
121
|
+
# escalates with the supplied password via `sudo -S` (read from stdin, never
|
|
122
|
+
# argv), so it runs as the invoker (run_as = ""). Validated with visudo before +
|
|
123
|
+
# after install and rolled back on failure.
|
|
124
|
+
[enable-passwordless-sudo]
|
|
125
|
+
serve = ["local"]
|
|
126
|
+
description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
|
|
127
|
+
autonomy = "confirm"
|
|
128
|
+
output = "json"
|
|
129
|
+
run_as = ""
|
|
130
|
+
|
|
131
|
+
[enable-passwordless-sudo.args.password]
|
|
132
|
+
type = "password"
|
|
133
|
+
description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
|
|
134
|
+
|
|
135
|
+
[enable-passwordless-sudo.args.user]
|
|
136
|
+
type = "str"
|
|
137
|
+
short = "-u"
|
|
138
|
+
description = "User to grant passwordless sudo (defaults to the current login user)"
|
|
139
|
+
required = false
|
|
140
|
+
|
|
141
|
+
[enable-passwordless-sudo.args.scope]
|
|
142
|
+
type = "choice"
|
|
143
|
+
options = ["scoped", "all"]
|
|
144
|
+
default = "scoped"
|
|
145
|
+
description = "scoped = NOPASSWD only for this venv's tools, run directly (sudo <venv>/bin/<tool> from a shell or cron); all = NOPASSWD: ALL (also needed for runs from runspec-console or the MCP gateway)"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import runspec as rs
|
|
5
|
+
from runspec_linux_core import enable_passwordless_sudo
|
|
6
|
+
from runspec_linux_core.errors import CommandError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main_enable_passwordless_sudo() -> None:
|
|
10
|
+
spec = rs.parse("enable-passwordless-sudo")
|
|
11
|
+
password = spec.password.value
|
|
12
|
+
user = spec.user.value
|
|
13
|
+
scope = spec.scope.value
|
|
14
|
+
try:
|
|
15
|
+
print(json.dumps(enable_passwordless_sudo(password, user, scope)))
|
|
16
|
+
except CommandError as e:
|
|
17
|
+
# Auth failure / sudoers validation failure (rolled back).
|
|
18
|
+
print(json.dumps({"installed": False, "error": str(e)}))
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
except Exception as e:
|
|
21
|
+
# Includes ValueError (bad scope/user) and ToolNotFoundError (no sudo/visudo).
|
|
22
|
+
print(json.dumps({"error": str(e)}))
|
|
23
|
+
sys.exit(1)
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shape of the root package's runspec.toml and entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
tomllib = pytest.importorskip("tomllib")
|
|
10
|
+
|
|
11
|
+
SPEC = tomllib.loads((files("runspec_linux_root") / "runspec.toml").read_text())
|
|
12
|
+
RUNNABLES = {k: v for k, v in SPEC.items() if k != "config"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_every_tool_runs_as_root_except_the_bootstrap() -> None:
|
|
16
|
+
for name, body in RUNNABLES.items():
|
|
17
|
+
expected = "" if name == "enable-passwordless-sudo" else "root"
|
|
18
|
+
assert body.get("run_as") == expected, name
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_no_per_invocation_escalation_args() -> None:
|
|
22
|
+
for name, body in RUNNABLES.items():
|
|
23
|
+
args = body.get("args") or {}
|
|
24
|
+
assert not {"run-as", "sudo", "become-method"} & set(args), name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_mutating_tools_need_confirmation() -> None:
|
|
28
|
+
for name, body in RUNNABLES.items():
|
|
29
|
+
if name != "list-upgrades":
|
|
30
|
+
assert body.get("autonomy", SPEC["config"]["autonomy-default"]) == "confirm", name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_entry_points_match_runnables() -> None:
|
|
34
|
+
import runspec_linux_root.packages as packages
|
|
35
|
+
import runspec_linux_root.perf as perf
|
|
36
|
+
import runspec_linux_root.power as power
|
|
37
|
+
import runspec_linux_root.sudoers as sudoers
|
|
38
|
+
|
|
39
|
+
mains = {
|
|
40
|
+
"list-upgrades": packages.main_list_upgrades,
|
|
41
|
+
"upgrade-packages": packages.main_upgrade_packages,
|
|
42
|
+
"install-package": packages.main_install_package,
|
|
43
|
+
"remove-package": packages.main_remove_package,
|
|
44
|
+
"autoremove-packages": packages.main_autoremove_packages,
|
|
45
|
+
"set-sysctl": perf.main_set_sysctl,
|
|
46
|
+
"reboot-host": power.main_reboot_host,
|
|
47
|
+
"enable-passwordless-sudo": sudoers.main_enable_passwordless_sudo,
|
|
48
|
+
}
|
|
49
|
+
assert set(mains) == set(RUNNABLES)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_set_sysctl_runs_the_core_helper_without_escalating(monkeypatch, capsys) -> None:
|
|
53
|
+
# Already root (run_as = "root"), so the wrapper passes no run_as of its own.
|
|
54
|
+
import json
|
|
55
|
+
|
|
56
|
+
import runspec_linux_root.perf as perf
|
|
57
|
+
|
|
58
|
+
calls = []
|
|
59
|
+
monkeypatch.setattr(perf, "set_sysctl", lambda key, value, **kw: calls.append((key, value, kw)) or {"key": key, "applied": True})
|
|
60
|
+
monkeypatch.setattr("sys.argv", ["set-sysctl", "--key", "vm.swappiness", "--value", "10"])
|
|
61
|
+
# The suite doesn't run as root; stub the identity check the parser uses.
|
|
62
|
+
monkeypatch.setattr("runspec.parser.verify_run_as_identity", lambda user: None)
|
|
63
|
+
perf.main_set_sysctl()
|
|
64
|
+
assert calls == [("vm.swappiness", "10", {"persist": False})]
|
|
65
|
+
assert json.loads(capsys.readouterr().out)["applied"] is True
|