runspec-linux 0.3.0__tar.gz → 0.4.1__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.
@@ -1,5 +1,39 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.4.1] — 2026-06-20
4
+
5
+ Give `list-upgrades` `run_as = "root"`. It refreshes the package index
6
+ (`apt-get update` / `dnf check-update` / …) by default, which needs root — but it
7
+ had no `run_as`, so over the console it ran as the SSH login user and failed with
8
+ `Could not open lock file /var/lib/apt/lists/lock — Permission denied`. It now
9
+ escalates like the rest of the package family (works password-free with the
10
+ `enable-passwordless-sudo` drop-in). `--no-refresh` still skips the index refresh
11
+ and lists from the cache (now just faster, rather than a non-root workaround).
12
+
13
+ ## [0.4.0] — 2026-06-20
14
+
15
+ Make the privileged package/power runnables escalate via runspec's `run_as`, and
16
+ add a one-time bootstrap runnable to enable it without a password. Bumps the
17
+ `runspec-linux-core` floor to `>=0.3.0`.
18
+
19
+ - **`run_as = "root"`** on `upgrade-packages`, `install-package`,
20
+ `remove-package`, and `reboot-host`. The console's SSH executor now wraps these
21
+ in `sudo -u root …` before they run, instead of failing with permission denied
22
+ when the SSH login user isn't root. (`list-packages` / `list-upgrades` stay
23
+ un-escalated — least privilege; `list-upgrades --no-refresh` works as any user.)
24
+ A site with a service account can override `run_as` in its own spec.
25
+ - **New `enable-passwordless-sudo`** (confirm) — the one-time setup that makes the
26
+ above work password-free. Takes a **`password`-typed** arg (delivered over the
27
+ console's secret channel and piped to `sudo -S` — never on argv/ps/logs),
28
+ optional `--user` (defaults to the login user), and `--scope`
29
+ (`scoped` = the runspec venv runnables only; `all` = `NOPASSWD: ALL`). Installs
30
+ a `visudo`-validated `/etc/sudoers.d/runspec`, rolling back on a bad result. It
31
+ declares no `run_as` (it bootstraps root itself).
32
+
33
+ Setup flow: run `enable-passwordless-sudo` once per host (pick `all` for
34
+ agent-driven fleet management; `scoped` is enough for manual/CLI runs), then
35
+ `upgrade-packages` and friends run without a password.
36
+
3
37
  ## [0.3.0] — 2026-06-20
4
38
 
5
39
  Add 6 runnables for managing a fleet's packages and power state from
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: runspec-linux-core>=0.2.0
6
+ Requires-Dist: runspec-linux-core>=0.3.0
7
7
  Requires-Dist: runspec>=0.27.0
8
8
  Provides-Extra: dev
9
9
  Requires-Dist: mypy; extra == 'dev'
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux"
7
- version = "0.3.0"
7
+ version = "0.4.1"
8
8
  requires-python = ">=3.10"
9
9
  description = "Linux system admin runnables for runspec"
10
10
  dependencies = [
11
11
  "runspec>=0.27.0",
12
- "runspec-linux-core>=0.2.0",
12
+ "runspec-linux-core>=0.3.0",
13
13
  ]
14
14
 
15
15
  [project.scripts]
@@ -39,6 +39,7 @@ upgrade-packages = "runspec_linux.packages:main_upgrade_packages"
39
39
  install-package = "runspec_linux.packages:main_install_package"
40
40
  remove-package = "runspec_linux.packages:main_remove_package"
41
41
  reboot-host = "runspec_linux.power:main_reboot_host"
42
+ enable-passwordless-sudo = "runspec_linux.sudoers:main_enable_passwordless_sudo"
42
43
  nc-command = "runspec_linux.nc_command:main"
43
44
 
44
45
  [project.optional-dependencies]
@@ -15,6 +15,7 @@ from runspec_linux_core import (
15
15
  container_logs,
16
16
  detect_manager,
17
17
  disk_usage,
18
+ enable_passwordless_sudo,
18
19
  find_large_files,
19
20
  install_package,
20
21
  journalctl,
@@ -46,6 +47,7 @@ __all__ = [
46
47
  "container_logs",
47
48
  "detect_manager",
48
49
  "disk_usage",
50
+ "enable_passwordless_sudo",
49
51
  "find_large_files",
50
52
  "install_package",
51
53
  "journalctl",
@@ -324,10 +324,11 @@ serve = ["local"]
324
324
  description = "List packages with an available upgrade (refreshes the package index first)"
325
325
  autonomy = "autonomous"
326
326
  output = "json"
327
+ run_as = "root"
327
328
 
328
329
  [list-upgrades.args.no-refresh]
329
330
  type = "flag"
330
- description = "Read the cached package index instead of refreshing it first (no root needed)"
331
+ description = "Skip refreshing the package index — list from the cached index (faster)"
331
332
  default = false
332
333
 
333
334
  [upgrade-packages]
@@ -335,12 +336,14 @@ serve = ["local"]
335
336
  description = "Refresh the package index and upgrade all installed packages (needs root)"
336
337
  autonomy = "confirm"
337
338
  output = "json"
339
+ run_as = "root"
338
340
 
339
341
  [install-package]
340
342
  serve = ["local"]
341
343
  description = "Install one or more packages by name (needs root)"
342
344
  autonomy = "confirm"
343
345
  output = "json"
346
+ run_as = "root"
344
347
 
345
348
  [install-package.args.package]
346
349
  type = "str"
@@ -352,6 +355,7 @@ serve = ["local"]
352
355
  description = "Remove one or more packages by name (needs root)"
353
356
  autonomy = "confirm"
354
357
  output = "json"
358
+ run_as = "root"
355
359
 
356
360
  [remove-package.args.package]
357
361
  type = "str"
@@ -365,6 +369,7 @@ serve = ["local"]
365
369
  description = "Reboot the host, optionally after a delay in minutes (needs root)"
366
370
  autonomy = "confirm"
367
371
  output = "json"
372
+ run_as = "root"
368
373
 
369
374
  [reboot-host.args.delay-minutes]
370
375
  type = "int"
@@ -372,6 +377,34 @@ short = "-d"
372
377
  description = "Minutes to wait before rebooting (0 = immediately)"
373
378
  default = 0
374
379
 
380
+ # ── Privilege bootstrap ───────────────────────────────────────────────────────
381
+
382
+ # One-time setup so the run_as="root" runnables above need no password. Itself
383
+ # escalates with the supplied password via `sudo -S` (read from stdin, never
384
+ # argv), so it must NOT declare run_as. Validated with visudo before + after
385
+ # install and rolled back on failure.
386
+ [enable-passwordless-sudo]
387
+ serve = ["local"]
388
+ description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
389
+ autonomy = "confirm"
390
+ output = "json"
391
+
392
+ [enable-passwordless-sudo.args.password]
393
+ type = "password"
394
+ description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
395
+
396
+ [enable-passwordless-sudo.args.user]
397
+ type = "str"
398
+ short = "-u"
399
+ description = "User to grant passwordless sudo (defaults to the current login user)"
400
+ required = false
401
+
402
+ [enable-passwordless-sudo.args.scope]
403
+ type = "choice"
404
+ options = ["scoped", "all"]
405
+ default = "scoped"
406
+ description = "scoped = NOPASSWD only for the runspec venv runnables (manual/CLI runs); all = NOPASSWD: ALL (also covers agent-driven runs)"
407
+
375
408
  # ── TCP interfaces (nc-command) ────────────────────────────────────────────────
376
409
 
377
410
  [nc-command]
@@ -0,0 +1,23 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import enable_passwordless_sudo
6
+ from runspec_linux_core.errors import CommandError
7
+
8
+
9
+ def main_enable_passwordless_sudo() -> None:
10
+ spec = rs.parse("enable-passwordless-sudo")
11
+ password = spec.password.value
12
+ user = spec.user.value
13
+ scope = spec.scope.value
14
+ try:
15
+ print(json.dumps(enable_passwordless_sudo(password, user, scope)))
16
+ except CommandError as e:
17
+ # Auth failure / sudoers validation failure (rolled back).
18
+ print(json.dumps({"installed": False, "error": str(e)}))
19
+ sys.exit(1)
20
+ except Exception as e:
21
+ # Includes ValueError (bad scope/user) and ToolNotFoundError (no sudo/visudo).
22
+ print(json.dumps({"error": str(e)}))
23
+ sys.exit(1)
File without changes