runspec-linux-root 0.1.0__tar.gz → 0.2.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 → runspec_linux_root-0.2.0}/.gitignore +5 -0
- runspec_linux_root-0.2.0/CHANGELOG.md +54 -0
- runspec_linux_root-0.2.0/PKG-INFO +130 -0
- runspec_linux_root-0.2.0/README.md +112 -0
- {runspec_linux_root-0.1.0 → runspec_linux_root-0.2.0}/pyproject.toml +8 -6
- runspec_linux_root-0.2.0/runspec_linux_root/repos.py +55 -0
- runspec_linux_root-0.2.0/runspec_linux_root/runspec.toml +241 -0
- runspec_linux_root-0.2.0/runspec_linux_root/services.py +26 -0
- runspec_linux_root-0.2.0/tests/test_spec.py +151 -0
- runspec_linux_root-0.1.0/CHANGELOG.md +0 -21
- runspec_linux_root-0.1.0/PKG-INFO +0 -70
- runspec_linux_root-0.1.0/README.md +0 -52
- runspec_linux_root-0.1.0/runspec_linux_root/runspec.toml +0 -145
- runspec_linux_root-0.1.0/tests/test_spec.py +0 -65
- {runspec_linux_root-0.1.0 → runspec_linux_root-0.2.0}/runspec_linux_root/__init__.py +0 -0
- {runspec_linux_root-0.1.0 → runspec_linux_root-0.2.0}/runspec_linux_root/packages.py +0 -0
- {runspec_linux_root-0.1.0 → runspec_linux_root-0.2.0}/runspec_linux_root/perf.py +0 -0
- {runspec_linux_root-0.1.0 → runspec_linux_root-0.2.0}/runspec_linux_root/power.py +0 -0
- {runspec_linux_root-0.1.0 → runspec_linux_root-0.2.0}/runspec_linux_root/sudoers.py +0 -0
- {runspec_linux_root-0.1.0 → runspec_linux_root-0.2.0}/tests/__init__.py +0 -0
|
@@ -25,6 +25,8 @@ coverage.xml
|
|
|
25
25
|
# Node
|
|
26
26
|
node_modules/
|
|
27
27
|
dist/
|
|
28
|
+
# runspec manifest output (written at npm pack time)
|
|
29
|
+
runspec-files.json
|
|
28
30
|
*.js.map
|
|
29
31
|
.npm
|
|
30
32
|
|
|
@@ -59,3 +61,6 @@ chainlit-docs/
|
|
|
59
61
|
|
|
60
62
|
# Stray committed test venv (removed from tracking)
|
|
61
63
|
.venv-test/
|
|
64
|
+
|
|
65
|
+
# Private gitleaks rules (see .gitleaks.toml)
|
|
66
|
+
.gitleaks.local.toml
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-linux-root` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.2.0] — 2026-11-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`package-repo`** (`list` / `add` / `remove`): add a vendor's package
|
|
10
|
+
repository and its signing key without a shell, the way install guides like
|
|
11
|
+
Grafana Alloy's do it. One call takes the apt pair (`--apt-url`,
|
|
12
|
+
`--apt-key`, `--apt-suite`, `--apt-components`) and the rpm pair
|
|
13
|
+
(`--rpm-url`, `--rpm-key`, for dnf, yum and zypper); each host uses its own,
|
|
14
|
+
so one call covers a mixed group of hosts. apt keys go to
|
|
15
|
+
`/etc/apt/keyrings/<name>.asc` with `signed-by=`; rpm keys to
|
|
16
|
+
`/etc/pki/rpm-gpg/` plus `rpm --import`, with `gpgcheck=1` and
|
|
17
|
+
`repo_gpgcheck=1` (`--unsigned-metadata` turns the second off). The output
|
|
18
|
+
lists every key's fingerprint, `--key-fingerprint` refuses a key file
|
|
19
|
+
holding a key you didn't list, and an `http://` key needs one. `add`
|
|
20
|
+
refreshes just that repository and undoes everything if the refresh fails;
|
|
21
|
+
running it again with the same values changes nothing. `--replace`
|
|
22
|
+
overwrites a same-named repository set up by hand; `--proxy` and
|
|
23
|
+
`--ca-bundle` apply to the key download. `list` is autonomous, `add` and
|
|
24
|
+
`remove` need approval. pacman hosts are refused.
|
|
25
|
+
- **`system-service`** (`start` / `stop` / `restart` / `reload` / `enable` /
|
|
26
|
+
`disable`, `--now` with enable/disable): systemd service control as root,
|
|
27
|
+
for system services such as `alloy` (runspec-linux's `restart-service` never
|
|
28
|
+
runs as root). A failure includes the unit's last journal lines.
|
|
29
|
+
- Needs `runspec-linux-core >= 0.15.0`.
|
|
30
|
+
|
|
31
|
+
## [0.1.1] — 2026-10-01
|
|
32
|
+
|
|
33
|
+
- `enable-passwordless-sudo --scope scoped`: the help text and README now say
|
|
34
|
+
the scoped grant also covers runspec-console and MCP-gateway runs, from
|
|
35
|
+
runspec 0.70.0 (their env prelude runs on the venv's `bin/python`).
|
|
36
|
+
`--scope all` is no longer needed for those. No code change.
|
|
37
|
+
|
|
38
|
+
## [0.1.0] — 2026-09-30
|
|
39
|
+
|
|
40
|
+
First release. The Linux tools that need root, split out of `runspec-linux`
|
|
41
|
+
(0.19.0) so that package never runs anything as root:
|
|
42
|
+
`list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`,
|
|
43
|
+
`autoremove-packages`, `set-sysctl`, `reboot-host` and
|
|
44
|
+
`enable-passwordless-sudo`.
|
|
45
|
+
|
|
46
|
+
- Every tool declares `run_as = "root"` (except `enable-passwordless-sudo`,
|
|
47
|
+
which runs as the invoker and escalates with the password it's given).
|
|
48
|
+
`set-sysctl` loses its `--run-as`/`--sudo`/`--become-method` args: it always
|
|
49
|
+
runs as root.
|
|
50
|
+
- Meant for a root-owned venv, so only root can change code that root runs.
|
|
51
|
+
- `enable-passwordless-sudo`'s `scoped` option is documented honestly: it covers
|
|
52
|
+
direct `sudo <tool>` runs only, not runs from runspec-console or the MCP
|
|
53
|
+
gateway.
|
|
54
|
+
- The helpers stay in `runspec-linux-core` (>= 0.13.0).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runspec-linux-root
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Linux admin runnables that need root (packages, package repositories, services, reboot, sysctl) for runspec
|
|
5
|
+
Project-URL: Documentation, https://runspec.app/
|
|
6
|
+
Project-URL: Source, https://github.com/runspec-app/runspec/tree/main/packages/python/runspec-linux-root
|
|
7
|
+
Project-URL: Changelog, https://github.com/runspec-app/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/runspec-app/runspec/issues
|
|
9
|
+
Keywords: linux,packages,root,runnable,runspec,sysadmin
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: runspec-linux-core>=0.15.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
|
+
| `package-repo list` | List the package repositories configured on the host | autonomous |
|
|
31
|
+
| `package-repo add` | Add or update a repository and its signing key, then refresh it (undone if that fails) | confirm |
|
|
32
|
+
| `package-repo remove` | Remove a repository and its key file | confirm |
|
|
33
|
+
| `system-service <action>` | `start`, `stop`, `restart`, `reload`, `enable` or `disable` a systemd service (`--now` with enable/disable) | confirm |
|
|
34
|
+
| `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
|
|
35
|
+
| `reboot-host` | Reboot, optionally after a delay | confirm |
|
|
36
|
+
| `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
|
|
37
|
+
|
|
38
|
+
They work across apt, dnf, yum, zypper and pacman (`package-repo`: all but pacman). The logic lives in
|
|
39
|
+
[`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
|
|
40
|
+
everyday tools that never need root are in
|
|
41
|
+
[`runspec-linux`](https://pypi.org/project/runspec-linux/).
|
|
42
|
+
|
|
43
|
+
## Install into a root-owned venv
|
|
44
|
+
|
|
45
|
+
Every tool here declares `run_as = "root"`: root runs this code. So install it
|
|
46
|
+
into a venv **only root can change**. A venv owned by a service account would
|
|
47
|
+
let that account edit code root later runs.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
sudo python3 -m venv /opt/venvs/admin
|
|
51
|
+
sudo /opt/venvs/admin/bin/pip install runspec-linux-root
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Who can run them
|
|
55
|
+
|
|
56
|
+
sudoers decides. An admin runs a tool with sudo:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
sudo /opt/venvs/admin/bin/upgrade-packages
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
runspec checks the process really is root (`enforce_run_as`) and refuses
|
|
63
|
+
otherwise, naming the account to `sudo -u` to.
|
|
64
|
+
|
|
65
|
+
**From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
|
|
66
|
+
which never prompts for a password, so the admin needs a passwordless sudo rule.
|
|
67
|
+
`enable-passwordless-sudo` installs one:
|
|
68
|
+
|
|
69
|
+
- `--scope scoped` allows only the programs in this venv's `bin/`. That covers
|
|
70
|
+
direct runs (`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron) and,
|
|
71
|
+
with runspec-console / runspec-mcp built on runspec ≥ 0.70.0, console and
|
|
72
|
+
gateway runs too. Those pass variables to the tool on stdin through a small
|
|
73
|
+
prelude that runs on the venv's own `bin/python`, which the scoped rule covers.
|
|
74
|
+
(Before runspec 0.70.0 the prelude ran on `sh`, which the scoped rule doesn't
|
|
75
|
+
cover, so console runs that carried variables needed `--scope all`.)
|
|
76
|
+
- `--scope all` grants `NOPASSWD: ALL`.
|
|
77
|
+
|
|
78
|
+
## Adding a package repository
|
|
79
|
+
|
|
80
|
+
Vendor install guides (Grafana Alloy, Docker, PostgreSQL, ...) start with "add
|
|
81
|
+
our key and repository". `package-repo add` does that without a shell:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
package-repo add grafana \
|
|
85
|
+
--apt-url https://apt.grafana.com --apt-key https://apt.grafana.com/gpg-full.key \
|
|
86
|
+
--rpm-url https://rpm.grafana.com --rpm-key https://rpm.grafana.com/gpg.key
|
|
87
|
+
install-package alloy
|
|
88
|
+
system-service enable alloy --now
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- One call carries the **apt** pair and the **rpm** pair (dnf, yum and zypper).
|
|
92
|
+
Each host uses the pair for its own package manager, so the same call works
|
|
93
|
+
on every host in a mixed group. A missing pair is an error naming it.
|
|
94
|
+
- **apt**: the key goes to `/etc/apt/keyrings/<name>.asc` and
|
|
95
|
+
`/etc/apt/sources.list.d/<name>.list` names it with `signed-by=`, so the key
|
|
96
|
+
is trusted for that repository only. `--apt-suite` (default `stable`) and
|
|
97
|
+
`--apt-components` (default `main`) fill in the rest of the `deb` line.
|
|
98
|
+
- **dnf / yum / zypper**: the key goes to `/etc/pki/rpm-gpg/RPM-GPG-KEY-<name>`
|
|
99
|
+
and is imported with `rpm --import`; `/etc/yum.repos.d/<name>.repo` (or
|
|
100
|
+
`/etc/zypp/repos.d/`) has `gpgcheck=1`, `repo_gpgcheck=1` and
|
|
101
|
+
`gpgkey=file://` pointing at that file. `--unsigned-metadata` sets
|
|
102
|
+
`repo_gpgcheck=0` for a repository whose metadata isn't signed.
|
|
103
|
+
- **The key** is an `https://` URL or a file already on the host (`http://`
|
|
104
|
+
only with `--key-fingerprint`). The output lists every key's fingerprint.
|
|
105
|
+
`--key-fingerprint` (repeat it, or separate with commas) refuses a key file
|
|
106
|
+
holding any key you didn't list. Check them with the vendor first. A key file
|
|
107
|
+
can hold several keys: Grafana's apt file has three.
|
|
108
|
+
- **Then it refreshes just that repository.** If the refresh fails (wrong key,
|
|
109
|
+
bad URL) the files are put back and newly imported rpm keys removed, so a
|
|
110
|
+
host is never left with a repository that breaks `apt-get update`. On
|
|
111
|
+
dnf/yum, a new key also clears dnf's cached copy of the old one, so the
|
|
112
|
+
refresh really checks it.
|
|
113
|
+
- **Running it again** with the same values changes nothing (no refresh). A
|
|
114
|
+
same-named file it didn't write (one you set up by hand) needs `--replace`.
|
|
115
|
+
On apt, the same URL in another file is refused: apt won't accept one
|
|
116
|
+
repository with two keys. Remove the old one first.
|
|
117
|
+
- The key download uses the proxy environment variables, or `--proxy`, and
|
|
118
|
+
`--ca-bundle` for a private CA. The package manager uses its own proxy
|
|
119
|
+
settings.
|
|
120
|
+
|
|
121
|
+
`package-repo remove <name>` deletes the repository file and its key file
|
|
122
|
+
(unless another repository uses that key), including one set up by hand
|
|
123
|
+
following the vendor's guide. Keys already in the rpm database stay.
|
|
124
|
+
|
|
125
|
+
## Across a group of hosts
|
|
126
|
+
|
|
127
|
+
From runspec-console, ask the agent to run it with a **host group** as the
|
|
128
|
+
target: it runs on every host in the group behind one approval, and each host
|
|
129
|
+
picks its own apt or rpm details. A saved runbook can hold the whole sequence
|
|
130
|
+
(add the repository, `install-package`, then `system-service enable --now`).
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
| `package-repo list` | List the package repositories configured on the host | autonomous |
|
|
13
|
+
| `package-repo add` | Add or update a repository and its signing key, then refresh it (undone if that fails) | confirm |
|
|
14
|
+
| `package-repo remove` | Remove a repository and its key file | confirm |
|
|
15
|
+
| `system-service <action>` | `start`, `stop`, `restart`, `reload`, `enable` or `disable` a systemd service (`--now` with enable/disable) | confirm |
|
|
16
|
+
| `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
|
|
17
|
+
| `reboot-host` | Reboot, optionally after a delay | confirm |
|
|
18
|
+
| `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
|
|
19
|
+
|
|
20
|
+
They work across apt, dnf, yum, zypper and pacman (`package-repo`: all but pacman). The logic lives in
|
|
21
|
+
[`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
|
|
22
|
+
everyday tools that never need root are in
|
|
23
|
+
[`runspec-linux`](https://pypi.org/project/runspec-linux/).
|
|
24
|
+
|
|
25
|
+
## Install into a root-owned venv
|
|
26
|
+
|
|
27
|
+
Every tool here declares `run_as = "root"`: root runs this code. So install it
|
|
28
|
+
into a venv **only root can change**. A venv owned by a service account would
|
|
29
|
+
let that account edit code root later runs.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
sudo python3 -m venv /opt/venvs/admin
|
|
33
|
+
sudo /opt/venvs/admin/bin/pip install runspec-linux-root
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Who can run them
|
|
37
|
+
|
|
38
|
+
sudoers decides. An admin runs a tool with sudo:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
sudo /opt/venvs/admin/bin/upgrade-packages
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
runspec checks the process really is root (`enforce_run_as`) and refuses
|
|
45
|
+
otherwise, naming the account to `sudo -u` to.
|
|
46
|
+
|
|
47
|
+
**From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
|
|
48
|
+
which never prompts for a password, so the admin needs a passwordless sudo rule.
|
|
49
|
+
`enable-passwordless-sudo` installs one:
|
|
50
|
+
|
|
51
|
+
- `--scope scoped` allows only the programs in this venv's `bin/`. That covers
|
|
52
|
+
direct runs (`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron) and,
|
|
53
|
+
with runspec-console / runspec-mcp built on runspec ≥ 0.70.0, console and
|
|
54
|
+
gateway runs too. Those pass variables to the tool on stdin through a small
|
|
55
|
+
prelude that runs on the venv's own `bin/python`, which the scoped rule covers.
|
|
56
|
+
(Before runspec 0.70.0 the prelude ran on `sh`, which the scoped rule doesn't
|
|
57
|
+
cover, so console runs that carried variables needed `--scope all`.)
|
|
58
|
+
- `--scope all` grants `NOPASSWD: ALL`.
|
|
59
|
+
|
|
60
|
+
## Adding a package repository
|
|
61
|
+
|
|
62
|
+
Vendor install guides (Grafana Alloy, Docker, PostgreSQL, ...) start with "add
|
|
63
|
+
our key and repository". `package-repo add` does that without a shell:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
package-repo add grafana \
|
|
67
|
+
--apt-url https://apt.grafana.com --apt-key https://apt.grafana.com/gpg-full.key \
|
|
68
|
+
--rpm-url https://rpm.grafana.com --rpm-key https://rpm.grafana.com/gpg.key
|
|
69
|
+
install-package alloy
|
|
70
|
+
system-service enable alloy --now
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- One call carries the **apt** pair and the **rpm** pair (dnf, yum and zypper).
|
|
74
|
+
Each host uses the pair for its own package manager, so the same call works
|
|
75
|
+
on every host in a mixed group. A missing pair is an error naming it.
|
|
76
|
+
- **apt**: the key goes to `/etc/apt/keyrings/<name>.asc` and
|
|
77
|
+
`/etc/apt/sources.list.d/<name>.list` names it with `signed-by=`, so the key
|
|
78
|
+
is trusted for that repository only. `--apt-suite` (default `stable`) and
|
|
79
|
+
`--apt-components` (default `main`) fill in the rest of the `deb` line.
|
|
80
|
+
- **dnf / yum / zypper**: the key goes to `/etc/pki/rpm-gpg/RPM-GPG-KEY-<name>`
|
|
81
|
+
and is imported with `rpm --import`; `/etc/yum.repos.d/<name>.repo` (or
|
|
82
|
+
`/etc/zypp/repos.d/`) has `gpgcheck=1`, `repo_gpgcheck=1` and
|
|
83
|
+
`gpgkey=file://` pointing at that file. `--unsigned-metadata` sets
|
|
84
|
+
`repo_gpgcheck=0` for a repository whose metadata isn't signed.
|
|
85
|
+
- **The key** is an `https://` URL or a file already on the host (`http://`
|
|
86
|
+
only with `--key-fingerprint`). The output lists every key's fingerprint.
|
|
87
|
+
`--key-fingerprint` (repeat it, or separate with commas) refuses a key file
|
|
88
|
+
holding any key you didn't list. Check them with the vendor first. A key file
|
|
89
|
+
can hold several keys: Grafana's apt file has three.
|
|
90
|
+
- **Then it refreshes just that repository.** If the refresh fails (wrong key,
|
|
91
|
+
bad URL) the files are put back and newly imported rpm keys removed, so a
|
|
92
|
+
host is never left with a repository that breaks `apt-get update`. On
|
|
93
|
+
dnf/yum, a new key also clears dnf's cached copy of the old one, so the
|
|
94
|
+
refresh really checks it.
|
|
95
|
+
- **Running it again** with the same values changes nothing (no refresh). A
|
|
96
|
+
same-named file it didn't write (one you set up by hand) needs `--replace`.
|
|
97
|
+
On apt, the same URL in another file is refused: apt won't accept one
|
|
98
|
+
repository with two keys. Remove the old one first.
|
|
99
|
+
- The key download uses the proxy environment variables, or `--proxy`, and
|
|
100
|
+
`--ca-bundle` for a private CA. The package manager uses its own proxy
|
|
101
|
+
settings.
|
|
102
|
+
|
|
103
|
+
`package-repo remove <name>` deletes the repository file and its key file
|
|
104
|
+
(unless another repository uses that key), including one set up by hand
|
|
105
|
+
following the vendor's guide. Keys already in the rpm database stay.
|
|
106
|
+
|
|
107
|
+
## Across a group of hosts
|
|
108
|
+
|
|
109
|
+
From runspec-console, ask the agent to run it with a **host group** as the
|
|
110
|
+
target: it runs on every host in the group behind one approval, and each host
|
|
111
|
+
picks its own apt or rpm details. A saved runbook can hold the whole sequence
|
|
112
|
+
(add the repository, `install-package`, then `system-service enable --now`).
|
|
@@ -4,21 +4,21 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-linux-root"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
|
-
description = "Linux admin runnables that need root (packages, reboot, sysctl) for runspec"
|
|
9
|
+
description = "Linux admin runnables that need root (packages, package repositories, services, reboot, sysctl) for runspec"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
keywords = ["runspec", "runnable", "linux", "sysadmin", "root", "packages"]
|
|
12
12
|
dependencies = [
|
|
13
13
|
"runspec>=0.68.0",
|
|
14
|
-
"runspec-linux-core>=0.
|
|
14
|
+
"runspec-linux-core>=0.15.0",
|
|
15
15
|
]
|
|
16
16
|
|
|
17
17
|
[project.urls]
|
|
18
18
|
Documentation = "https://runspec.app/"
|
|
19
|
-
Source = "https://github.com/
|
|
20
|
-
Changelog = "https://github.com/
|
|
21
|
-
Issues = "https://github.com/
|
|
19
|
+
Source = "https://github.com/runspec-app/runspec/tree/main/packages/python/runspec-linux-root"
|
|
20
|
+
Changelog = "https://github.com/runspec-app/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md"
|
|
21
|
+
Issues = "https://github.com/runspec-app/runspec/issues"
|
|
22
22
|
|
|
23
23
|
[project.scripts]
|
|
24
24
|
list-upgrades = "runspec_linux_root.packages:main_list_upgrades"
|
|
@@ -26,6 +26,8 @@ upgrade-packages = "runspec_linux_root.packages:main_upgrade_packages"
|
|
|
26
26
|
install-package = "runspec_linux_root.packages:main_install_package"
|
|
27
27
|
remove-package = "runspec_linux_root.packages:main_remove_package"
|
|
28
28
|
autoremove-packages = "runspec_linux_root.packages:main_autoremove_packages"
|
|
29
|
+
package-repo = "runspec_linux_root.repos:main"
|
|
30
|
+
system-service = "runspec_linux_root.services:main"
|
|
29
31
|
set-sysctl = "runspec_linux_root.perf:main_set_sysctl"
|
|
30
32
|
reboot-host = "runspec_linux_root.power:main_reboot_host"
|
|
31
33
|
enable-passwordless-sudo = "runspec_linux_root.sudoers:main_enable_passwordless_sudo"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``package-repo``: list, add and remove package repositories (apt, dnf/yum, zypper)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import runspec as rs
|
|
10
|
+
from runspec_linux_core.errors import LinuxCoreError
|
|
11
|
+
from runspec_linux_core.repos import add_repo, list_repos, remove_repo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
15
|
+
print(json.dumps(payload, default=str))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run(spec: rs.RunSpec) -> dict[str, Any]:
|
|
19
|
+
command = spec.runspec_command
|
|
20
|
+
if command == "list":
|
|
21
|
+
return list_repos(spec.filter.value or None)
|
|
22
|
+
if command == "add":
|
|
23
|
+
return add_repo(
|
|
24
|
+
spec.name.value,
|
|
25
|
+
apt_url=spec.apt_url.value or None,
|
|
26
|
+
apt_key=spec.apt_key.value or None,
|
|
27
|
+
apt_suite=spec.apt_suite.value,
|
|
28
|
+
apt_components=spec.apt_components.value,
|
|
29
|
+
rpm_url=spec.rpm_url.value or None,
|
|
30
|
+
rpm_key=spec.rpm_key.value or None,
|
|
31
|
+
key_fingerprints=list(spec.key_fingerprint.value or []),
|
|
32
|
+
signed_metadata=not spec.unsigned_metadata.value,
|
|
33
|
+
replace=spec.replace.value,
|
|
34
|
+
refresh=not spec.no_refresh.value,
|
|
35
|
+
proxy=spec.proxy.value or None,
|
|
36
|
+
ca_bundle=spec.ca_bundle.value or None,
|
|
37
|
+
)
|
|
38
|
+
if command == "remove":
|
|
39
|
+
return remove_repo(spec.name.value)
|
|
40
|
+
raise SystemExit("no command — see `package-repo --help`") # pragma: no cover (require-command)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
spec = rs.parse("package-repo")
|
|
45
|
+
try:
|
|
46
|
+
result = _run(spec)
|
|
47
|
+
except (LinuxCoreError, ValueError) as exc:
|
|
48
|
+
# LinuxCoreError covers ToolNotFoundError (no package manager) and CommandError.
|
|
49
|
+
_emit({"error": str(exc), "kind": type(exc).__name__})
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
_emit(result)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
# ── Package repositories ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
# One call carries the apt and the rpm details; each host uses the pair for its
|
|
83
|
+
# own package manager, so one call (or one group fan-out from runspec-console)
|
|
84
|
+
# covers a mixed Debian/RHEL/SUSE fleet.
|
|
85
|
+
[package-repo]
|
|
86
|
+
serve = ["local"]
|
|
87
|
+
description = "Manage package repositories (apt, dnf/yum, zypper) and their signing keys: list, add, remove"
|
|
88
|
+
output = "json"
|
|
89
|
+
run_as = "root"
|
|
90
|
+
require-command = true
|
|
91
|
+
examples = [
|
|
92
|
+
{cmd = "package-repo list", description = "List the repositories configured on this host"},
|
|
93
|
+
{cmd = "package-repo add grafana --apt-url https://apt.grafana.com --apt-key https://apt.grafana.com/gpg-full.key --rpm-url https://rpm.grafana.com --rpm-key https://rpm.grafana.com/gpg.key", description = "Add Grafana's repository (for Alloy) on an apt, dnf, yum or zypper host"},
|
|
94
|
+
{cmd = "package-repo add grafana <same args> --key-fingerprint <fingerprint>,<fingerprint>", description = "Only trust the key file if every key in it is one you listed (a first add prints them)"},
|
|
95
|
+
{cmd = "package-repo remove grafana", description = "Remove the repository and its key file"},
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
[package-repo.commands.list]
|
|
99
|
+
description = "List the package repositories configured on this host (file, URLs, enabled, signing key, and whether package-repo added it)"
|
|
100
|
+
autonomy = "autonomous"
|
|
101
|
+
[package-repo.commands.list.args]
|
|
102
|
+
filter = {type = "str", required = false, description = "Only repositories whose name, id or URL contains this text"}
|
|
103
|
+
|
|
104
|
+
[package-repo.commands.add]
|
|
105
|
+
description = "Add or update a package repository: download its signing key, write the repo file (key trusted for this repo only), then refresh just that repo — undone if the refresh fails. Same values again changes nothing"
|
|
106
|
+
autonomy = "confirm"
|
|
107
|
+
[package-repo.commands.add.args]
|
|
108
|
+
name = {type = "str", position = 1, pattern = "[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", description = "Repository name, used for the file names (e.g. grafana → /etc/apt/sources.list.d/grafana.list or /etc/yum.repos.d/grafana.repo)"}
|
|
109
|
+
apt-url = {type = "str", required = false, description = "apt hosts: the repository URL (e.g. https://apt.grafana.com)"}
|
|
110
|
+
apt-key = {type = "str", required = false, description = "apt hosts: the signing key — an https:// URL or a file path on the host (e.g. https://apt.grafana.com/gpg-full.key)"}
|
|
111
|
+
apt-suite = {type = "str", default = "stable", description = "apt hosts: the suite / distribution (e.g. stable, bookworm, noble). A flat repo ends in '/'"}
|
|
112
|
+
apt-components = {type = "str", default = "main", description = "apt hosts: space-separated components (e.g. main, or 'main contrib')"}
|
|
113
|
+
rpm-url = {type = "str", required = false, description = "dnf, yum and zypper hosts: the repository baseurl (e.g. https://rpm.grafana.com; $releasever and $basearch are allowed)"}
|
|
114
|
+
rpm-key = {type = "str", required = false, description = "dnf, yum and zypper hosts: the signing key — an https:// URL or a file path on the host (e.g. https://rpm.grafana.com/gpg.key)"}
|
|
115
|
+
key-fingerprint = {type = "str", multiple = true, delimiter = ",", required = false, description = "Refuse the key file unless every key in it is listed here (full fingerprints; repeat the flag or separate with commas). The output of a first add shows them"}
|
|
116
|
+
unsigned-metadata = {type = "flag", default = false, description = "dnf/yum/zypper: the repository's metadata isn't signed (repo_gpgcheck=0). Packages are still checked (gpgcheck=1)"}
|
|
117
|
+
replace = {type = "flag", default = false, description = "Overwrite a same-named repository file that package-repo didn't add (e.g. one set up by hand)"}
|
|
118
|
+
no-refresh = {type = "flag", default = false, description = "Write the files but don't refresh the repository (nothing is checked or undone)"}
|
|
119
|
+
proxy = {type = "str", required = false, description = "Proxy for downloading the key (http://host:port), or 'direct' for none. Default: the proxy environment variables. The package manager uses its own proxy settings"}
|
|
120
|
+
ca-bundle = {type = "str", required = false, description = "PEM file of CA certificates for downloading the key from a server with a private CA"}
|
|
121
|
+
|
|
122
|
+
[package-repo.commands.remove]
|
|
123
|
+
description = "Remove a package repository by name (as shown by list) and the key file it uses, if no other repository uses it. Keys already in the rpm database stay. Removing one that isn't there is not an error"
|
|
124
|
+
autonomy = "confirm"
|
|
125
|
+
[package-repo.commands.remove.args]
|
|
126
|
+
name = {type = "str", position = 1, description = "Repository name (the file name without .list/.sources/.repo, as shown by list)"}
|
|
127
|
+
|
|
128
|
+
# ── Services ──────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
# runspec-linux's restart-service never runs as root; this is the root version,
|
|
131
|
+
# for system services (e.g. starting and enabling alloy after installing it).
|
|
132
|
+
[system-service]
|
|
133
|
+
serve = ["local"]
|
|
134
|
+
description = "Start, stop, restart, reload, enable or disable a systemd system service (needs root)"
|
|
135
|
+
output = "json"
|
|
136
|
+
run_as = "root"
|
|
137
|
+
autonomy = "confirm"
|
|
138
|
+
require-command = true
|
|
139
|
+
examples = [
|
|
140
|
+
{cmd = "system-service enable alloy --now", description = "Start alloy now and at every boot"},
|
|
141
|
+
{cmd = "system-service restart alloy", description = "Restart alloy (e.g. after changing its config)"},
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
[system-service.commands.start]
|
|
145
|
+
description = "Start a service"
|
|
146
|
+
[system-service.commands.start.args]
|
|
147
|
+
service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
|
|
148
|
+
|
|
149
|
+
[system-service.commands.stop]
|
|
150
|
+
description = "Stop a service"
|
|
151
|
+
[system-service.commands.stop.args]
|
|
152
|
+
service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
|
|
153
|
+
|
|
154
|
+
[system-service.commands.restart]
|
|
155
|
+
description = "Restart a service"
|
|
156
|
+
[system-service.commands.restart.args]
|
|
157
|
+
service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
|
|
158
|
+
|
|
159
|
+
[system-service.commands.reload]
|
|
160
|
+
description = "Ask a service to reload its configuration (not every service supports it)"
|
|
161
|
+
[system-service.commands.reload.args]
|
|
162
|
+
service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
|
|
163
|
+
|
|
164
|
+
[system-service.commands.enable]
|
|
165
|
+
description = "Start a service at boot; --now also starts it now"
|
|
166
|
+
[system-service.commands.enable.args]
|
|
167
|
+
service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
|
|
168
|
+
now = {type = "flag", default = false, description = "Also start it now"}
|
|
169
|
+
|
|
170
|
+
[system-service.commands.disable]
|
|
171
|
+
description = "Stop starting a service at boot; --now also stops it now"
|
|
172
|
+
[system-service.commands.disable.args]
|
|
173
|
+
service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
|
|
174
|
+
now = {type = "flag", default = false, description = "Also stop it now"}
|
|
175
|
+
|
|
176
|
+
# ── Kernel ────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
[set-sysctl]
|
|
179
|
+
serve = ["local"]
|
|
180
|
+
description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d (needs root)"
|
|
181
|
+
autonomy = "confirm"
|
|
182
|
+
output = "json"
|
|
183
|
+
run_as = "root"
|
|
184
|
+
|
|
185
|
+
[set-sysctl.args.key]
|
|
186
|
+
type = "str"
|
|
187
|
+
short = "-k"
|
|
188
|
+
description = "Dotted sysctl key (e.g. net.core.somaxconn)"
|
|
189
|
+
|
|
190
|
+
[set-sysctl.args.value]
|
|
191
|
+
type = "str"
|
|
192
|
+
description = "New value to set"
|
|
193
|
+
|
|
194
|
+
[set-sysctl.args.persist]
|
|
195
|
+
type = "flag"
|
|
196
|
+
description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
|
|
197
|
+
default = false
|
|
198
|
+
|
|
199
|
+
# ── Power ─────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
[reboot-host]
|
|
202
|
+
serve = ["local"]
|
|
203
|
+
description = "Reboot the host, optionally after a delay in minutes (needs root)"
|
|
204
|
+
autonomy = "confirm"
|
|
205
|
+
output = "json"
|
|
206
|
+
run_as = "root"
|
|
207
|
+
|
|
208
|
+
[reboot-host.args.delay-minutes]
|
|
209
|
+
type = "int"
|
|
210
|
+
short = "-d"
|
|
211
|
+
description = "Minutes to wait before rebooting (0 = immediately)"
|
|
212
|
+
default = 0
|
|
213
|
+
|
|
214
|
+
# ── Privilege bootstrap ───────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
# One-time setup so the run_as="root" runnables above need no password. Itself
|
|
217
|
+
# escalates with the supplied password via `sudo -S` (read from stdin, never
|
|
218
|
+
# argv), so it runs as the invoker (run_as = ""). Validated with visudo before +
|
|
219
|
+
# after install and rolled back on failure.
|
|
220
|
+
[enable-passwordless-sudo]
|
|
221
|
+
serve = ["local"]
|
|
222
|
+
description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
|
|
223
|
+
autonomy = "confirm"
|
|
224
|
+
output = "json"
|
|
225
|
+
run_as = ""
|
|
226
|
+
|
|
227
|
+
[enable-passwordless-sudo.args.password]
|
|
228
|
+
type = "password"
|
|
229
|
+
description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
|
|
230
|
+
|
|
231
|
+
[enable-passwordless-sudo.args.user]
|
|
232
|
+
type = "str"
|
|
233
|
+
short = "-u"
|
|
234
|
+
description = "User to grant passwordless sudo (defaults to the current login user)"
|
|
235
|
+
required = false
|
|
236
|
+
|
|
237
|
+
[enable-passwordless-sudo.args.scope]
|
|
238
|
+
type = "choice"
|
|
239
|
+
options = ["scoped", "all"]
|
|
240
|
+
default = "scoped"
|
|
241
|
+
description = "scoped = NOPASSWD only for the programs in this venv's bin/ (direct runs, and runspec-console / MCP gateway runs built on runspec >= 0.70.0); all = NOPASSWD: ALL"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""``system-service``: start/stop/restart/reload/enable/disable a systemd service as root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import runspec as rs
|
|
9
|
+
from runspec_linux_core.errors import LinuxCoreError
|
|
10
|
+
from runspec_linux_core.services import control_service
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
spec = rs.parse("system-service")
|
|
15
|
+
action = spec.runspec_command
|
|
16
|
+
now = spec.now.value if "now" in spec else False
|
|
17
|
+
try:
|
|
18
|
+
result = control_service(spec.service.value, action, now=bool(now))
|
|
19
|
+
except (LinuxCoreError, ValueError) as exc:
|
|
20
|
+
print(json.dumps({"service": spec.service.value, "action": action, "error": str(exc), "kind": type(exc).__name__}))
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
print(json.dumps(result))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
READ_ONLY = {("list-upgrades", None), ("package-repo", "list")}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_mutating_tools_need_confirmation() -> None:
|
|
31
|
+
default = SPEC["config"]["autonomy-default"]
|
|
32
|
+
for name, body in RUNNABLES.items():
|
|
33
|
+
commands = body.get("commands") or {None: {}}
|
|
34
|
+
for command, cbody in commands.items():
|
|
35
|
+
autonomy = cbody.get("autonomy", body.get("autonomy", default))
|
|
36
|
+
expected = "autonomous" if (name, command) in READ_ONLY else "confirm"
|
|
37
|
+
assert autonomy == expected, (name, command)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_entry_points_match_runnables() -> None:
|
|
41
|
+
import runspec_linux_root.packages as packages
|
|
42
|
+
import runspec_linux_root.perf as perf
|
|
43
|
+
import runspec_linux_root.power as power
|
|
44
|
+
import runspec_linux_root.repos as repos
|
|
45
|
+
import runspec_linux_root.services as services
|
|
46
|
+
import runspec_linux_root.sudoers as sudoers
|
|
47
|
+
|
|
48
|
+
mains = {
|
|
49
|
+
"list-upgrades": packages.main_list_upgrades,
|
|
50
|
+
"upgrade-packages": packages.main_upgrade_packages,
|
|
51
|
+
"install-package": packages.main_install_package,
|
|
52
|
+
"remove-package": packages.main_remove_package,
|
|
53
|
+
"autoremove-packages": packages.main_autoremove_packages,
|
|
54
|
+
"package-repo": repos.main,
|
|
55
|
+
"system-service": services.main,
|
|
56
|
+
"set-sysctl": perf.main_set_sysctl,
|
|
57
|
+
"reboot-host": power.main_reboot_host,
|
|
58
|
+
"enable-passwordless-sudo": sudoers.main_enable_passwordless_sudo,
|
|
59
|
+
}
|
|
60
|
+
assert set(mains) == set(RUNNABLES)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_set_sysctl_runs_the_core_helper_without_escalating(monkeypatch, capsys) -> None:
|
|
64
|
+
# Already root (run_as = "root"), so the wrapper passes no run_as of its own.
|
|
65
|
+
import json
|
|
66
|
+
|
|
67
|
+
import runspec_linux_root.perf as perf
|
|
68
|
+
|
|
69
|
+
calls = []
|
|
70
|
+
monkeypatch.setattr(perf, "set_sysctl", lambda key, value, **kw: calls.append((key, value, kw)) or {"key": key, "applied": True})
|
|
71
|
+
monkeypatch.setattr("sys.argv", ["set-sysctl", "--key", "vm.swappiness", "--value", "10"])
|
|
72
|
+
# The suite doesn't run as root; stub the identity check the parser uses.
|
|
73
|
+
monkeypatch.setattr("runspec.parser.verify_run_as_identity", lambda user: None)
|
|
74
|
+
perf.main_set_sysctl()
|
|
75
|
+
assert calls == [("vm.swappiness", "10", {"persist": False})]
|
|
76
|
+
assert json.loads(capsys.readouterr().out)["applied"] is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run_main(monkeypatch, argv, main):
|
|
80
|
+
monkeypatch.setattr("sys.argv", argv)
|
|
81
|
+
monkeypatch.setattr("runspec.parser.verify_run_as_identity", lambda user: None)
|
|
82
|
+
main()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_package_repo_add_passes_both_pairs(monkeypatch, capsys) -> None:
|
|
86
|
+
import json
|
|
87
|
+
|
|
88
|
+
import runspec_linux_root.repos as repos
|
|
89
|
+
|
|
90
|
+
seen = {}
|
|
91
|
+
monkeypatch.setattr(repos, "add_repo", lambda name, **kw: seen.update(name=name, **kw) or {"name": name, "changed": True})
|
|
92
|
+
_run_main(
|
|
93
|
+
monkeypatch,
|
|
94
|
+
[
|
|
95
|
+
"package-repo",
|
|
96
|
+
"add",
|
|
97
|
+
"grafana",
|
|
98
|
+
"--apt-url",
|
|
99
|
+
"https://apt.grafana.com",
|
|
100
|
+
"--apt-key",
|
|
101
|
+
"https://apt.grafana.com/gpg-full.key",
|
|
102
|
+
"--rpm-url",
|
|
103
|
+
"https://rpm.grafana.com",
|
|
104
|
+
"--rpm-key",
|
|
105
|
+
"https://rpm.grafana.com/gpg.key",
|
|
106
|
+
"--key-fingerprint",
|
|
107
|
+
"AAAA,BBBB",
|
|
108
|
+
"--key-fingerprint",
|
|
109
|
+
"CCCC",
|
|
110
|
+
"--no-refresh",
|
|
111
|
+
],
|
|
112
|
+
repos.main,
|
|
113
|
+
)
|
|
114
|
+
assert seen["name"] == "grafana"
|
|
115
|
+
assert seen["apt_url"] == "https://apt.grafana.com"
|
|
116
|
+
assert seen["rpm_key"] == "https://rpm.grafana.com/gpg.key"
|
|
117
|
+
assert seen["apt_suite"] == "stable" and seen["apt_components"] == "main"
|
|
118
|
+
assert seen["key_fingerprints"] == ["AAAA", "BBBB", "CCCC"]
|
|
119
|
+
assert seen["refresh"] is False and seen["signed_metadata"] is True and seen["replace"] is False
|
|
120
|
+
assert json.loads(capsys.readouterr().out)["changed"] is True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_package_repo_errors_are_json(monkeypatch, capsys) -> None:
|
|
124
|
+
import json
|
|
125
|
+
|
|
126
|
+
import runspec_linux_root.repos as repos
|
|
127
|
+
|
|
128
|
+
def boom(name, **kw):
|
|
129
|
+
raise ValueError("this host uses apt: give --apt-url and --apt-key for the 'grafana' repo")
|
|
130
|
+
|
|
131
|
+
monkeypatch.setattr(repos, "add_repo", boom)
|
|
132
|
+
with pytest.raises(SystemExit) as exc:
|
|
133
|
+
_run_main(monkeypatch, ["package-repo", "add", "grafana"], repos.main)
|
|
134
|
+
assert exc.value.code == 1
|
|
135
|
+
assert "give --apt-url" in json.loads(capsys.readouterr().out)["error"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.parametrize(
|
|
139
|
+
("argv", "expected"),
|
|
140
|
+
[
|
|
141
|
+
(["system-service", "enable", "alloy", "--now"], ("alloy", "enable", True)),
|
|
142
|
+
(["system-service", "restart", "alloy"], ("alloy", "restart", False)),
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
def test_system_service_calls_the_core_helper(monkeypatch, capsys, argv, expected) -> None:
|
|
146
|
+
import runspec_linux_root.services as services
|
|
147
|
+
|
|
148
|
+
calls = []
|
|
149
|
+
monkeypatch.setattr(services, "control_service", lambda s, a, now: calls.append((s, a, now)) or {"service": s})
|
|
150
|
+
_run_main(monkeypatch, argv, services.main)
|
|
151
|
+
assert calls == [expected]
|
|
@@ -1,21 +0,0 @@
|
|
|
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).
|
|
@@ -1,70 +0,0 @@
|
|
|
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.
|
|
@@ -1,52 +0,0 @@
|
|
|
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.
|
|
@@ -1,145 +0,0 @@
|
|
|
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)"
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|