runspec-linux 0.18.0__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/CHANGELOG.md +35 -0
  2. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/PKG-INFO +15 -12
  3. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/README.md +13 -10
  4. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/pyproject.toml +2 -10
  5. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/__init__.py +2 -13
  6. runspec_linux-0.19.0/runspec_linux/_escalation.py +42 -0
  7. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/cron.py +3 -0
  8. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/fs/cli.py +2 -0
  9. runspec_linux-0.19.0/runspec_linux/packages.py +27 -0
  10. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/perf.py +1 -20
  11. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/runspec.toml +20 -250
  12. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/venvs.py +6 -0
  13. runspec_linux-0.19.0/tests/test_escalation.py +56 -0
  14. runspec_linux-0.18.0/runspec_linux/_escalation.py +0 -20
  15. runspec_linux-0.18.0/runspec_linux/packages.py +0 -99
  16. runspec_linux-0.18.0/runspec_linux/power.py +0 -20
  17. runspec_linux-0.18.0/runspec_linux/sudoers.py +0 -23
  18. runspec_linux-0.18.0/tests/test_escalation.py +0 -34
  19. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/.gitignore +0 -0
  20. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/commands.py +0 -0
  21. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/containers.py +0 -0
  22. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/files.py +0 -0
  23. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/fs/__init__.py +0 -0
  24. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/fs/_dispatch.py +0 -0
  25. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/fs/_worker.py +0 -0
  26. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/investigate.py +0 -0
  27. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/logs.py +0 -0
  28. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/nc_command.py +0 -0
  29. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/network.py +0 -0
  30. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/security.py +0 -0
  31. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/services.py +0 -0
  32. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/system_info.py +0 -0
  33. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/runspec_linux/webops.py +0 -0
  34. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/tests/__init__.py +0 -0
  35. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/tests/test_files.py +0 -0
  36. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/tests/test_fs.py +0 -0
  37. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/tests/test_inventory.py +0 -0
  38. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/tests/test_nc_send.py +0 -0
  39. {runspec_linux-0.18.0 → runspec_linux-0.19.0}/tests/test_webops.py +0 -0
@@ -1,5 +1,40 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.19.0] — 2026-09-30
4
+
5
+ **No root in runspec-linux.** Everything here now runs as an unprivileged
6
+ account: you, or the service account that owns a shared venv (runspec >= 0.68.0
7
+ runs a shared venv's tools as its owner). The tools that need root moved to the
8
+ new **`runspec-linux-root`** package, for a root-owned admin venv.
9
+
10
+ ### Removed (moved to `runspec-linux-root`)
11
+
12
+ - `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`,
13
+ `autoremove-packages`, `set-sysctl`, `reboot-host`, `enable-passwordless-sudo`.
14
+ Their helpers stay in `runspec-linux-core`. The `runspec_linux` re-exports of
15
+ `install_package`, `list_upgrades`, `reboot_host`, `remove_package`,
16
+ `upgrade_packages` and `enable_passwordless_sudo` are gone: import them from
17
+ `runspec_linux_core`.
18
+ - **`--sudo`** ("run as root") on `restart-service`, `tail-log`, `search-log`,
19
+ `journalctl`, `list-containers`, `container-logs`, `restart-container`,
20
+ `service-detail` and `open-ports`.
21
+
22
+ ### Changed
23
+
24
+ - **`--run-as root` is refused** on every tool that takes `--run-as` (the ones
25
+ above, the venv tools, `filesystem` and `cron`). It prints a JSON error naming
26
+ `runspec-linux-root` and exits 2. `--run-as` still selects any unprivileged
27
+ account.
28
+ - **`--run-as` naming the account you already are does nothing** (from
29
+ runspec-linux-core 0.13.0). A service account running its own venv's tools with
30
+ `--run-as <itself>` used to need a sudoers rule letting it sudo to itself.
31
+
32
+ ### Upgrading
33
+
34
+ Install `runspec-linux-root` into a separate root-owned venv (see its README).
35
+ If an existing venv has both packages, upgrade `runspec-linux` first: pip
36
+ removes the old copies of the moved scripts when it upgrades it.
37
+
3
38
  ## [0.18.0] — 2026-09-29
4
39
 
5
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspec-linux
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Project-URL: Documentation, https://runspec.app/
6
6
  Project-URL: Source, https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-linux
@@ -9,7 +9,7 @@ Project-URL: Issues, https://github.com/jasonfinestone/runspec/issues
9
9
  Keywords: linux,ops,runnable,runspec,ssh,sysadmin,venv
10
10
  Requires-Python: >=3.10
11
11
  Requires-Dist: runspec-fs-core>=0.5.0
12
- Requires-Dist: runspec-linux-core>=0.12.0
12
+ Requires-Dist: runspec-linux-core>=0.13.0
13
13
  Requires-Dist: runspec-webops-core>=0.3.0
14
14
  Requires-Dist: runspec>=0.50.1
15
15
  Provides-Extra: dev
@@ -27,10 +27,15 @@ the runnables are discoverable by `runspec local`, `runspec serve` (MCP), and
27
27
  runspec-console.
28
28
 
29
29
  All runnables emit JSON on stdout. Read-only runnables are `autonomy =
30
- "autonomous"`; state-changing ones are `autonomy = "confirm"`. The privileged
31
- runnables declare `run_as = "root"`, so runspec-console escalates them via
32
- `sudo` — run `enable-passwordless-sudo` once to install a `visudo`-validated
33
- `/etc/sudoers.d` drop-in so they run without a password prompt.
30
+ "autonomous"`; state-changing ones are `autonomy = "confirm"`.
31
+
32
+ **Nothing here runs as root.** A tool runs as whoever owns its venv (runspec
33
+ >= 0.68.0): you in a personal venv, or the service account in a shared one.
34
+ `--run-as` picks another unprivileged account and refuses `root`. The tools that
35
+ need root (package upgrades/installs, `reboot-host`, `set-sysctl`,
36
+ `enable-passwordless-sudo`) are in
37
+ [`runspec-linux-root`](https://pypi.org/project/runspec-linux-root/), for a
38
+ root-owned admin venv.
34
39
 
35
40
  ## Install
36
41
 
@@ -47,13 +52,11 @@ pip install runspec-linux
47
52
  | Services | `list-services`, `check-service`, `restart-service`, `service-detail`, `failed-units` |
48
53
  | Logs | `tail-log`, `search-log`, `journalctl` |
49
54
  | Network | `network-interfaces`, `ping-host`, `check-port`, `show-connections`, `trace-route`, `route-table`, `socket-stats`, `dns-config`, `open-ports` |
50
- | Kernel params | `get-sysctl`, `set-sysctl` |
55
+ | Kernel params | `get-sysctl` |
51
56
  | Files | `find-large-files`, `backup-files`, `owning-package` |
52
57
  | Sessions | `last-logins`, `who` |
53
58
  | Containers | `list-containers`, `container-logs`, `restart-container` |
54
- | Packages | `list-packages`, `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`, `autoremove-packages`, `list-autoremovable` |
55
- | Power | `reboot-host` |
56
- | Sudo bootstrap | `enable-passwordless-sudo` |
59
+ | Packages (read-only) | `list-packages`, `list-autoremovable` |
57
60
  | Python venvs | `create-venv`, `install-into-venv`, `configure-pip` |
58
61
  | TCP | `nc-command` |
59
62
  | Commands | `which` |
@@ -61,13 +64,13 @@ pip install runspec-linux
61
64
  | Filesystem | `filesystem` (multi-verb, backed by `runspec-fs-core`) |
62
65
  | Web / TLS / API | `cert-check`, `cert-chain`, `tls-info`, `http-check`, `http-headers`, `redirect-trace`, `dns-lookup`, `api-spec` |
63
66
 
64
- The package-management runnables auto-detect apt / dnf / yum / zypper / pacman.
67
+ The package runnables auto-detect apt / dnf / yum / zypper / pacman.
65
68
  The web/TLS/API probes are pure HTTP (shared with `runspec-windows` via
66
69
  `runspec-webops-core`) and run anywhere.
67
70
 
68
71
  ```
69
72
  system-info
70
- list-upgrades
73
+ list-packages --filter nginx
71
74
  check-port --host db-01 --port 5432
72
75
  cert-check --url https://internal.example.com
73
76
  ```
@@ -7,10 +7,15 @@ the runnables are discoverable by `runspec local`, `runspec serve` (MCP), and
7
7
  runspec-console.
8
8
 
9
9
  All runnables emit JSON on stdout. Read-only runnables are `autonomy =
10
- "autonomous"`; state-changing ones are `autonomy = "confirm"`. The privileged
11
- runnables declare `run_as = "root"`, so runspec-console escalates them via
12
- `sudo` — run `enable-passwordless-sudo` once to install a `visudo`-validated
13
- `/etc/sudoers.d` drop-in so they run without a password prompt.
10
+ "autonomous"`; state-changing ones are `autonomy = "confirm"`.
11
+
12
+ **Nothing here runs as root.** A tool runs as whoever owns its venv (runspec
13
+ >= 0.68.0): you in a personal venv, or the service account in a shared one.
14
+ `--run-as` picks another unprivileged account and refuses `root`. The tools that
15
+ need root (package upgrades/installs, `reboot-host`, `set-sysctl`,
16
+ `enable-passwordless-sudo`) are in
17
+ [`runspec-linux-root`](https://pypi.org/project/runspec-linux-root/), for a
18
+ root-owned admin venv.
14
19
 
15
20
  ## Install
16
21
 
@@ -27,13 +32,11 @@ pip install runspec-linux
27
32
  | Services | `list-services`, `check-service`, `restart-service`, `service-detail`, `failed-units` |
28
33
  | Logs | `tail-log`, `search-log`, `journalctl` |
29
34
  | Network | `network-interfaces`, `ping-host`, `check-port`, `show-connections`, `trace-route`, `route-table`, `socket-stats`, `dns-config`, `open-ports` |
30
- | Kernel params | `get-sysctl`, `set-sysctl` |
35
+ | Kernel params | `get-sysctl` |
31
36
  | Files | `find-large-files`, `backup-files`, `owning-package` |
32
37
  | Sessions | `last-logins`, `who` |
33
38
  | Containers | `list-containers`, `container-logs`, `restart-container` |
34
- | Packages | `list-packages`, `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`, `autoremove-packages`, `list-autoremovable` |
35
- | Power | `reboot-host` |
36
- | Sudo bootstrap | `enable-passwordless-sudo` |
39
+ | Packages (read-only) | `list-packages`, `list-autoremovable` |
37
40
  | Python venvs | `create-venv`, `install-into-venv`, `configure-pip` |
38
41
  | TCP | `nc-command` |
39
42
  | Commands | `which` |
@@ -41,13 +44,13 @@ pip install runspec-linux
41
44
  | Filesystem | `filesystem` (multi-verb, backed by `runspec-fs-core`) |
42
45
  | Web / TLS / API | `cert-check`, `cert-chain`, `tls-info`, `http-check`, `http-headers`, `redirect-trace`, `dns-lookup`, `api-spec` |
43
46
 
44
- The package-management runnables auto-detect apt / dnf / yum / zypper / pacman.
47
+ The package runnables auto-detect apt / dnf / yum / zypper / pacman.
45
48
  The web/TLS/API probes are pure HTTP (shared with `runspec-windows` via
46
49
  `runspec-webops-core`) and run anywhere.
47
50
 
48
51
  ```
49
52
  system-info
50
- list-upgrades
53
+ list-packages --filter nginx
51
54
  check-port --host db-01 --port 5432
52
55
  cert-check --url https://internal.example.com
53
56
  ```
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux"
7
- version = "0.18.0"
7
+ version = "0.19.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Linux system admin runnables for runspec"
10
10
  readme = "README.md"
11
11
  keywords = ["runspec", "runnable", "linux", "sysadmin", "ops", "ssh", "venv"]
12
12
  dependencies = [
13
13
  "runspec>=0.50.1",
14
- "runspec-linux-core>=0.12.0",
14
+ "runspec-linux-core>=0.13.0",
15
15
  "runspec-webops-core>=0.3.0",
16
16
  "runspec-fs-core>=0.5.0",
17
17
  ]
@@ -43,7 +43,6 @@ socket-stats = "runspec_linux.network:main_socket_stats"
43
43
  dns-config = "runspec_linux.network:main_dns_config"
44
44
  network-interfaces = "runspec_linux.network:main_network_interfaces"
45
45
  get-sysctl = "runspec_linux.perf:main_get_sysctl"
46
- set-sysctl = "runspec_linux.perf:main_set_sysctl"
47
46
  cpu-info = "runspec_linux.perf:main_cpu_info"
48
47
  pressure = "runspec_linux.perf:main_pressure"
49
48
  service-detail = "runspec_linux.investigate:main_service_detail"
@@ -58,14 +57,7 @@ list-containers = "runspec_linux.containers:main_list_containers"
58
57
  container-logs = "runspec_linux.containers:main_container_logs"
59
58
  restart-container = "runspec_linux.containers:main_restart_container"
60
59
  list-packages = "runspec_linux.packages:main_list_packages"
61
- list-upgrades = "runspec_linux.packages:main_list_upgrades"
62
- upgrade-packages = "runspec_linux.packages:main_upgrade_packages"
63
- install-package = "runspec_linux.packages:main_install_package"
64
- remove-package = "runspec_linux.packages:main_remove_package"
65
- autoremove-packages = "runspec_linux.packages:main_autoremove_packages"
66
60
  list-autoremovable = "runspec_linux.packages:main_list_autoremovable"
67
- reboot-host = "runspec_linux.power:main_reboot_host"
68
- enable-passwordless-sudo = "runspec_linux.sudoers:main_enable_passwordless_sudo"
69
61
  create-venv = "runspec_linux.venvs:main_create_venv"
70
62
  install-into-venv = "runspec_linux.venvs:main_install_into_venv"
71
63
  configure-pip = "runspec_linux.venvs:main_configure_pip"
@@ -4,7 +4,8 @@ The pure logic lives in the dependency-free ``runspec-linux-core`` package; the
4
4
  modules here wrap each helper in a runspec runnable. The helper functions are
5
5
  re-exported for convenience and back-compat (e.g. ``from runspec_linux import
6
6
  nc_send``). To import the helpers *without* surfacing any runnables in a venv,
7
- depend on ``runspec-linux-core`` directly instead.
7
+ depend on ``runspec-linux-core`` directly instead. Tools that need root live in
8
+ ``runspec-linux-root``; their helpers are in ``runspec-linux-core``.
8
9
  """
9
10
 
10
11
  from runspec_linux_core import (
@@ -19,10 +20,8 @@ from runspec_linux_core import (
19
20
  create_venv,
20
21
  detect_manager,
21
22
  disk_usage,
22
- enable_passwordless_sudo,
23
23
  find_large_files,
24
24
  install_into_venv,
25
- install_package,
26
25
  journalctl,
27
26
  last_logins,
28
27
  list_containers,
@@ -30,19 +29,15 @@ from runspec_linux_core import (
30
29
  list_packages,
31
30
  list_processes,
32
31
  list_services,
33
- list_upgrades,
34
32
  nc_send,
35
33
  ping_host,
36
- reboot_host,
37
34
  remove_cron,
38
- remove_package,
39
35
  restart_container,
40
36
  restart_service,
41
37
  search_log,
42
38
  show_connections,
43
39
  system_info,
44
40
  tail_log,
45
- upgrade_packages,
46
41
  which,
47
42
  who,
48
43
  )
@@ -59,10 +54,8 @@ __all__ = [
59
54
  "create_venv",
60
55
  "detect_manager",
61
56
  "disk_usage",
62
- "enable_passwordless_sudo",
63
57
  "find_large_files",
64
58
  "install_into_venv",
65
- "install_package",
66
59
  "journalctl",
67
60
  "last_logins",
68
61
  "list_containers",
@@ -70,19 +63,15 @@ __all__ = [
70
63
  "list_packages",
71
64
  "list_processes",
72
65
  "list_services",
73
- "list_upgrades",
74
66
  "nc_send",
75
67
  "ping_host",
76
- "reboot_host",
77
68
  "remove_cron",
78
- "remove_package",
79
69
  "restart_container",
80
70
  "restart_service",
81
71
  "search_log",
82
72
  "show_connections",
83
73
  "system_info",
84
74
  "tail_log",
85
- "upgrade_packages",
86
75
  "which",
87
76
  "who",
88
77
  ]
@@ -0,0 +1,42 @@
1
+ """Shared wrapper helper for the ``--run-as`` / ``--become-method`` escalation args.
2
+
3
+ runspec-linux never escalates to root: tools that need root live in the separate
4
+ ``runspec-linux-root`` package, installed into a root-owned venv. ``--run-as``
5
+ picks an unprivileged account (typically the service account that owns a shared
6
+ venv); a root target is refused before anything runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import Any
14
+
15
+ ROOT_REFUSAL = "runspec-linux does not run anything as root — root tools live in runspec-linux-root"
16
+
17
+
18
+ def is_root(name: str) -> bool:
19
+ """True when ``name`` names uid 0 (``root``, ``0`` or any alias)."""
20
+ if name in ("root", "0"):
21
+ return True
22
+ try:
23
+ import pwd
24
+
25
+ return pwd.getpwnam(name).pw_uid == 0
26
+ except (ImportError, KeyError):
27
+ return False
28
+
29
+
30
+ def refuse_root(run_as: str | None) -> None:
31
+ """Print a JSON error and exit 2 when ``run_as`` targets root."""
32
+ if run_as and is_root(run_as):
33
+ print(json.dumps({"error": f"--run-as {run_as}: {ROOT_REFUSAL}"}))
34
+ sys.exit(2)
35
+
36
+
37
+ def resolve_escalation(spec: Any) -> tuple[str | None, str]:
38
+ """Return ``(run_as, become_method)`` from a spec carrying the escalation args
39
+ (``None`` = no escalation, runs as the caller). Exits on a root target."""
40
+ run_as = spec.run_as.value or None
41
+ refuse_root(run_as)
42
+ return run_as, spec.become_method.value
@@ -23,6 +23,8 @@ import runspec as rs
23
23
  from runspec_linux_core.cron import add_cron, clear_cron, list_cron, remove_cron
24
24
  from runspec_linux_core.errors import CommandError, ToolNotFoundError
25
25
 
26
+ from runspec_linux._escalation import refuse_root
27
+
26
28
 
27
29
  def _emit(payload: dict[str, Any]) -> None:
28
30
  print(json.dumps(payload, default=str))
@@ -31,6 +33,7 @@ def _emit(payload: dict[str, Any]) -> None:
31
33
  def _run(spec: rs.RunSpec) -> dict[str, Any]:
32
34
  command = spec.runspec_command
33
35
  run_as = spec.run_as.value or None
36
+ refuse_root(run_as)
34
37
  become_method = spec.become_method.value
35
38
 
36
39
  if command == "list":
@@ -26,6 +26,7 @@ from typing import Any
26
26
  import runspec as rs
27
27
  from runspec_fs_core import BecomeError, FsCoreError, build_become, current_user, escalation_supported
28
28
 
29
+ from runspec_linux._escalation import refuse_root
29
30
  from runspec_linux.fs._dispatch import run_verb
30
31
 
31
32
 
@@ -115,6 +116,7 @@ def _run_as_worker(run_as: str, method: str, verb: str, kwargs: dict[str, Any])
115
116
  def main() -> None:
116
117
  spec = rs.parse("filesystem")
117
118
  run_as = spec.run_as.value
119
+ refuse_root(run_as or None)
118
120
  method = spec.become_method.value
119
121
  try:
120
122
  kwargs = _verb_kwargs(spec)
@@ -0,0 +1,27 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import list_autoremovable, list_packages
6
+
7
+
8
+ def main_list_packages() -> None:
9
+ spec = rs.parse("list-packages")
10
+ name_filter = spec.filter.value
11
+ limit = spec.limit.value
12
+ try:
13
+ print(json.dumps(list_packages(name_filter, limit, reason=spec.reason.value)))
14
+ except Exception as e:
15
+ # Includes ToolNotFoundError (no package manager) and CommandError.
16
+ print(json.dumps({"error": str(e)}))
17
+ sys.exit(1)
18
+
19
+
20
+ def main_list_autoremovable() -> None:
21
+ spec = rs.parse("list-autoremovable")
22
+ try:
23
+ print(json.dumps(list_autoremovable(purge=spec.purge.value)))
24
+ except Exception as e:
25
+ # Includes CommandError (yum, failed listing) and ToolNotFoundError.
26
+ print(json.dumps({"error": str(e)}))
27
+ sys.exit(1)
@@ -4,10 +4,7 @@ import json
4
4
  import sys
5
5
 
6
6
  import runspec as rs
7
- from runspec_linux_core import cpu_info, get_sysctl, pressure, set_sysctl
8
- from runspec_linux_core.errors import CommandError
9
-
10
- from runspec_linux._escalation import resolve_escalation
7
+ from runspec_linux_core import cpu_info, get_sysctl, pressure
11
8
 
12
9
 
13
10
  def main_get_sysctl() -> None:
@@ -20,22 +17,6 @@ def main_get_sysctl() -> None:
20
17
  sys.exit(1)
21
18
 
22
19
 
23
- def main_set_sysctl() -> None:
24
- spec = rs.parse("set-sysctl")
25
- key = spec.key.value
26
- value = spec.value.value
27
- persist = spec.persist.value
28
- run_as, method = resolve_escalation(spec)
29
- try:
30
- print(json.dumps(set_sysctl(key, value, persist=persist, run_as=run_as, become_method=method)))
31
- except CommandError as e:
32
- print(json.dumps({"key": key, "applied": False, "error": str(e)}))
33
- sys.exit(1)
34
- except Exception as e:
35
- print(json.dumps({"error": str(e)}))
36
- sys.exit(1)
37
-
38
-
39
20
  def main_cpu_info() -> None:
40
21
  rs.parse("cpu-info")
41
22
  try:
@@ -96,24 +96,15 @@ description = "Service name to restart"
96
96
  # Escalation: restarting a system service needs privilege.
97
97
  [restart-service.args.run-as]
98
98
  type = "str"
99
- description = "Run as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
99
+ description = "Run as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
100
100
  required = false
101
101
 
102
- [restart-service.args.sudo]
103
- type = "flag"
104
- description = "Run as root (via sudo). Mutually exclusive with --run-as."
105
- default = false
106
-
107
102
  [restart-service.args.become-method]
108
103
  type = "choice"
109
104
  options = ["sudo", "su"]
110
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
105
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
111
106
  default = "sudo"
112
107
 
113
- [restart-service.groups.escalation]
114
- exclusive = true
115
- args = ["run-as", "sudo"]
116
-
117
108
  # ── Logs ──────────────────────────────────────────────────────────────────────
118
109
 
119
110
  [tail-log]
@@ -133,27 +124,18 @@ short = "-n"
133
124
  description = "Number of lines to return"
134
125
  default = 50
135
126
 
136
- # Escalation: read a root-owned / service-account-owned log (tail -n under sudo/su).
127
+ # Escalation: read a service-account-owned log (tail -n under sudo/su).
137
128
  [tail-log.args.run-as]
138
129
  type = "str"
139
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
130
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
140
131
  required = false
141
132
 
142
- [tail-log.args.sudo]
143
- type = "flag"
144
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
145
- default = false
146
-
147
133
  [tail-log.args.become-method]
148
134
  type = "choice"
149
135
  options = ["sudo", "su"]
150
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
136
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
151
137
  default = "sudo"
152
138
 
153
- [tail-log.groups.escalation]
154
- exclusive = true
155
- args = ["run-as", "sudo"]
156
-
157
139
  [search-log]
158
140
  serve = ["local"]
159
141
  description = "Search a log file for lines matching a pattern"
@@ -176,27 +158,18 @@ short = "-n"
176
158
  description = "Maximum number of matching lines to return"
177
159
  default = 100
178
160
 
179
- # Escalation: search a root-owned / service-account-owned log (cat under sudo/su).
161
+ # Escalation: search a service-account-owned log (cat under sudo/su).
180
162
  [search-log.args.run-as]
181
163
  type = "str"
182
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
164
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
183
165
  required = false
184
166
 
185
- [search-log.args.sudo]
186
- type = "flag"
187
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
188
- default = false
189
-
190
167
  [search-log.args.become-method]
191
168
  type = "choice"
192
169
  options = ["sudo", "su"]
193
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
170
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
194
171
  default = "sudo"
195
172
 
196
- [search-log.groups.escalation]
197
- exclusive = true
198
- args = ["run-as", "sudo"]
199
-
200
173
  [journalctl]
201
174
  serve = ["local"]
202
175
  description = "Fetch recent journal entries for a systemd unit (systemd-only)"
@@ -222,24 +195,15 @@ required = false
222
195
  # Escalation: read a protected unit's journal (outside the systemd-journal group).
223
196
  [journalctl.args.run-as]
224
197
  type = "str"
225
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
198
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
226
199
  required = false
227
200
 
228
- [journalctl.args.sudo]
229
- type = "flag"
230
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
231
- default = false
232
-
233
201
  [journalctl.args.become-method]
234
202
  type = "choice"
235
203
  options = ["sudo", "su"]
236
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
204
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
237
205
  default = "sudo"
238
206
 
239
- [journalctl.groups.escalation]
240
- exclusive = true
241
- args = ["run-as", "sudo"]
242
-
243
207
  # ── Network ───────────────────────────────────────────────────────────────────
244
208
 
245
209
  [ping-host]
@@ -375,24 +339,15 @@ default = false
375
339
  # Escalation: the Docker daemon socket is usually root/docker-group owned.
376
340
  [list-containers.args.run-as]
377
341
  type = "str"
378
- description = "Run docker as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
342
+ description = "Run docker as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
379
343
  required = false
380
344
 
381
- [list-containers.args.sudo]
382
- type = "flag"
383
- description = "Run docker as root (via sudo). Mutually exclusive with --run-as."
384
- default = false
385
-
386
345
  [list-containers.args.become-method]
387
346
  type = "choice"
388
347
  options = ["sudo", "su"]
389
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
348
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
390
349
  default = "sudo"
391
350
 
392
- [list-containers.groups.escalation]
393
- exclusive = true
394
- args = ["run-as", "sudo"]
395
-
396
351
  [container-logs]
397
352
  serve = ["local"]
398
353
  description = "Fetch recent log output from a Docker container"
@@ -413,24 +368,15 @@ default = 50
413
368
  # Escalation: the Docker daemon socket is usually root/docker-group owned.
414
369
  [container-logs.args.run-as]
415
370
  type = "str"
416
- description = "Run docker as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
371
+ description = "Run docker as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
417
372
  required = false
418
373
 
419
- [container-logs.args.sudo]
420
- type = "flag"
421
- description = "Run docker as root (via sudo). Mutually exclusive with --run-as."
422
- default = false
423
-
424
374
  [container-logs.args.become-method]
425
375
  type = "choice"
426
376
  options = ["sudo", "su"]
427
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
377
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
428
378
  default = "sudo"
429
379
 
430
- [container-logs.groups.escalation]
431
- exclusive = true
432
- args = ["run-as", "sudo"]
433
-
434
380
  [restart-container]
435
381
  serve = ["local"]
436
382
  description = "Restart a Docker container"
@@ -445,24 +391,15 @@ description = "Container name or ID"
445
391
  # Escalation: the Docker daemon socket is usually root/docker-group owned.
446
392
  [restart-container.args.run-as]
447
393
  type = "str"
448
- description = "Run docker as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
394
+ description = "Run docker as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
449
395
  required = false
450
396
 
451
- [restart-container.args.sudo]
452
- type = "flag"
453
- description = "Run docker as root (via sudo). Mutually exclusive with --run-as."
454
- default = false
455
-
456
397
  [restart-container.args.become-method]
457
398
  type = "choice"
458
399
  options = ["sudo", "su"]
459
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
400
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
460
401
  default = "sudo"
461
402
 
462
- [restart-container.groups.escalation]
463
- exclusive = true
464
- args = ["run-as", "sudo"]
465
-
466
403
  # ── Packages ──────────────────────────────────────────────────────────────────
467
404
 
468
405
  [list-packages]
@@ -488,54 +425,6 @@ type = "flag"
488
425
  description = "Also say whether each package was installed by name (manual) or as a dependency (auto) — autoremove only ever removes auto packages"
489
426
  default = false
490
427
 
491
- [list-upgrades]
492
- serve = ["local"]
493
- description = "List packages with an available upgrade (refreshes the package index first)"
494
- autonomy = "autonomous"
495
- output = "json"
496
- run_as = "root"
497
-
498
- [list-upgrades.args.no-refresh]
499
- type = "flag"
500
- description = "Skip refreshing the package index — list from the cached index (faster)"
501
- default = false
502
-
503
- [upgrade-packages]
504
- serve = ["local"]
505
- description = "Refresh the package index and upgrade all installed packages (needs root)"
506
- autonomy = "confirm"
507
- output = "json"
508
- run_as = "root"
509
-
510
- [install-package]
511
- serve = ["local"]
512
- description = "Install one or more packages by name (needs root)"
513
- autonomy = "confirm"
514
- output = "json"
515
- run_as = "root"
516
-
517
- [install-package.args.package]
518
- type = "str"
519
- short = "-p"
520
- description = "Package name to install (space-separated for several)"
521
-
522
- [remove-package]
523
- serve = ["local"]
524
- description = "Remove one or more packages by name (needs root)"
525
- autonomy = "confirm"
526
- output = "json"
527
- run_as = "root"
528
-
529
- [remove-package.args.package]
530
- type = "str"
531
- short = "-p"
532
- description = "Package name to remove (space-separated for several)"
533
-
534
- [remove-package.args.purge]
535
- type = "flag"
536
- description = "Also delete the packages' configuration files (apt purge / pacman -Rn; a plain remove on dnf/yum/zypper)"
537
- default = false
538
-
539
428
  [list-autoremovable]
540
429
  serve = ["local"]
541
430
  description = "List the packages autoremove-packages would remove (read-only preview; not supported on yum)"
@@ -547,66 +436,6 @@ type = "flag"
547
436
  description = "Preview a purge (apt shows config files would go too)"
548
437
  default = false
549
438
 
550
- [autoremove-packages]
551
- serve = ["local"]
552
- description = "Remove packages nothing depends on any more, e.g. old kernels (apt autoremove and equivalents; needs root)"
553
- autonomy = "confirm"
554
- output = "json"
555
- run_as = "root"
556
-
557
- [autoremove-packages.args.purge]
558
- type = "flag"
559
- description = "Also delete the removed packages' configuration files (apt --purge / pacman -n)"
560
- default = false
561
-
562
- [autoremove-packages.args.dry-run]
563
- type = "flag"
564
- description = "Only list what would be removed; remove nothing (not supported on yum). list-autoremovable does the same without needing approval"
565
- default = false
566
-
567
- # ── Power ─────────────────────────────────────────────────────────────────────
568
-
569
- [reboot-host]
570
- serve = ["local"]
571
- description = "Reboot the host, optionally after a delay in minutes (needs root)"
572
- autonomy = "confirm"
573
- output = "json"
574
- run_as = "root"
575
-
576
- [reboot-host.args.delay-minutes]
577
- type = "int"
578
- short = "-d"
579
- description = "Minutes to wait before rebooting (0 = immediately)"
580
- default = 0
581
-
582
- # ── Privilege bootstrap ───────────────────────────────────────────────────────
583
-
584
- # One-time setup so the run_as="root" runnables above need no password. Itself
585
- # escalates with the supplied password via `sudo -S` (read from stdin, never
586
- # argv), so it must NOT declare run_as. Validated with visudo before + after
587
- # install and rolled back on failure.
588
- [enable-passwordless-sudo]
589
- serve = ["local"]
590
- description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
591
- autonomy = "confirm"
592
- output = "json"
593
-
594
- [enable-passwordless-sudo.args.password]
595
- type = "password"
596
- description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
597
-
598
- [enable-passwordless-sudo.args.user]
599
- type = "str"
600
- short = "-u"
601
- description = "User to grant passwordless sudo (defaults to the current login user)"
602
- required = false
603
-
604
- [enable-passwordless-sudo.args.scope]
605
- type = "choice"
606
- options = ["scoped", "all"]
607
- default = "scoped"
608
- description = "scoped = NOPASSWD only for the runspec venv runnables (manual/CLI runs); all = NOPASSWD: ALL (also covers agent-driven runs)"
609
-
610
439
  # ── TCP interfaces (nc-command) ────────────────────────────────────────────────
611
440
 
612
441
  [nc-command]
@@ -1237,47 +1066,6 @@ short = "-p"
1237
1066
  description = "Only return keys containing this substring (e.g. 'net.core')"
1238
1067
  required = false
1239
1068
 
1240
- [set-sysctl]
1241
- serve = ["local"]
1242
- description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d"
1243
- autonomy = "confirm"
1244
- output = "json"
1245
-
1246
- [set-sysctl.args.key]
1247
- type = "str"
1248
- short = "-k"
1249
- description = "Dotted sysctl key (e.g. net.core.somaxconn)"
1250
-
1251
- [set-sysctl.args.value]
1252
- type = "str"
1253
- description = "New value to set"
1254
-
1255
- [set-sysctl.args.persist]
1256
- type = "flag"
1257
- description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
1258
- default = false
1259
-
1260
- # Escalation: writing a kernel parameter is privileged.
1261
- [set-sysctl.args.run-as]
1262
- type = "str"
1263
- description = "Apply as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1264
- required = false
1265
-
1266
- [set-sysctl.args.sudo]
1267
- type = "flag"
1268
- description = "Apply as root (via sudo). Mutually exclusive with --run-as."
1269
- default = false
1270
-
1271
- [set-sysctl.args.become-method]
1272
- type = "choice"
1273
- options = ["sudo", "su"]
1274
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1275
- default = "sudo"
1276
-
1277
- [set-sysctl.groups.escalation]
1278
- exclusive = true
1279
- args = ["run-as", "sudo"]
1280
-
1281
1069
  [cpu-info]
1282
1070
  serve = ["local"]
1283
1071
  description = "Report CPU model, core count, and per-CPU scaling governor and frequency"
@@ -1306,24 +1094,15 @@ description = "Systemd unit name (e.g. nginx or nginx.service)"
1306
1094
  # Escalation: some properties on protected units need privilege.
1307
1095
  [service-detail.args.run-as]
1308
1096
  type = "str"
1309
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1097
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
1310
1098
  required = false
1311
1099
 
1312
- [service-detail.args.sudo]
1313
- type = "flag"
1314
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
1315
- default = false
1316
-
1317
1100
  [service-detail.args.become-method]
1318
1101
  type = "choice"
1319
1102
  options = ["sudo", "su"]
1320
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1103
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
1321
1104
  default = "sudo"
1322
1105
 
1323
- [service-detail.groups.escalation]
1324
- exclusive = true
1325
- args = ["run-as", "sudo"]
1326
-
1327
1106
  [open-ports]
1328
1107
  serve = ["local"]
1329
1108
  description = "List listening sockets mapped to the owning process (port to pid to process name)"
@@ -1339,24 +1118,15 @@ default = "tcp"
1339
1118
  # Escalation: the full pid/process map needs privilege (ss shows other users' processes only as root).
1340
1119
  [open-ports.args.run-as]
1341
1120
  type = "str"
1342
- description = "Probe as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1121
+ description = "Probe as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
1343
1122
  required = false
1344
1123
 
1345
- [open-ports.args.sudo]
1346
- type = "flag"
1347
- description = "Probe as root (via sudo). Mutually exclusive with --run-as."
1348
- default = false
1349
-
1350
1124
  [open-ports.args.become-method]
1351
1125
  type = "choice"
1352
1126
  options = ["sudo", "su"]
1353
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1127
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
1354
1128
  default = "sudo"
1355
1129
 
1356
- [open-ports.groups.escalation]
1357
- exclusive = true
1358
- args = ["run-as", "sudo"]
1359
-
1360
1130
  [owning-package]
1361
1131
  serve = ["local"]
1362
1132
  description = "Report which installed package owns a file path (dpkg / rpm)"
@@ -5,9 +5,12 @@ import runspec as rs
5
5
  from runspec_linux_core import configure_pip, create_venv, install_into_venv, share_venv_logs
6
6
  from runspec_linux_core.errors import CommandError
7
7
 
8
+ from runspec_linux._escalation import refuse_root
9
+
8
10
 
9
11
  def main_create_venv() -> None:
10
12
  spec = rs.parse("create-venv")
13
+ refuse_root(spec.run_as.value or None)
11
14
  try:
12
15
  result = create_venv(
13
16
  str(spec.python.value),
@@ -33,6 +36,7 @@ def main_create_venv() -> None:
33
36
 
34
37
  def main_install_into_venv() -> None:
35
38
  spec = rs.parse("install-into-venv")
39
+ refuse_root(spec.run_as.value or None)
36
40
  try:
37
41
  result = install_into_venv(
38
42
  str(spec.venv.value),
@@ -57,6 +61,7 @@ def main_install_into_venv() -> None:
57
61
 
58
62
  def main_share_venv_logs() -> None:
59
63
  spec = rs.parse("share-venv-logs")
64
+ refuse_root(spec.run_as.value or None)
60
65
  try:
61
66
  result = share_venv_logs(
62
67
  str(spec.venv.value),
@@ -77,6 +82,7 @@ def main_share_venv_logs() -> None:
77
82
 
78
83
  def main_configure_pip() -> None:
79
84
  spec = rs.parse("configure-pip")
85
+ refuse_root(spec.run_as.value or None)
80
86
  try:
81
87
  result = configure_pip(
82
88
  spec.index_url.value,
@@ -0,0 +1,56 @@
1
+ """The wrapper-side escalation resolver: --run-as / --become-method →
2
+ (run_as, become_method), refusing any root target.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from types import SimpleNamespace
9
+
10
+ import pytest
11
+
12
+ from runspec_linux._escalation import is_root, refuse_root, resolve_escalation
13
+
14
+
15
+ def _spec(run_as: str, method: str = "sudo") -> SimpleNamespace:
16
+ # A parsed spec exposes each arg as an object with a .value.
17
+ return SimpleNamespace(run_as=SimpleNamespace(value=run_as), become_method=SimpleNamespace(value=method))
18
+
19
+
20
+ def test_run_as_user() -> None:
21
+ assert resolve_escalation(_spec("deploy")) == ("deploy", "sudo")
22
+
23
+
24
+ def test_empty_is_none() -> None:
25
+ assert resolve_escalation(_spec("")) == (None, "sudo")
26
+
27
+
28
+ def test_become_method_passthrough() -> None:
29
+ assert resolve_escalation(_spec("svc", method="su")) == ("svc", "su")
30
+
31
+
32
+ @pytest.mark.parametrize("target", ["root", "0"])
33
+ def test_root_is_refused(target: str, capsys: pytest.CaptureFixture[str]) -> None:
34
+ with pytest.raises(SystemExit) as exc:
35
+ resolve_escalation(_spec(target))
36
+ assert exc.value.code == 2
37
+ assert "runspec-linux-root" in json.loads(capsys.readouterr().out)["error"]
38
+
39
+
40
+ def test_is_root() -> None:
41
+ assert is_root("root") and is_root("0")
42
+ assert not is_root("svc-no-such-user")
43
+ refuse_root(None)
44
+ refuse_root("svc")
45
+
46
+
47
+ def test_no_root_tools_left() -> None:
48
+ from importlib.resources import files
49
+
50
+ tomllib = pytest.importorskip("tomllib")
51
+ spec = tomllib.loads((files("runspec_linux") / "runspec.toml").read_text())
52
+ for name, body in spec.items():
53
+ if name == "config":
54
+ continue
55
+ assert body.get("run_as") != "root", name
56
+ assert "sudo" not in (body.get("args") or {}), name
@@ -1,20 +0,0 @@
1
- """Shared wrapper helper for the ``--run-as`` / ``--sudo`` / ``--become-method``
2
- escalation args.
3
-
4
- The privileged runnables declare a mutually-exclusive ``escalation`` group with
5
- ``--run-as <user>`` and ``--sudo`` (a flag = "run as root"), plus
6
- ``--become-method sudo|su``. The group guarantees at most one of run-as/sudo is
7
- set, so this resolves them to the ``(run_as, become_method)`` the core helpers
8
- take: ``--sudo`` → ``run_as="root"``, ``--run-as x`` → ``run_as="x"``, neither →
9
- ``run_as=None`` (no escalation, runs as the login user).
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import runspec as rs
15
-
16
-
17
- def resolve_escalation(spec: rs.RunSpec) -> tuple[str | None, str]:
18
- """Return ``(run_as, become_method)`` from a spec carrying the escalation args."""
19
- run_as = "root" if spec.sudo.value else (spec.run_as.value or None)
20
- return run_as, spec.become_method.value
@@ -1,99 +0,0 @@
1
- import json
2
- import sys
3
-
4
- import runspec as rs
5
- from runspec_linux_core import (
6
- autoremove_packages,
7
- install_package,
8
- list_autoremovable,
9
- list_packages,
10
- list_upgrades,
11
- remove_package,
12
- upgrade_packages,
13
- )
14
- from runspec_linux_core.errors import CommandError
15
-
16
-
17
- def main_list_packages() -> None:
18
- spec = rs.parse("list-packages")
19
- name_filter = spec.filter.value
20
- limit = spec.limit.value
21
- try:
22
- print(json.dumps(list_packages(name_filter, limit, reason=spec.reason.value)))
23
- except Exception as e:
24
- # Includes ToolNotFoundError (no package manager) and CommandError.
25
- print(json.dumps({"error": str(e)}))
26
- sys.exit(1)
27
-
28
-
29
- def main_list_upgrades() -> None:
30
- spec = rs.parse("list-upgrades")
31
- try:
32
- print(json.dumps(list_upgrades(refresh=not spec.no_refresh.value)))
33
- except Exception as e:
34
- print(json.dumps({"error": str(e)}))
35
- sys.exit(1)
36
-
37
-
38
- def main_upgrade_packages() -> None:
39
- rs.parse("upgrade-packages")
40
- try:
41
- print(json.dumps(upgrade_packages()))
42
- except CommandError as e:
43
- print(json.dumps({"upgraded": False, "error": str(e)}))
44
- sys.exit(1)
45
- except Exception as e:
46
- # Includes ToolNotFoundError (no package manager).
47
- print(json.dumps({"error": str(e)}))
48
- sys.exit(1)
49
-
50
-
51
- def main_install_package() -> None:
52
- spec = rs.parse("install-package")
53
- package = spec.package.value
54
- try:
55
- print(json.dumps(install_package(package)))
56
- except CommandError as e:
57
- print(json.dumps({"package": package, "installed": False, "error": str(e)}))
58
- sys.exit(1)
59
- except Exception as e:
60
- # Includes ValueError (bad name) and ToolNotFoundError (no package manager).
61
- print(json.dumps({"error": str(e)}))
62
- sys.exit(1)
63
-
64
-
65
- def main_remove_package() -> None:
66
- spec = rs.parse("remove-package")
67
- package = spec.package.value
68
- try:
69
- print(json.dumps(remove_package(package, purge=spec.purge.value)))
70
- except CommandError as e:
71
- print(json.dumps({"package": package, "removed": False, "error": str(e)}))
72
- sys.exit(1)
73
- except Exception as e:
74
- # Includes ValueError (bad name) and ToolNotFoundError (no package manager).
75
- print(json.dumps({"error": str(e)}))
76
- sys.exit(1)
77
-
78
-
79
- def main_list_autoremovable() -> None:
80
- spec = rs.parse("list-autoremovable")
81
- try:
82
- print(json.dumps(list_autoremovable(purge=spec.purge.value)))
83
- except Exception as e:
84
- # Includes CommandError (yum, failed listing) and ToolNotFoundError.
85
- print(json.dumps({"error": str(e)}))
86
- sys.exit(1)
87
-
88
-
89
- def main_autoremove_packages() -> None:
90
- spec = rs.parse("autoremove-packages")
91
- try:
92
- print(json.dumps(autoremove_packages(purge=spec.purge.value, dry_run=spec.dry_run.value)))
93
- except CommandError as e:
94
- print(json.dumps({"removed": False, "error": str(e)}))
95
- sys.exit(1)
96
- except Exception as e:
97
- # Includes ToolNotFoundError (no package manager).
98
- print(json.dumps({"error": str(e)}))
99
- sys.exit(1)
@@ -1,20 +0,0 @@
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)
@@ -1,23 +0,0 @@
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)
@@ -1,34 +0,0 @@
1
- """The wrapper-side escalation resolver: --sudo / --run-as / --become-method →
2
- (run_as, become_method).
3
- """
4
-
5
- from __future__ import annotations
6
-
7
- from types import SimpleNamespace
8
-
9
- from runspec_linux._escalation import resolve_escalation
10
-
11
-
12
- def _spec(sudo: bool, run_as: str, method: str = "sudo") -> SimpleNamespace:
13
- # A parsed spec exposes each arg as an object with a .value.
14
- return SimpleNamespace(
15
- sudo=SimpleNamespace(value=sudo),
16
- run_as=SimpleNamespace(value=run_as),
17
- become_method=SimpleNamespace(value=method),
18
- )
19
-
20
-
21
- def test_sudo_maps_to_root() -> None:
22
- assert resolve_escalation(_spec(sudo=True, run_as="")) == ("root", "sudo")
23
-
24
-
25
- def test_run_as_user() -> None:
26
- assert resolve_escalation(_spec(sudo=False, run_as="deploy")) == ("deploy", "sudo")
27
-
28
-
29
- def test_neither_is_none() -> None:
30
- assert resolve_escalation(_spec(sudo=False, run_as="")) == (None, "sudo")
31
-
32
-
33
- def test_become_method_passthrough() -> None:
34
- assert resolve_escalation(_spec(sudo=False, run_as="svc", method="su")) == ("svc", "su")