runspec-linux 0.6.2__tar.gz → 0.8.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.
Potentially problematic release.
This version of runspec-linux might be problematic. Click here for more details.
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/CHANGELOG.md +51 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/PKG-INFO +4 -3
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/pyproject.toml +18 -3
- runspec_linux-0.8.0/runspec_linux/_escalation.py +20 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/containers.py +8 -3
- runspec_linux-0.8.0/runspec_linux/fs/__init__.py +12 -0
- runspec_linux-0.8.0/runspec_linux/fs/_dispatch.py +63 -0
- runspec_linux-0.8.0/runspec_linux/fs/_worker.py +32 -0
- runspec_linux-0.8.0/runspec_linux/fs/cli.py +131 -0
- runspec_linux-0.8.0/runspec_linux/investigate.py +50 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/logs.py +15 -4
- runspec_linux-0.8.0/runspec_linux/network.py +80 -0
- runspec_linux-0.8.0/runspec_linux/perf.py +54 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/runspec.toml +455 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/services.py +4 -1
- runspec_linux-0.8.0/tests/test_escalation.py +34 -0
- runspec_linux-0.8.0/tests/test_fs.py +51 -0
- runspec_linux-0.6.2/runspec_linux/network.py +0 -34
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/.gitignore +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/__init__.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/commands.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/files.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/nc_command.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/packages.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/power.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/security.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/sudoers.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/system_info.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/venvs.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/runspec_linux/webops.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/tests/__init__.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/tests/test_nc_send.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.8.0}/tests/test_webops.py +0 -0
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# runspec-linux Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0] — 2026-08-07
|
|
4
|
+
|
|
5
|
+
The Docker runnables can now run as another user. `list-containers`,
|
|
6
|
+
`container-logs`, and `restart-container` gain the mutually-exclusive
|
|
7
|
+
`--run-as <user>` / `--sudo` pair (+ `--become-method sudo|su`) — the same
|
|
8
|
+
`exclusive`-group escalation the service/log runnables use — so they work where
|
|
9
|
+
the Docker socket needs root or `docker`-group membership (`--sudo` runs docker
|
|
10
|
+
as root). Requires `runspec-linux-core >= 0.5.0`.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [0.7.0] — 2026-08-01
|
|
14
|
+
|
|
15
|
+
Enhancements for corporate application support, devops, and performance work.
|
|
16
|
+
|
|
17
|
+
### Added — filesystem (folded from runspec-fs)
|
|
18
|
+
|
|
19
|
+
- The **`fs`** runnable is now folded into the Linux pack (backed by the
|
|
20
|
+
dependency-free `runspec-fs-core`): one verb per Filesystem MCP tool
|
|
21
|
+
(`read-text-file` … `move-file`), reads `autonomous` and mutations `confirm`.
|
|
22
|
+
`runspec serve` flattens the verbs into leaf `fs_<verb>` MCP tools, so a
|
|
23
|
+
runspec-console agent gets local **and** remote filesystem access on every
|
|
24
|
+
fleet host through the OS pack it already installs — no separate `runspec-fs`
|
|
25
|
+
needed. Per-invocation `--run-as <user>` / `--become-method` are inherited on
|
|
26
|
+
every verb (POSIX escalation, no directory jail).
|
|
27
|
+
|
|
28
|
+
### Added — new runnables
|
|
29
|
+
|
|
30
|
+
- **Perf tuning & analysis:** `get-sysctl`, `set-sysctl` (a privileged write —
|
|
31
|
+
`confirm`, optional `--persist` to `/etc/sysctl.d`), `cpu-info` (governor /
|
|
32
|
+
frequency / topology), `pressure` (kernel PSI stats).
|
|
33
|
+
- **App discovery & investigation:** `service-detail` (a unit's ExecStart binary,
|
|
34
|
+
working dir, user, env files, unit-file path, state), `open-ports` (listening
|
|
35
|
+
port → pid → process), `owning-package` (which package owns a path, dpkg/rpm),
|
|
36
|
+
`failed-units`.
|
|
37
|
+
- **Network deep-diagnostics:** `trace-route`, `route-table`, `socket-stats`,
|
|
38
|
+
`dns-config`.
|
|
39
|
+
|
|
40
|
+
### Added — `--run-as` / `--sudo` on the privileged runnables
|
|
41
|
+
|
|
42
|
+
- `restart-service`, `tail-log`, `search-log`, `journalctl`, `set-sysctl`,
|
|
43
|
+
`service-detail`, and `open-ports` now take a mutually-exclusive
|
|
44
|
+
**`--run-as <user>`** / **`--sudo`** pair (plus `--become-method sudo|su`) so
|
|
45
|
+
they can run against root-owned / service-account-owned resources.
|
|
46
|
+
Escalation is internal (`sudo -n -u` / `su`), governed by an `exclusive` group
|
|
47
|
+
so the two flags can't be combined.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- Requires `runspec >= 0.44.0` (leaf-tool inherited globals via `runspec serve`)
|
|
52
|
+
and `runspec-linux-core >= 0.4.0`; adds a `runspec-fs-core >= 0.2.0` dependency.
|
|
53
|
+
|
|
3
54
|
## [0.6.2] — 2026-07-08
|
|
4
55
|
|
|
5
56
|
Pull in `runspec-linux-core >= 0.3.5` so `install-into-venv` now normalises the
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runspec-linux
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Linux system admin runnables for runspec
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
-
Requires-Dist: runspec-
|
|
6
|
+
Requires-Dist: runspec-fs-core>=0.2.0
|
|
7
|
+
Requires-Dist: runspec-linux-core>=0.5.0
|
|
7
8
|
Requires-Dist: runspec-webops-core>=0.1.0
|
|
8
|
-
Requires-Dist: runspec>=0.
|
|
9
|
+
Requires-Dist: runspec>=0.44.0
|
|
9
10
|
Provides-Extra: dev
|
|
10
11
|
Requires-Dist: mypy; extra == 'dev'
|
|
11
12
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -4,13 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-linux"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
9
|
description = "Linux system admin runnables for runspec"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"runspec>=0.
|
|
12
|
-
"runspec-linux-core>=0.
|
|
11
|
+
"runspec>=0.44.0",
|
|
12
|
+
"runspec-linux-core>=0.5.0",
|
|
13
13
|
"runspec-webops-core>=0.1.0",
|
|
14
|
+
"runspec-fs-core>=0.2.0",
|
|
14
15
|
]
|
|
15
16
|
|
|
16
17
|
[project.scripts]
|
|
@@ -27,6 +28,18 @@ journalctl = "runspec_linux.logs:main_journalctl"
|
|
|
27
28
|
ping-host = "runspec_linux.network:main_ping_host"
|
|
28
29
|
check-port = "runspec_linux.network:main_check_port"
|
|
29
30
|
show-connections = "runspec_linux.network:main_show_connections"
|
|
31
|
+
trace-route = "runspec_linux.network:main_trace_route"
|
|
32
|
+
route-table = "runspec_linux.network:main_route_table"
|
|
33
|
+
socket-stats = "runspec_linux.network:main_socket_stats"
|
|
34
|
+
dns-config = "runspec_linux.network:main_dns_config"
|
|
35
|
+
get-sysctl = "runspec_linux.perf:main_get_sysctl"
|
|
36
|
+
set-sysctl = "runspec_linux.perf:main_set_sysctl"
|
|
37
|
+
cpu-info = "runspec_linux.perf:main_cpu_info"
|
|
38
|
+
pressure = "runspec_linux.perf:main_pressure"
|
|
39
|
+
service-detail = "runspec_linux.investigate:main_service_detail"
|
|
40
|
+
open-ports = "runspec_linux.investigate:main_open_ports"
|
|
41
|
+
owning-package = "runspec_linux.investigate:main_owning_package"
|
|
42
|
+
failed-units = "runspec_linux.investigate:main_failed_units"
|
|
30
43
|
find-large-files = "runspec_linux.files:main_find_large_files"
|
|
31
44
|
backup-files = "runspec_linux.files:main_backup_files"
|
|
32
45
|
last-logins = "runspec_linux.security:main_last_logins"
|
|
@@ -46,6 +59,8 @@ install-into-venv = "runspec_linux.venvs:main_install_into_venv"
|
|
|
46
59
|
configure-pip = "runspec_linux.venvs:main_configure_pip"
|
|
47
60
|
nc-command = "runspec_linux.nc_command:main"
|
|
48
61
|
which = "runspec_linux.commands:main_which"
|
|
62
|
+
# Filesystem (folded from runspec-fs; backed by runspec-fs-core)
|
|
63
|
+
fs = "runspec_linux.fs.cli:main"
|
|
49
64
|
# Web / TLS probes (runspec-webops-core; cross-platform)
|
|
50
65
|
cert-check = "runspec_linux.webops:main_cert_check"
|
|
51
66
|
cert-chain = "runspec_linux.webops:main_cert_chain"
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
|
@@ -5,12 +5,15 @@ import runspec as rs
|
|
|
5
5
|
from runspec_linux_core import container_logs, list_containers, restart_container
|
|
6
6
|
from runspec_linux_core.errors import CommandError
|
|
7
7
|
|
|
8
|
+
from runspec_linux._escalation import resolve_escalation
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
def main_list_containers() -> None:
|
|
10
12
|
spec = rs.parse("list-containers")
|
|
11
13
|
include_all = spec.all.value
|
|
14
|
+
run_as, method = resolve_escalation(spec)
|
|
12
15
|
try:
|
|
13
|
-
print(json.dumps(list_containers(include_all)))
|
|
16
|
+
print(json.dumps(list_containers(include_all, run_as=run_as, become_method=method)))
|
|
14
17
|
except Exception as e:
|
|
15
18
|
# Includes ToolNotFoundError (docker absent) and CommandError (docker ps failed).
|
|
16
19
|
print(json.dumps({"error": str(e)}))
|
|
@@ -21,8 +24,9 @@ def main_container_logs() -> None:
|
|
|
21
24
|
spec = rs.parse("container-logs")
|
|
22
25
|
container = spec.container.value
|
|
23
26
|
lines = spec.lines.value
|
|
27
|
+
run_as, method = resolve_escalation(spec)
|
|
24
28
|
try:
|
|
25
|
-
print(json.dumps(container_logs(container, lines)))
|
|
29
|
+
print(json.dumps(container_logs(container, lines, run_as=run_as, become_method=method)))
|
|
26
30
|
except Exception as e:
|
|
27
31
|
print(json.dumps({"error": str(e)}))
|
|
28
32
|
sys.exit(1)
|
|
@@ -31,8 +35,9 @@ def main_container_logs() -> None:
|
|
|
31
35
|
def main_restart_container() -> None:
|
|
32
36
|
spec = rs.parse("restart-container")
|
|
33
37
|
container = spec.container.value
|
|
38
|
+
run_as, method = resolve_escalation(spec)
|
|
34
39
|
try:
|
|
35
|
-
print(json.dumps(restart_container(container)))
|
|
40
|
+
print(json.dumps(restart_container(container, run_as=run_as, become_method=method)))
|
|
36
41
|
except CommandError as e:
|
|
37
42
|
print(json.dumps({"container": container, "restarted": False, "error": str(e)}))
|
|
38
43
|
sys.exit(1)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The ``fs`` runnable folded into runspec-linux.
|
|
2
|
+
|
|
3
|
+
The filesystem is part of the operating system, so the Linux pack ships the
|
|
4
|
+
filesystem runnable directly (backed by the dependency-free ``runspec-fs-core``)
|
|
5
|
+
rather than leaving it to the separate ``runspec-fs`` package. Installing
|
|
6
|
+
runspec-linux therefore gives a runspec-console agent local *and* remote
|
|
7
|
+
filesystem access on every fleet host — one verb per Filesystem MCP tool,
|
|
8
|
+
flattened by ``runspec serve`` into leaf ``fs_<verb>`` tools.
|
|
9
|
+
|
|
10
|
+
The code mirrors ``runspec-fs`` (``cli`` + runspec-free ``_dispatch`` /
|
|
11
|
+
``_worker``); all logic lives in ``runspec_fs_core``.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Verb → core-helper dispatch, importing only ``runspec_fs_core`` (no runspec).
|
|
2
|
+
|
|
3
|
+
Shared by ``cli.py`` (the in-process path) and ``_worker.py`` (the ``--run-as``
|
|
4
|
+
path), so both map a verb name + a plain-JSON kwargs dict to the same core
|
|
5
|
+
helper. Keeping it runspec-free means the worker — which runs as a *different* OS
|
|
6
|
+
user under sudo/su — needs only the pure core on its import path, not runspec.
|
|
7
|
+
|
|
8
|
+
A verbatim copy of ``runspec_fs._dispatch`` so the Linux pack stays
|
|
9
|
+
self-contained; the logic is the shared ``runspec_fs_core``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from runspec_fs_core import (
|
|
17
|
+
allowed_directories,
|
|
18
|
+
create_directory,
|
|
19
|
+
directory_tree,
|
|
20
|
+
edit_file,
|
|
21
|
+
get_file_info,
|
|
22
|
+
list_directory,
|
|
23
|
+
list_directory_with_sizes,
|
|
24
|
+
move_file,
|
|
25
|
+
read_media_file,
|
|
26
|
+
read_multiple_files,
|
|
27
|
+
read_text_file,
|
|
28
|
+
search_files,
|
|
29
|
+
write_file,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_verb(verb: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
"""Run ``verb`` with plain ``kwargs`` (already unpacked from the parsed spec,
|
|
35
|
+
JSON-round-trip safe). Raises on failure like the core helpers do."""
|
|
36
|
+
if verb == "read-text-file":
|
|
37
|
+
return read_text_file(kwargs["path"], head=kwargs.get("head"), tail=kwargs.get("tail"))
|
|
38
|
+
if verb == "read-media-file":
|
|
39
|
+
return read_media_file(kwargs["path"])
|
|
40
|
+
if verb == "read-multiple-files":
|
|
41
|
+
return read_multiple_files(kwargs["paths"])
|
|
42
|
+
if verb == "list-directory":
|
|
43
|
+
return list_directory(kwargs["path"])
|
|
44
|
+
if verb == "list-directory-with-sizes":
|
|
45
|
+
return list_directory_with_sizes(kwargs["path"], sort_by=kwargs["sort_by"])
|
|
46
|
+
if verb == "directory-tree":
|
|
47
|
+
return directory_tree(kwargs["path"], exclude=kwargs.get("exclude"))
|
|
48
|
+
if verb == "search-files":
|
|
49
|
+
return search_files(kwargs["path"], kwargs["pattern"], exclude=kwargs.get("exclude"))
|
|
50
|
+
if verb == "get-file-info":
|
|
51
|
+
return get_file_info(kwargs["path"])
|
|
52
|
+
if verb == "list-allowed-directories":
|
|
53
|
+
return allowed_directories()
|
|
54
|
+
if verb == "write-file":
|
|
55
|
+
return write_file(kwargs["path"], kwargs["content"])
|
|
56
|
+
if verb == "edit-file":
|
|
57
|
+
edits = [tuple(pair) for pair in kwargs["edits"]]
|
|
58
|
+
return edit_file(kwargs["path"], edits, dry_run=kwargs["dry_run"])
|
|
59
|
+
if verb == "create-directory":
|
|
60
|
+
return create_directory(kwargs["path"])
|
|
61
|
+
if verb == "move-file":
|
|
62
|
+
return move_file(kwargs["source"], kwargs["destination"])
|
|
63
|
+
raise ValueError(f"unknown verb: {verb!r}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Run-as worker: read ``{"verb": ..., "kwargs": ...}`` JSON from stdin, run the
|
|
2
|
+
verb via the pure core, print the JSON result. ``cli.py`` invokes this as the
|
|
3
|
+
target OS user under ``sudo``/``su`` when ``--run-as`` is set, so an in-process
|
|
4
|
+
filesystem verb runs with that user's permissions.
|
|
5
|
+
|
|
6
|
+
Imports only ``runspec_linux.fs._dispatch`` (→ ``runspec_fs_core``), never
|
|
7
|
+
``runspec`` — the escalated user only needs the pure core importable, and the
|
|
8
|
+
payload is already fully resolved, so no ``runspec.toml`` lookup happens here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from runspec_fs_core import FsCoreError
|
|
17
|
+
|
|
18
|
+
from runspec_linux.fs._dispatch import run_verb
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None:
|
|
22
|
+
payload = json.loads(sys.stdin.read())
|
|
23
|
+
try:
|
|
24
|
+
result = run_verb(payload["verb"], payload["kwargs"])
|
|
25
|
+
except (FsCoreError, OSError, ValueError) as exc:
|
|
26
|
+
print(json.dumps({"error": str(exc), "kind": type(exc).__name__}, default=str))
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
print(json.dumps(result, default=str))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""cli.py — the ``fs`` entry point (folded into runspec-linux).
|
|
2
|
+
|
|
3
|
+
One binary, a verb per Filesystem MCP tool. ``rs.parse("fs")`` resolves the
|
|
4
|
+
subcommand and its args (from the merged ``[fs]`` tables in the pack's
|
|
5
|
+
``runspec.toml``); we unpack each ``.value`` at the boundary into a plain kwargs
|
|
6
|
+
dict and run the matching :mod:`runspec_fs_core` helper.
|
|
7
|
+
|
|
8
|
+
**Per-invocation ``--run-as``** (a global arg, like ``runspec-linux``'s venv
|
|
9
|
+
runnables): when set to a different OS user, the resolved ``(verb, kwargs)`` is
|
|
10
|
+
shipped over stdin to :mod:`runspec_linux.fs._worker` re-invoked as that user via
|
|
11
|
+
``sudo -n -u`` / ``su`` (:func:`runspec_fs_core.build_become`), so the in-process
|
|
12
|
+
verb runs with that user's permissions. Access is bounded by that user's OS
|
|
13
|
+
permissions — there is no directory jail.
|
|
14
|
+
|
|
15
|
+
A verbatim copy of ``runspec_fs.cli`` with the local ``_dispatch`` / ``_worker``
|
|
16
|
+
import paths; the logic is the shared ``runspec_fs_core``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import runspec as rs
|
|
27
|
+
from runspec_fs_core import BecomeError, FsCoreError, build_become, current_user, escalation_supported
|
|
28
|
+
|
|
29
|
+
from runspec_linux.fs._dispatch import run_verb
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
33
|
+
print(json.dumps(payload, default=str))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pair_edits(old_text: list[str], new_text: list[str]) -> list[list[str]]:
|
|
37
|
+
"""Zip the two parallel ``--old-text`` / ``--new-text`` lists into
|
|
38
|
+
``[old, new]`` pairs, erroring if their counts differ."""
|
|
39
|
+
if len(old_text) != len(new_text):
|
|
40
|
+
raise ValueError(f"--old-text / --new-text count mismatch: {len(old_text)} vs {len(new_text)} (pair them by position)")
|
|
41
|
+
return [[old, new] for old, new in zip(old_text, new_text, strict=False)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _verb_kwargs(spec: rs.RunSpec) -> dict[str, Any]:
|
|
45
|
+
"""Unpack the parsed spec into a plain, JSON-round-trip-safe kwargs dict for
|
|
46
|
+
the active verb (paths as strings), so the same dict serves the in-process
|
|
47
|
+
path and the run-as worker."""
|
|
48
|
+
command = spec.runspec_command
|
|
49
|
+
|
|
50
|
+
if command == "read-text-file":
|
|
51
|
+
return {"path": str(spec.path.value), "head": spec.head.value, "tail": spec.tail.value}
|
|
52
|
+
if command == "read-media-file":
|
|
53
|
+
return {"path": str(spec.path.value)}
|
|
54
|
+
if command == "read-multiple-files":
|
|
55
|
+
return {"paths": [str(p) for p in spec.paths.value]}
|
|
56
|
+
if command == "list-directory":
|
|
57
|
+
return {"path": str(spec.path.value)}
|
|
58
|
+
if command == "list-directory-with-sizes":
|
|
59
|
+
return {"path": str(spec.path.value), "sort_by": spec.sort_by.value}
|
|
60
|
+
if command == "directory-tree":
|
|
61
|
+
return {"path": str(spec.path.value), "exclude": spec.exclude.value or None}
|
|
62
|
+
if command == "search-files":
|
|
63
|
+
return {"path": str(spec.path.value), "pattern": spec.pattern.value, "exclude": spec.exclude.value or None}
|
|
64
|
+
if command == "get-file-info":
|
|
65
|
+
return {"path": str(spec.path.value)}
|
|
66
|
+
if command == "list-allowed-directories":
|
|
67
|
+
return {}
|
|
68
|
+
if command == "write-file":
|
|
69
|
+
return {"path": str(spec.path.value), "content": spec.content.value}
|
|
70
|
+
if command == "edit-file":
|
|
71
|
+
return {
|
|
72
|
+
"path": str(spec.path.value),
|
|
73
|
+
"edits": _pair_edits(spec.old_text.value, spec.new_text.value),
|
|
74
|
+
"dry_run": spec.dry_run.value,
|
|
75
|
+
}
|
|
76
|
+
if command == "create-directory":
|
|
77
|
+
return {"path": str(spec.path.value)}
|
|
78
|
+
if command == "move-file":
|
|
79
|
+
return {"source": str(spec.source.value), "destination": str(spec.destination.value)}
|
|
80
|
+
|
|
81
|
+
# require-command = true makes a bare invocation unreachable.
|
|
82
|
+
raise SystemExit("no command — see `fs --help`") # pragma: no cover
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_as_worker(run_as: str, method: str, verb: str, kwargs: dict[str, Any]) -> str:
|
|
86
|
+
"""Re-invoke the verb as ``run_as`` via the worker under sudo/su and return
|
|
87
|
+
its stdout. Raises :class:`BecomeError` on an escalation failure (a
|
|
88
|
+
passwordless-sudo hint on the classic cases)."""
|
|
89
|
+
if not escalation_supported():
|
|
90
|
+
raise BecomeError("--run-as requires a POSIX host (sudo/su)")
|
|
91
|
+
argv = build_become([sys.executable, "-m", "runspec_linux.fs._worker"], run_as, method)
|
|
92
|
+
payload = json.dumps({"verb": verb, "kwargs": kwargs})
|
|
93
|
+
try:
|
|
94
|
+
proc = subprocess.run(argv, input=payload, capture_output=True, text=True)
|
|
95
|
+
except FileNotFoundError as exc: # sudo / su not on PATH
|
|
96
|
+
raise BecomeError(f"{method} not found: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
if proc.returncode != 0:
|
|
99
|
+
err = (proc.stderr or "").strip()
|
|
100
|
+
low = err.lower()
|
|
101
|
+
if "password is required" in low or "a terminal is required" in low:
|
|
102
|
+
raise BecomeError(f"passwordless {method} is not configured for run-as {run_as!r} ({err})")
|
|
103
|
+
# The worker prints a JSON error on stdout for tool-level failures — pass
|
|
104
|
+
# it through with the worker's exit code rather than masking it.
|
|
105
|
+
out = (proc.stdout or "").strip()
|
|
106
|
+
if out:
|
|
107
|
+
print(out)
|
|
108
|
+
sys.exit(proc.returncode)
|
|
109
|
+
raise BecomeError(err or f"run-as {run_as!r} failed (exit {proc.returncode})")
|
|
110
|
+
return proc.stdout
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main() -> None:
|
|
114
|
+
spec = rs.parse("fs")
|
|
115
|
+
run_as = spec.run_as.value
|
|
116
|
+
method = spec.become_method.value
|
|
117
|
+
try:
|
|
118
|
+
kwargs = _verb_kwargs(spec)
|
|
119
|
+
if run_as and run_as != current_user():
|
|
120
|
+
out = _run_as_worker(run_as, method, spec.runspec_command, kwargs)
|
|
121
|
+
sys.stdout.write(out if out.endswith("\n") else out + "\n")
|
|
122
|
+
return
|
|
123
|
+
result = run_verb(spec.runspec_command, kwargs)
|
|
124
|
+
except (FsCoreError, OSError, ValueError) as exc:
|
|
125
|
+
_emit({"error": str(exc), "kind": type(exc).__name__})
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
_emit(result)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Wrappers for the application discovery & investigation runnables."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import runspec as rs
|
|
7
|
+
from runspec_linux_core import failed_units, open_ports, owning_package, service_detail
|
|
8
|
+
|
|
9
|
+
from runspec_linux._escalation import resolve_escalation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main_service_detail() -> None:
|
|
13
|
+
spec = rs.parse("service-detail")
|
|
14
|
+
unit = spec.unit.value
|
|
15
|
+
run_as, method = resolve_escalation(spec)
|
|
16
|
+
try:
|
|
17
|
+
print(json.dumps(service_detail(unit, run_as=run_as, become_method=method)))
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print(json.dumps({"error": str(e), "unit": unit}))
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main_open_ports() -> None:
|
|
24
|
+
spec = rs.parse("open-ports")
|
|
25
|
+
proto = spec.proto.value
|
|
26
|
+
run_as, method = resolve_escalation(spec)
|
|
27
|
+
try:
|
|
28
|
+
print(json.dumps(open_ports(proto, run_as=run_as, become_method=method)))
|
|
29
|
+
except Exception as e:
|
|
30
|
+
print(json.dumps({"error": str(e)}))
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main_owning_package() -> None:
|
|
35
|
+
spec = rs.parse("owning-package")
|
|
36
|
+
path = str(spec.path.value)
|
|
37
|
+
try:
|
|
38
|
+
print(json.dumps(owning_package(path)))
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(json.dumps({"error": str(e), "path": path}))
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main_failed_units() -> None:
|
|
45
|
+
rs.parse("failed-units")
|
|
46
|
+
try:
|
|
47
|
+
print(json.dumps(failed_units()))
|
|
48
|
+
except Exception as e:
|
|
49
|
+
print(json.dumps({"error": str(e)}))
|
|
50
|
+
sys.exit(1)
|
|
@@ -5,15 +5,21 @@ import sys
|
|
|
5
5
|
|
|
6
6
|
import runspec as rs
|
|
7
7
|
from runspec_linux_core import journalctl, search_log, tail_log
|
|
8
|
-
from runspec_linux_core.errors import ToolNotFoundError
|
|
8
|
+
from runspec_linux_core.errors import CommandError, ToolNotFoundError
|
|
9
|
+
|
|
10
|
+
from runspec_linux._escalation import resolve_escalation
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def main_tail_log() -> None:
|
|
12
14
|
spec = rs.parse("tail-log")
|
|
13
15
|
file_path = str(spec.file.value)
|
|
14
16
|
lines = spec.lines.value
|
|
17
|
+
run_as, method = resolve_escalation(spec)
|
|
15
18
|
try:
|
|
16
|
-
print(json.dumps(tail_log(file_path, lines)))
|
|
19
|
+
print(json.dumps(tail_log(file_path, lines, run_as=run_as, become_method=method)))
|
|
20
|
+
except CommandError as e:
|
|
21
|
+
print(json.dumps({"error": str(e), "file": file_path}))
|
|
22
|
+
sys.exit(1)
|
|
17
23
|
except OSError as e:
|
|
18
24
|
print(json.dumps({"error": str(e), "file": file_path}))
|
|
19
25
|
sys.exit(1)
|
|
@@ -24,8 +30,12 @@ def main_search_log() -> None:
|
|
|
24
30
|
file_path = str(spec.file.value)
|
|
25
31
|
pattern = spec.pattern.value
|
|
26
32
|
limit = spec.limit.value
|
|
33
|
+
run_as, method = resolve_escalation(spec)
|
|
27
34
|
try:
|
|
28
|
-
print(json.dumps(search_log(file_path, pattern, limit)))
|
|
35
|
+
print(json.dumps(search_log(file_path, pattern, limit, run_as=run_as, become_method=method)))
|
|
36
|
+
except CommandError as e:
|
|
37
|
+
print(json.dumps({"error": str(e), "file": file_path}))
|
|
38
|
+
sys.exit(1)
|
|
29
39
|
except OSError as e:
|
|
30
40
|
print(json.dumps({"error": str(e), "file": file_path}))
|
|
31
41
|
sys.exit(1)
|
|
@@ -39,8 +49,9 @@ def main_journalctl() -> None:
|
|
|
39
49
|
unit = spec.unit.value
|
|
40
50
|
lines = spec.lines.value
|
|
41
51
|
since = spec.since.value
|
|
52
|
+
run_as, method = resolve_escalation(spec)
|
|
42
53
|
try:
|
|
43
|
-
print(json.dumps(journalctl(unit, lines, since)))
|
|
54
|
+
print(json.dumps(journalctl(unit, lines, since, run_as=run_as, become_method=method)))
|
|
44
55
|
except ToolNotFoundError as e:
|
|
45
56
|
print(json.dumps({"error": str(e)}))
|
|
46
57
|
sys.exit(1)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import runspec as rs
|
|
5
|
+
from runspec_linux_core import (
|
|
6
|
+
check_port,
|
|
7
|
+
dns_config,
|
|
8
|
+
ping_host,
|
|
9
|
+
route_table,
|
|
10
|
+
show_connections,
|
|
11
|
+
socket_stats,
|
|
12
|
+
trace_route,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main_ping_host() -> None:
|
|
17
|
+
spec = rs.parse("ping-host")
|
|
18
|
+
host = spec.host.value
|
|
19
|
+
count = spec.count.value
|
|
20
|
+
try:
|
|
21
|
+
print(json.dumps(ping_host(host, count)))
|
|
22
|
+
except Exception as e:
|
|
23
|
+
print(json.dumps({"error": str(e), "host": host}))
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main_check_port() -> None:
|
|
28
|
+
spec = rs.parse("check-port")
|
|
29
|
+
host = spec.host.value
|
|
30
|
+
port = spec.port.value
|
|
31
|
+
timeout = spec.timeout.value
|
|
32
|
+
print(json.dumps(check_port(host, port, timeout)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main_show_connections() -> None:
|
|
36
|
+
spec = rs.parse("show-connections")
|
|
37
|
+
state_filter = spec.state.value
|
|
38
|
+
try:
|
|
39
|
+
print(json.dumps(show_connections(state_filter)))
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print(json.dumps({"error": str(e)}))
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main_trace_route() -> None:
|
|
46
|
+
spec = rs.parse("trace-route")
|
|
47
|
+
host = spec.host.value
|
|
48
|
+
max_hops = spec.max_hops.value
|
|
49
|
+
try:
|
|
50
|
+
print(json.dumps(trace_route(host, max_hops)))
|
|
51
|
+
except Exception as e:
|
|
52
|
+
print(json.dumps({"error": str(e), "host": host}))
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main_route_table() -> None:
|
|
57
|
+
rs.parse("route-table")
|
|
58
|
+
try:
|
|
59
|
+
print(json.dumps(route_table()))
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(json.dumps({"error": str(e)}))
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main_socket_stats() -> None:
|
|
66
|
+
rs.parse("socket-stats")
|
|
67
|
+
try:
|
|
68
|
+
print(json.dumps(socket_stats()))
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(json.dumps({"error": str(e)}))
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main_dns_config() -> None:
|
|
75
|
+
rs.parse("dns-config")
|
|
76
|
+
try:
|
|
77
|
+
print(json.dumps(dns_config()))
|
|
78
|
+
except Exception as e:
|
|
79
|
+
print(json.dumps({"error": str(e)}))
|
|
80
|
+
sys.exit(1)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Wrappers for the performance tuning & analysis runnables."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
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
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main_get_sysctl() -> None:
|
|
14
|
+
spec = rs.parse("get-sysctl")
|
|
15
|
+
pattern = spec.pattern.value or None
|
|
16
|
+
try:
|
|
17
|
+
print(json.dumps(get_sysctl(pattern)))
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print(json.dumps({"error": str(e)}))
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
|
|
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
|
+
def main_cpu_info() -> None:
|
|
40
|
+
rs.parse("cpu-info")
|
|
41
|
+
try:
|
|
42
|
+
print(json.dumps(cpu_info()))
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(json.dumps({"error": str(e)}))
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main_pressure() -> None:
|
|
49
|
+
rs.parse("pressure")
|
|
50
|
+
try:
|
|
51
|
+
print(json.dumps(pressure()))
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(json.dumps({"error": str(e)}))
|
|
54
|
+
sys.exit(1)
|