runspec-linux 0.2.0__tar.gz → 0.4.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.
@@ -1,5 +1,59 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.4.0] — 2026-06-20
4
+
5
+ Make the privileged package/power runnables escalate via runspec's `run_as`, and
6
+ add a one-time bootstrap runnable to enable it without a password. Bumps the
7
+ `runspec-linux-core` floor to `>=0.3.0`.
8
+
9
+ - **`run_as = "root"`** on `upgrade-packages`, `install-package`,
10
+ `remove-package`, and `reboot-host`. The console's SSH executor now wraps these
11
+ in `sudo -u root …` before they run, instead of failing with permission denied
12
+ when the SSH login user isn't root. (`list-packages` / `list-upgrades` stay
13
+ un-escalated — least privilege; `list-upgrades --no-refresh` works as any user.)
14
+ A site with a service account can override `run_as` in its own spec.
15
+ - **New `enable-passwordless-sudo`** (confirm) — the one-time setup that makes the
16
+ above work password-free. Takes a **`password`-typed** arg (delivered over the
17
+ console's secret channel and piped to `sudo -S` — never on argv/ps/logs),
18
+ optional `--user` (defaults to the login user), and `--scope`
19
+ (`scoped` = the runspec venv runnables only; `all` = `NOPASSWD: ALL`). Installs
20
+ a `visudo`-validated `/etc/sudoers.d/runspec`, rolling back on a bad result. It
21
+ declares no `run_as` (it bootstraps root itself).
22
+
23
+ Setup flow: run `enable-passwordless-sudo` once per host (pick `all` for
24
+ agent-driven fleet management; `scoped` is enough for manual/CLI runs), then
25
+ `upgrade-packages` and friends run without a password.
26
+
27
+ ## [0.3.0] — 2026-06-20
28
+
29
+ Add 6 runnables for managing a fleet's packages and power state from
30
+ runspec-console — thin wrappers over the new `runspec-linux-core` 0.2.0 helpers
31
+ (dependency floor bumped to `>=0.2.0`). They work across the common distro
32
+ families (apt/dnf/yum/zypper/pacman).
33
+
34
+ **Packages**
35
+
36
+ - `list-packages` (autonomous) — list installed packages, optional `--filter`
37
+ substring and `--limit`.
38
+ - `list-upgrades` (autonomous) — list packages with an available upgrade;
39
+ refreshes the index first, or pass `--no-refresh` to read the cache without
40
+ root.
41
+ - `upgrade-packages` (confirm) — refresh the index and upgrade everything.
42
+ - `install-package` (confirm) — install one or more packages (`--package`,
43
+ space-separated for several).
44
+ - `remove-package` (confirm) — remove one or more packages.
45
+
46
+ **Power**
47
+
48
+ - `reboot-host` (confirm) — reboot the host now, or schedule with
49
+ `--delay-minutes`.
50
+
51
+ The read-only runnables are `autonomous`; the state-changing ones are `confirm`,
52
+ matching `restart-service` / `restart-container`. Missing package manager / a
53
+ non-root failure returns structured JSON (`{... false, "error": ...}`) and a
54
+ non-zero exit rather than a stack trace; package names are validated before they
55
+ reach the package manager.
56
+
3
57
  ## [0.2.0] — 2026-06-09
4
58
 
5
59
  Split the pure logic out into a new dependency-free package,
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: runspec-linux-core>=0.1.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.2.0"
7
+ version = "0.4.0"
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.1.0",
12
+ "runspec-linux-core>=0.3.0",
13
13
  ]
14
14
 
15
15
  [project.scripts]
@@ -33,6 +33,13 @@ who = "runspec_linux.security:main_who"
33
33
  list-containers = "runspec_linux.containers:main_list_containers"
34
34
  container-logs = "runspec_linux.containers:main_container_logs"
35
35
  restart-container = "runspec_linux.containers:main_restart_container"
36
+ list-packages = "runspec_linux.packages:main_list_packages"
37
+ list-upgrades = "runspec_linux.packages:main_list_upgrades"
38
+ upgrade-packages = "runspec_linux.packages:main_upgrade_packages"
39
+ install-package = "runspec_linux.packages:main_install_package"
40
+ remove-package = "runspec_linux.packages:main_remove_package"
41
+ reboot-host = "runspec_linux.power:main_reboot_host"
42
+ enable-passwordless-sudo = "runspec_linux.sudoers:main_enable_passwordless_sudo"
36
43
  nc-command = "runspec_linux.nc_command:main"
37
44
 
38
45
  [project.optional-dependencies]
@@ -13,21 +13,29 @@ from runspec_linux_core import (
13
13
  check_port,
14
14
  check_service,
15
15
  container_logs,
16
+ detect_manager,
16
17
  disk_usage,
18
+ enable_passwordless_sudo,
17
19
  find_large_files,
20
+ install_package,
18
21
  journalctl,
19
22
  last_logins,
20
23
  list_containers,
24
+ list_packages,
21
25
  list_processes,
22
26
  list_services,
27
+ list_upgrades,
23
28
  nc_send,
24
29
  ping_host,
30
+ reboot_host,
31
+ remove_package,
25
32
  restart_container,
26
33
  restart_service,
27
34
  search_log,
28
35
  show_connections,
29
36
  system_info,
30
37
  tail_log,
38
+ upgrade_packages,
31
39
  who,
32
40
  )
33
41
 
@@ -37,20 +45,28 @@ __all__ = [
37
45
  "check_port",
38
46
  "check_service",
39
47
  "container_logs",
48
+ "detect_manager",
40
49
  "disk_usage",
50
+ "enable_passwordless_sudo",
41
51
  "find_large_files",
52
+ "install_package",
42
53
  "journalctl",
43
54
  "last_logins",
44
55
  "list_containers",
56
+ "list_packages",
45
57
  "list_processes",
46
58
  "list_services",
59
+ "list_upgrades",
47
60
  "nc_send",
48
61
  "ping_host",
62
+ "reboot_host",
63
+ "remove_package",
49
64
  "restart_container",
50
65
  "restart_service",
51
66
  "search_log",
52
67
  "show_connections",
53
68
  "system_info",
54
69
  "tail_log",
70
+ "upgrade_packages",
55
71
  "who",
56
72
  ]
@@ -8,7 +8,7 @@ from runspec_linux_core.errors import CommandError
8
8
 
9
9
  def main_list_containers() -> None:
10
10
  spec = rs.parse("list-containers")
11
- include_all = bool(spec.all)
11
+ include_all = spec.all.value
12
12
  try:
13
13
  print(json.dumps(list_containers(include_all)))
14
14
  except Exception as e:
@@ -19,8 +19,8 @@ def main_list_containers() -> None:
19
19
 
20
20
  def main_container_logs() -> None:
21
21
  spec = rs.parse("container-logs")
22
- container = str(spec.container)
23
- lines = int(spec.lines)
22
+ container = spec.container.value
23
+ lines = spec.lines.value
24
24
  try:
25
25
  print(json.dumps(container_logs(container, lines)))
26
26
  except Exception as e:
@@ -30,7 +30,7 @@ def main_container_logs() -> None:
30
30
 
31
31
  def main_restart_container() -> None:
32
32
  spec = rs.parse("restart-container")
33
- container = str(spec.container)
33
+ container = spec.container.value
34
34
  try:
35
35
  print(json.dumps(restart_container(container)))
36
36
  except CommandError as e:
@@ -7,9 +7,9 @@ from runspec_linux_core import backup_files, find_large_files
7
7
 
8
8
  def main_find_large_files() -> None:
9
9
  spec = rs.parse("find-large-files")
10
- search_path = str(spec.path)
11
- min_mb = float(spec.min_mb)
12
- limit = int(spec.limit)
10
+ search_path = str(spec.path.value)
11
+ min_mb = spec.min_mb.value
12
+ limit = spec.limit.value
13
13
  try:
14
14
  print(json.dumps(find_large_files(search_path, min_mb, limit)))
15
15
  except Exception as e:
@@ -19,8 +19,8 @@ def main_find_large_files() -> None:
19
19
 
20
20
  def main_backup_files() -> None:
21
21
  spec = rs.parse("backup-files")
22
- source = str(spec.source)
23
- destination = str(spec.destination)
22
+ source = str(spec.source.value)
23
+ destination = str(spec.destination.value)
24
24
  try:
25
25
  print(json.dumps(backup_files(source, destination)))
26
26
  except Exception as e:
@@ -10,8 +10,8 @@ from runspec_linux_core.errors import ToolNotFoundError
10
10
 
11
11
  def main_tail_log() -> None:
12
12
  spec = rs.parse("tail-log")
13
- file_path = str(spec.file)
14
- lines = int(spec.lines)
13
+ file_path = str(spec.file.value)
14
+ lines = spec.lines.value
15
15
  try:
16
16
  print(json.dumps(tail_log(file_path, lines)))
17
17
  except OSError as e:
@@ -21,9 +21,9 @@ def main_tail_log() -> None:
21
21
 
22
22
  def main_search_log() -> None:
23
23
  spec = rs.parse("search-log")
24
- file_path = str(spec.file)
25
- pattern = str(spec.pattern)
26
- limit = int(spec.limit)
24
+ file_path = str(spec.file.value)
25
+ pattern = spec.pattern.value
26
+ limit = spec.limit.value
27
27
  try:
28
28
  print(json.dumps(search_log(file_path, pattern, limit)))
29
29
  except OSError as e:
@@ -36,9 +36,9 @@ def main_search_log() -> None:
36
36
 
37
37
  def main_journalctl() -> None:
38
38
  spec = rs.parse("journalctl")
39
- unit = str(spec.unit)
40
- lines = int(spec.lines)
41
- since = str(spec.since) if spec.since is not None else None
39
+ unit = spec.unit.value
40
+ lines = spec.lines.value
41
+ since = spec.since.value
42
42
  try:
43
43
  print(json.dumps(journalctl(unit, lines, since)))
44
44
  except ToolNotFoundError as e:
@@ -13,11 +13,11 @@ __all__ = ["main", "nc_send"]
13
13
 
14
14
  def main() -> None:
15
15
  spec = rs.parse("nc-command")
16
- host = str(spec.host)
17
- port = int(spec.port)
18
- command = str(spec.command)
19
- wait = float(spec.wait)
20
- read_timeout = float(spec.read_timeout)
16
+ host = spec.host.value
17
+ port = spec.port.value
18
+ command = spec.command.value
19
+ wait = spec.wait.value
20
+ read_timeout = spec.read_timeout.value
21
21
 
22
22
  try:
23
23
  response = nc_send(host, port, command, wait=wait, read_timeout=read_timeout)
@@ -7,8 +7,8 @@ from runspec_linux_core import check_port, ping_host, show_connections
7
7
 
8
8
  def main_ping_host() -> None:
9
9
  spec = rs.parse("ping-host")
10
- host = str(spec.host)
11
- count = int(spec.count)
10
+ host = spec.host.value
11
+ count = spec.count.value
12
12
  try:
13
13
  print(json.dumps(ping_host(host, count)))
14
14
  except Exception as e:
@@ -18,15 +18,15 @@ def main_ping_host() -> None:
18
18
 
19
19
  def main_check_port() -> None:
20
20
  spec = rs.parse("check-port")
21
- host = str(spec.host)
22
- port = int(spec.port)
23
- timeout = float(spec.timeout)
21
+ host = spec.host.value
22
+ port = spec.port.value
23
+ timeout = spec.timeout.value
24
24
  print(json.dumps(check_port(host, port, timeout)))
25
25
 
26
26
 
27
27
  def main_show_connections() -> None:
28
28
  spec = rs.parse("show-connections")
29
- state_filter = str(spec.state)
29
+ state_filter = spec.state.value
30
30
  try:
31
31
  print(json.dumps(show_connections(state_filter)))
32
32
  except Exception as e:
@@ -0,0 +1,74 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import (
6
+ install_package,
7
+ list_packages,
8
+ list_upgrades,
9
+ remove_package,
10
+ upgrade_packages,
11
+ )
12
+ from runspec_linux_core.errors import CommandError
13
+
14
+
15
+ def main_list_packages() -> None:
16
+ spec = rs.parse("list-packages")
17
+ name_filter = spec.filter.value
18
+ limit = spec.limit.value
19
+ try:
20
+ print(json.dumps(list_packages(name_filter, limit)))
21
+ except Exception as e:
22
+ # Includes ToolNotFoundError (no package manager) and CommandError.
23
+ print(json.dumps({"error": str(e)}))
24
+ sys.exit(1)
25
+
26
+
27
+ def main_list_upgrades() -> None:
28
+ spec = rs.parse("list-upgrades")
29
+ try:
30
+ print(json.dumps(list_upgrades(refresh=not spec.no_refresh.value)))
31
+ except Exception as e:
32
+ print(json.dumps({"error": str(e)}))
33
+ sys.exit(1)
34
+
35
+
36
+ def main_upgrade_packages() -> None:
37
+ rs.parse("upgrade-packages")
38
+ try:
39
+ print(json.dumps(upgrade_packages()))
40
+ except CommandError as e:
41
+ print(json.dumps({"upgraded": False, "error": str(e)}))
42
+ sys.exit(1)
43
+ except Exception as e:
44
+ # Includes ToolNotFoundError (no package manager).
45
+ print(json.dumps({"error": str(e)}))
46
+ sys.exit(1)
47
+
48
+
49
+ def main_install_package() -> None:
50
+ spec = rs.parse("install-package")
51
+ package = spec.package.value
52
+ try:
53
+ print(json.dumps(install_package(package)))
54
+ except CommandError as e:
55
+ print(json.dumps({"package": package, "installed": False, "error": str(e)}))
56
+ sys.exit(1)
57
+ except Exception as e:
58
+ # Includes ValueError (bad name) and ToolNotFoundError (no package manager).
59
+ print(json.dumps({"error": str(e)}))
60
+ sys.exit(1)
61
+
62
+
63
+ def main_remove_package() -> None:
64
+ spec = rs.parse("remove-package")
65
+ package = spec.package.value
66
+ try:
67
+ print(json.dumps(remove_package(package)))
68
+ except CommandError as e:
69
+ print(json.dumps({"package": package, "removed": False, "error": str(e)}))
70
+ sys.exit(1)
71
+ except Exception as e:
72
+ # Includes ValueError (bad name) and ToolNotFoundError (no package manager).
73
+ print(json.dumps({"error": str(e)}))
74
+ sys.exit(1)
@@ -0,0 +1,20 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import reboot_host
6
+ from runspec_linux_core.errors import CommandError
7
+
8
+
9
+ def main_reboot_host() -> None:
10
+ spec = rs.parse("reboot-host")
11
+ delay_minutes = spec.delay_minutes.value
12
+ try:
13
+ print(json.dumps(reboot_host(delay_minutes)))
14
+ except CommandError as e:
15
+ print(json.dumps({"scheduled": False, "error": str(e)}))
16
+ sys.exit(1)
17
+ except Exception as e:
18
+ # Includes ToolNotFoundError (no shutdown/systemctl).
19
+ print(json.dumps({"error": str(e)}))
20
+ sys.exit(1)
@@ -299,6 +299,111 @@ type = "str"
299
299
  short = "-c"
300
300
  description = "Container name or ID"
301
301
 
302
+ # ── Packages ──────────────────────────────────────────────────────────────────
303
+
304
+ [list-packages]
305
+ serve = ["local"]
306
+ description = "List installed packages (apt/dnf/yum/zypper/pacman), optionally filtered by name"
307
+ autonomy = "autonomous"
308
+ output = "json"
309
+
310
+ [list-packages.args.filter]
311
+ type = "str"
312
+ short = "-f"
313
+ description = "Only list packages whose name contains this string"
314
+ required = false
315
+
316
+ [list-packages.args.limit]
317
+ type = "int"
318
+ short = "-n"
319
+ description = "Maximum number of packages to return (sorted by name)"
320
+ default = 100
321
+
322
+ [list-upgrades]
323
+ serve = ["local"]
324
+ description = "List packages with an available upgrade (refreshes the package index first)"
325
+ autonomy = "autonomous"
326
+ output = "json"
327
+
328
+ [list-upgrades.args.no-refresh]
329
+ type = "flag"
330
+ description = "Read the cached package index instead of refreshing it first (no root needed)"
331
+ default = false
332
+
333
+ [upgrade-packages]
334
+ serve = ["local"]
335
+ description = "Refresh the package index and upgrade all installed packages (needs root)"
336
+ autonomy = "confirm"
337
+ output = "json"
338
+ run_as = "root"
339
+
340
+ [install-package]
341
+ serve = ["local"]
342
+ description = "Install one or more packages by name (needs root)"
343
+ autonomy = "confirm"
344
+ output = "json"
345
+ run_as = "root"
346
+
347
+ [install-package.args.package]
348
+ type = "str"
349
+ short = "-p"
350
+ description = "Package name to install (space-separated for several)"
351
+
352
+ [remove-package]
353
+ serve = ["local"]
354
+ description = "Remove one or more packages by name (needs root)"
355
+ autonomy = "confirm"
356
+ output = "json"
357
+ run_as = "root"
358
+
359
+ [remove-package.args.package]
360
+ type = "str"
361
+ short = "-p"
362
+ description = "Package name to remove (space-separated for several)"
363
+
364
+ # ── Power ─────────────────────────────────────────────────────────────────────
365
+
366
+ [reboot-host]
367
+ serve = ["local"]
368
+ description = "Reboot the host, optionally after a delay in minutes (needs root)"
369
+ autonomy = "confirm"
370
+ output = "json"
371
+ run_as = "root"
372
+
373
+ [reboot-host.args.delay-minutes]
374
+ type = "int"
375
+ short = "-d"
376
+ description = "Minutes to wait before rebooting (0 = immediately)"
377
+ default = 0
378
+
379
+ # ── Privilege bootstrap ───────────────────────────────────────────────────────
380
+
381
+ # One-time setup so the run_as="root" runnables above need no password. Itself
382
+ # escalates with the supplied password via `sudo -S` (read from stdin, never
383
+ # argv), so it must NOT declare run_as. Validated with visudo before + after
384
+ # install and rolled back on failure.
385
+ [enable-passwordless-sudo]
386
+ serve = ["local"]
387
+ description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
388
+ autonomy = "confirm"
389
+ output = "json"
390
+
391
+ [enable-passwordless-sudo.args.password]
392
+ type = "password"
393
+ description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
394
+
395
+ [enable-passwordless-sudo.args.user]
396
+ type = "str"
397
+ short = "-u"
398
+ description = "User to grant passwordless sudo (defaults to the current login user)"
399
+ required = false
400
+
401
+ [enable-passwordless-sudo.args.scope]
402
+ type = "choice"
403
+ options = ["scoped", "all"]
404
+ default = "scoped"
405
+ description = "scoped = NOPASSWD only for the runspec venv runnables (manual/CLI runs); all = NOPASSWD: ALL (also covers agent-driven runs)"
406
+
302
407
  # ── TCP interfaces (nc-command) ────────────────────────────────────────────────
303
408
 
304
409
  [nc-command]
@@ -7,7 +7,7 @@ from runspec_linux_core import last_logins, who
7
7
 
8
8
  def main_last_logins() -> None:
9
9
  spec = rs.parse("last-logins")
10
- limit = int(spec.limit)
10
+ limit = spec.limit.value
11
11
  try:
12
12
  print(json.dumps(last_logins(limit)))
13
13
  except Exception as e:
@@ -8,7 +8,7 @@ from runspec_linux_core.errors import CommandError
8
8
 
9
9
  def main_check_service() -> None:
10
10
  spec = rs.parse("check-service")
11
- service = str(spec.service)
11
+ service = spec.service.value
12
12
  try:
13
13
  print(json.dumps(check_service(service)))
14
14
  except Exception as e:
@@ -18,7 +18,7 @@ def main_check_service() -> None:
18
18
 
19
19
  def main_list_services() -> None:
20
20
  spec = rs.parse("list-services")
21
- state_filter = str(spec.state)
21
+ state_filter = spec.state.value
22
22
  try:
23
23
  print(json.dumps(list_services(state_filter)))
24
24
  except Exception as e:
@@ -28,7 +28,7 @@ def main_list_services() -> None:
28
28
 
29
29
  def main_restart_service() -> None:
30
30
  spec = rs.parse("restart-service")
31
- service = str(spec.service)
31
+ service = spec.service.value
32
32
  try:
33
33
  print(json.dumps(restart_service(service)))
34
34
  except CommandError as e:
@@ -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)
@@ -34,8 +34,8 @@ def main_check_memory() -> None:
34
34
 
35
35
  def main_list_processes() -> None:
36
36
  spec = rs.parse("list-processes")
37
- name_filter = str(spec.filter) if spec.filter is not None else None
38
- limit = int(spec.limit)
37
+ name_filter = spec.filter.value
38
+ limit = spec.limit.value
39
39
  try:
40
40
  print(json.dumps(list_processes(name_filter, limit)))
41
41
  except Exception as e:
File without changes