runspec-linux 0.6.2__tar.gz → 0.7.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.7.0}/CHANGELOG.md +41 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/PKG-INFO +4 -3
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/pyproject.toml +18 -3
- runspec_linux-0.7.0/runspec_linux/_escalation.py +20 -0
- runspec_linux-0.7.0/runspec_linux/fs/__init__.py +12 -0
- runspec_linux-0.7.0/runspec_linux/fs/_dispatch.py +63 -0
- runspec_linux-0.7.0/runspec_linux/fs/_worker.py +32 -0
- runspec_linux-0.7.0/runspec_linux/fs/cli.py +131 -0
- runspec_linux-0.7.0/runspec_linux/investigate.py +50 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/logs.py +15 -4
- runspec_linux-0.7.0/runspec_linux/network.py +80 -0
- runspec_linux-0.7.0/runspec_linux/perf.py +54 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/runspec.toml +392 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/services.py +4 -1
- runspec_linux-0.7.0/tests/test_escalation.py +34 -0
- runspec_linux-0.7.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.7.0}/.gitignore +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/__init__.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/commands.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/containers.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/files.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/nc_command.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/packages.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/power.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/security.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/sudoers.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/system_info.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/venvs.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/webops.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/tests/__init__.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/tests/test_nc_send.py +0 -0
- {runspec_linux-0.6.2 → runspec_linux-0.7.0}/tests/test_webops.py +0 -0
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# runspec-linux Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0] — 2026-08-01
|
|
4
|
+
|
|
5
|
+
Enhancements for corporate application support, devops, and performance work.
|
|
6
|
+
|
|
7
|
+
### Added — filesystem (folded from runspec-fs)
|
|
8
|
+
|
|
9
|
+
- The **`fs`** runnable is now folded into the Linux pack (backed by the
|
|
10
|
+
dependency-free `runspec-fs-core`): one verb per Filesystem MCP tool
|
|
11
|
+
(`read-text-file` … `move-file`), reads `autonomous` and mutations `confirm`.
|
|
12
|
+
`runspec serve` flattens the verbs into leaf `fs_<verb>` MCP tools, so a
|
|
13
|
+
runspec-console agent gets local **and** remote filesystem access on every
|
|
14
|
+
fleet host through the OS pack it already installs — no separate `runspec-fs`
|
|
15
|
+
needed. Per-invocation `--run-as <user>` / `--become-method` are inherited on
|
|
16
|
+
every verb (POSIX escalation, no directory jail).
|
|
17
|
+
|
|
18
|
+
### Added — new runnables
|
|
19
|
+
|
|
20
|
+
- **Perf tuning & analysis:** `get-sysctl`, `set-sysctl` (a privileged write —
|
|
21
|
+
`confirm`, optional `--persist` to `/etc/sysctl.d`), `cpu-info` (governor /
|
|
22
|
+
frequency / topology), `pressure` (kernel PSI stats).
|
|
23
|
+
- **App discovery & investigation:** `service-detail` (a unit's ExecStart binary,
|
|
24
|
+
working dir, user, env files, unit-file path, state), `open-ports` (listening
|
|
25
|
+
port → pid → process), `owning-package` (which package owns a path, dpkg/rpm),
|
|
26
|
+
`failed-units`.
|
|
27
|
+
- **Network deep-diagnostics:** `trace-route`, `route-table`, `socket-stats`,
|
|
28
|
+
`dns-config`.
|
|
29
|
+
|
|
30
|
+
### Added — `--run-as` / `--sudo` on the privileged runnables
|
|
31
|
+
|
|
32
|
+
- `restart-service`, `tail-log`, `search-log`, `journalctl`, `set-sysctl`,
|
|
33
|
+
`service-detail`, and `open-ports` now take a mutually-exclusive
|
|
34
|
+
**`--run-as <user>`** / **`--sudo`** pair (plus `--become-method sudo|su`) so
|
|
35
|
+
they can run against root-owned / service-account-owned resources.
|
|
36
|
+
Escalation is internal (`sudo -n -u` / `su`), governed by an `exclusive` group
|
|
37
|
+
so the two flags can't be combined.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- Requires `runspec >= 0.44.0` (leaf-tool inherited globals via `runspec serve`)
|
|
42
|
+
and `runspec-linux-core >= 0.4.0`; adds a `runspec-fs-core >= 0.2.0` dependency.
|
|
43
|
+
|
|
3
44
|
## [0.6.2] — 2026-07-08
|
|
4
45
|
|
|
5
46
|
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.7.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.4.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.7.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.4.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
|
|
@@ -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)
|
|
@@ -81,6 +81,27 @@ type = "str"
|
|
|
81
81
|
short = "-s"
|
|
82
82
|
description = "Service name to restart"
|
|
83
83
|
|
|
84
|
+
# Escalation: restarting a system service needs privilege.
|
|
85
|
+
[restart-service.args.run-as]
|
|
86
|
+
type = "str"
|
|
87
|
+
description = "Run as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
|
|
88
|
+
required = false
|
|
89
|
+
|
|
90
|
+
[restart-service.args.sudo]
|
|
91
|
+
type = "flag"
|
|
92
|
+
description = "Run as root (via sudo). Mutually exclusive with --run-as."
|
|
93
|
+
default = false
|
|
94
|
+
|
|
95
|
+
[restart-service.args.become-method]
|
|
96
|
+
type = "choice"
|
|
97
|
+
options = ["sudo", "su"]
|
|
98
|
+
description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
|
|
99
|
+
default = "sudo"
|
|
100
|
+
|
|
101
|
+
[restart-service.groups.escalation]
|
|
102
|
+
exclusive = true
|
|
103
|
+
args = ["run-as", "sudo"]
|
|
104
|
+
|
|
84
105
|
# ── Logs ──────────────────────────────────────────────────────────────────────
|
|
85
106
|
|
|
86
107
|
[tail-log]
|
|
@@ -100,6 +121,27 @@ short = "-n"
|
|
|
100
121
|
description = "Number of lines to return"
|
|
101
122
|
default = 50
|
|
102
123
|
|
|
124
|
+
# Escalation: read a root-owned / service-account-owned log (tail -n under sudo/su).
|
|
125
|
+
[tail-log.args.run-as]
|
|
126
|
+
type = "str"
|
|
127
|
+
description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
|
|
128
|
+
required = false
|
|
129
|
+
|
|
130
|
+
[tail-log.args.sudo]
|
|
131
|
+
type = "flag"
|
|
132
|
+
description = "Read as root (via sudo). Mutually exclusive with --run-as."
|
|
133
|
+
default = false
|
|
134
|
+
|
|
135
|
+
[tail-log.args.become-method]
|
|
136
|
+
type = "choice"
|
|
137
|
+
options = ["sudo", "su"]
|
|
138
|
+
description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
|
|
139
|
+
default = "sudo"
|
|
140
|
+
|
|
141
|
+
[tail-log.groups.escalation]
|
|
142
|
+
exclusive = true
|
|
143
|
+
args = ["run-as", "sudo"]
|
|
144
|
+
|
|
103
145
|
[search-log]
|
|
104
146
|
serve = ["local"]
|
|
105
147
|
description = "Search a log file for lines matching a pattern"
|
|
@@ -122,6 +164,27 @@ short = "-n"
|
|
|
122
164
|
description = "Maximum number of matching lines to return"
|
|
123
165
|
default = 100
|
|
124
166
|
|
|
167
|
+
# Escalation: search a root-owned / service-account-owned log (cat under sudo/su).
|
|
168
|
+
[search-log.args.run-as]
|
|
169
|
+
type = "str"
|
|
170
|
+
description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
|
|
171
|
+
required = false
|
|
172
|
+
|
|
173
|
+
[search-log.args.sudo]
|
|
174
|
+
type = "flag"
|
|
175
|
+
description = "Read as root (via sudo). Mutually exclusive with --run-as."
|
|
176
|
+
default = false
|
|
177
|
+
|
|
178
|
+
[search-log.args.become-method]
|
|
179
|
+
type = "choice"
|
|
180
|
+
options = ["sudo", "su"]
|
|
181
|
+
description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
|
|
182
|
+
default = "sudo"
|
|
183
|
+
|
|
184
|
+
[search-log.groups.escalation]
|
|
185
|
+
exclusive = true
|
|
186
|
+
args = ["run-as", "sudo"]
|
|
187
|
+
|
|
125
188
|
[journalctl]
|
|
126
189
|
serve = ["local"]
|
|
127
190
|
description = "Fetch recent journal entries for a systemd unit (systemd-only)"
|
|
@@ -144,6 +207,27 @@ type = "str"
|
|
|
144
207
|
description = "Return entries since this time string (e.g. '1 hour ago', '2026-05-27 08:00')"
|
|
145
208
|
required = false
|
|
146
209
|
|
|
210
|
+
# Escalation: read a protected unit's journal (outside the systemd-journal group).
|
|
211
|
+
[journalctl.args.run-as]
|
|
212
|
+
type = "str"
|
|
213
|
+
description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
|
|
214
|
+
required = false
|
|
215
|
+
|
|
216
|
+
[journalctl.args.sudo]
|
|
217
|
+
type = "flag"
|
|
218
|
+
description = "Read as root (via sudo). Mutually exclusive with --run-as."
|
|
219
|
+
default = false
|
|
220
|
+
|
|
221
|
+
[journalctl.args.become-method]
|
|
222
|
+
type = "choice"
|
|
223
|
+
options = ["sudo", "su"]
|
|
224
|
+
description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
|
|
225
|
+
default = "sudo"
|
|
226
|
+
|
|
227
|
+
[journalctl.groups.escalation]
|
|
228
|
+
exclusive = true
|
|
229
|
+
args = ["run-as", "sudo"]
|
|
230
|
+
|
|
147
231
|
# ── Network ───────────────────────────────────────────────────────────────────
|
|
148
232
|
|
|
149
233
|
[ping-host]
|
|
@@ -896,3 +980,311 @@ type = "flag"
|
|
|
896
980
|
short = "-k"
|
|
897
981
|
description = "Skip TLS certificate verification when fetching the spec"
|
|
898
982
|
default = false
|
|
983
|
+
|
|
984
|
+
# ── Performance tuning & analysis ─────────────────────────────────────────────
|
|
985
|
+
|
|
986
|
+
[get-sysctl]
|
|
987
|
+
serve = ["local"]
|
|
988
|
+
description = "Read kernel parameters (sysctl), optionally filtered by a substring of the key"
|
|
989
|
+
autonomy = "autonomous"
|
|
990
|
+
output = "json"
|
|
991
|
+
|
|
992
|
+
[get-sysctl.args.pattern]
|
|
993
|
+
type = "str"
|
|
994
|
+
short = "-p"
|
|
995
|
+
description = "Only return keys containing this substring (e.g. 'net.core')"
|
|
996
|
+
required = false
|
|
997
|
+
|
|
998
|
+
[set-sysctl]
|
|
999
|
+
serve = ["local"]
|
|
1000
|
+
description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d"
|
|
1001
|
+
autonomy = "confirm"
|
|
1002
|
+
output = "json"
|
|
1003
|
+
|
|
1004
|
+
[set-sysctl.args.key]
|
|
1005
|
+
type = "str"
|
|
1006
|
+
short = "-k"
|
|
1007
|
+
description = "Dotted sysctl key (e.g. net.core.somaxconn)"
|
|
1008
|
+
|
|
1009
|
+
[set-sysctl.args.value]
|
|
1010
|
+
type = "str"
|
|
1011
|
+
description = "New value to set"
|
|
1012
|
+
|
|
1013
|
+
[set-sysctl.args.persist]
|
|
1014
|
+
type = "flag"
|
|
1015
|
+
description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
|
|
1016
|
+
default = false
|
|
1017
|
+
|
|
1018
|
+
# Escalation: writing a kernel parameter is privileged.
|
|
1019
|
+
[set-sysctl.args.run-as]
|
|
1020
|
+
type = "str"
|
|
1021
|
+
description = "Apply as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
|
|
1022
|
+
required = false
|
|
1023
|
+
|
|
1024
|
+
[set-sysctl.args.sudo]
|
|
1025
|
+
type = "flag"
|
|
1026
|
+
description = "Apply as root (via sudo). Mutually exclusive with --run-as."
|
|
1027
|
+
default = false
|
|
1028
|
+
|
|
1029
|
+
[set-sysctl.args.become-method]
|
|
1030
|
+
type = "choice"
|
|
1031
|
+
options = ["sudo", "su"]
|
|
1032
|
+
description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
|
|
1033
|
+
default = "sudo"
|
|
1034
|
+
|
|
1035
|
+
[set-sysctl.groups.escalation]
|
|
1036
|
+
exclusive = true
|
|
1037
|
+
args = ["run-as", "sudo"]
|
|
1038
|
+
|
|
1039
|
+
[cpu-info]
|
|
1040
|
+
serve = ["local"]
|
|
1041
|
+
description = "Report CPU model, core count, and per-CPU scaling governor and frequency"
|
|
1042
|
+
autonomy = "autonomous"
|
|
1043
|
+
output = "json"
|
|
1044
|
+
|
|
1045
|
+
[pressure]
|
|
1046
|
+
serve = ["local"]
|
|
1047
|
+
description = "Report kernel PSI pressure-stall stats for CPU, IO, and memory"
|
|
1048
|
+
autonomy = "autonomous"
|
|
1049
|
+
output = "json"
|
|
1050
|
+
|
|
1051
|
+
# ── Application discovery & investigation ─────────────────────────────────────
|
|
1052
|
+
|
|
1053
|
+
[service-detail]
|
|
1054
|
+
serve = ["local"]
|
|
1055
|
+
description = "Show where a systemd unit lives: ExecStart binary, working dir, user, env files, unit-file path, and state"
|
|
1056
|
+
autonomy = "autonomous"
|
|
1057
|
+
output = "json"
|
|
1058
|
+
|
|
1059
|
+
[service-detail.args.unit]
|
|
1060
|
+
type = "str"
|
|
1061
|
+
short = "-u"
|
|
1062
|
+
description = "Systemd unit name (e.g. nginx or nginx.service)"
|
|
1063
|
+
|
|
1064
|
+
# Escalation: some properties on protected units need privilege.
|
|
1065
|
+
[service-detail.args.run-as]
|
|
1066
|
+
type = "str"
|
|
1067
|
+
description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
|
|
1068
|
+
required = false
|
|
1069
|
+
|
|
1070
|
+
[service-detail.args.sudo]
|
|
1071
|
+
type = "flag"
|
|
1072
|
+
description = "Read as root (via sudo). Mutually exclusive with --run-as."
|
|
1073
|
+
default = false
|
|
1074
|
+
|
|
1075
|
+
[service-detail.args.become-method]
|
|
1076
|
+
type = "choice"
|
|
1077
|
+
options = ["sudo", "su"]
|
|
1078
|
+
description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
|
|
1079
|
+
default = "sudo"
|
|
1080
|
+
|
|
1081
|
+
[service-detail.groups.escalation]
|
|
1082
|
+
exclusive = true
|
|
1083
|
+
args = ["run-as", "sudo"]
|
|
1084
|
+
|
|
1085
|
+
[open-ports]
|
|
1086
|
+
serve = ["local"]
|
|
1087
|
+
description = "List listening sockets mapped to the owning process (port to pid to process name)"
|
|
1088
|
+
autonomy = "autonomous"
|
|
1089
|
+
output = "json"
|
|
1090
|
+
|
|
1091
|
+
[open-ports.args.proto]
|
|
1092
|
+
type = "choice"
|
|
1093
|
+
options = ["tcp", "udp", "both"]
|
|
1094
|
+
description = "Which listening sockets to report"
|
|
1095
|
+
default = "tcp"
|
|
1096
|
+
|
|
1097
|
+
# Escalation: the full pid/process map needs privilege (ss shows other users' processes only as root).
|
|
1098
|
+
[open-ports.args.run-as]
|
|
1099
|
+
type = "str"
|
|
1100
|
+
description = "Probe as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
|
|
1101
|
+
required = false
|
|
1102
|
+
|
|
1103
|
+
[open-ports.args.sudo]
|
|
1104
|
+
type = "flag"
|
|
1105
|
+
description = "Probe as root (via sudo). Mutually exclusive with --run-as."
|
|
1106
|
+
default = false
|
|
1107
|
+
|
|
1108
|
+
[open-ports.args.become-method]
|
|
1109
|
+
type = "choice"
|
|
1110
|
+
options = ["sudo", "su"]
|
|
1111
|
+
description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
|
|
1112
|
+
default = "sudo"
|
|
1113
|
+
|
|
1114
|
+
[open-ports.groups.escalation]
|
|
1115
|
+
exclusive = true
|
|
1116
|
+
args = ["run-as", "sudo"]
|
|
1117
|
+
|
|
1118
|
+
[owning-package]
|
|
1119
|
+
serve = ["local"]
|
|
1120
|
+
description = "Report which installed package owns a file path (dpkg / rpm)"
|
|
1121
|
+
autonomy = "autonomous"
|
|
1122
|
+
output = "json"
|
|
1123
|
+
|
|
1124
|
+
[owning-package.args.path]
|
|
1125
|
+
type = "path"
|
|
1126
|
+
short = "-p"
|
|
1127
|
+
description = "File path to resolve to its owning package"
|
|
1128
|
+
|
|
1129
|
+
[failed-units]
|
|
1130
|
+
serve = ["local"]
|
|
1131
|
+
description = "List systemd service units currently in the failed state"
|
|
1132
|
+
autonomy = "autonomous"
|
|
1133
|
+
output = "json"
|
|
1134
|
+
|
|
1135
|
+
# ── Network deep-diagnostics ──────────────────────────────────────────────────
|
|
1136
|
+
|
|
1137
|
+
[trace-route]
|
|
1138
|
+
serve = ["local"]
|
|
1139
|
+
description = "Trace the network path to a host (traceroute / tracepath)"
|
|
1140
|
+
autonomy = "autonomous"
|
|
1141
|
+
output = "json"
|
|
1142
|
+
|
|
1143
|
+
[trace-route.args.host]
|
|
1144
|
+
type = "str"
|
|
1145
|
+
short = "-H"
|
|
1146
|
+
description = "Destination host or IP"
|
|
1147
|
+
|
|
1148
|
+
[trace-route.args.max-hops]
|
|
1149
|
+
type = "int"
|
|
1150
|
+
short = "-m"
|
|
1151
|
+
description = "Maximum number of hops to probe"
|
|
1152
|
+
default = 30
|
|
1153
|
+
|
|
1154
|
+
[route-table]
|
|
1155
|
+
serve = ["local"]
|
|
1156
|
+
description = "Show the kernel IPv4 routing table (ip route)"
|
|
1157
|
+
autonomy = "autonomous"
|
|
1158
|
+
output = "json"
|
|
1159
|
+
|
|
1160
|
+
[socket-stats]
|
|
1161
|
+
serve = ["local"]
|
|
1162
|
+
description = "Summarise socket usage by protocol (ss -s)"
|
|
1163
|
+
autonomy = "autonomous"
|
|
1164
|
+
output = "json"
|
|
1165
|
+
|
|
1166
|
+
[dns-config]
|
|
1167
|
+
serve = ["local"]
|
|
1168
|
+
description = "Show the host's DNS configuration: nameservers, search domains, and options (/etc/resolv.conf + resolvectl)"
|
|
1169
|
+
autonomy = "autonomous"
|
|
1170
|
+
output = "json"
|
|
1171
|
+
|
|
1172
|
+
# ── Filesystem (folded from runspec-fs; backed by runspec-fs-core) ─────────────
|
|
1173
|
+
# The filesystem is part of the OS, so the Linux pack ships the `fs` runnable
|
|
1174
|
+
# directly. One verb per Filesystem MCP tool; `runspec serve` re-expands each
|
|
1175
|
+
# verb into a leaf MCP tool (fs_read-text-file, fs_write-file, …), giving a
|
|
1176
|
+
# runspec-console agent local AND remote filesystem access on every fleet host.
|
|
1177
|
+
# No directory jail — access is bounded by the OS permissions of the invoking
|
|
1178
|
+
# (or --run-as) user.
|
|
1179
|
+
|
|
1180
|
+
[fs]
|
|
1181
|
+
serve = ["local"]
|
|
1182
|
+
description = "Read and manipulate the filesystem on this host (local, or remote via runspec-console)"
|
|
1183
|
+
output = "json"
|
|
1184
|
+
require-command = true
|
|
1185
|
+
examples = [
|
|
1186
|
+
{cmd = "fs list-directory --path .", description = "List the current directory"},
|
|
1187
|
+
{cmd = "fs read-text-file --path README.md --head 20", description = "Read the first 20 lines of a file"},
|
|
1188
|
+
{cmd = "fs search-files --path . --pattern '*.py'", description = "Find Python files recursively"},
|
|
1189
|
+
{cmd = "fs edit-file --path a.txt --old-text foo --new-text bar", description = "Replace 'foo' with 'bar'"},
|
|
1190
|
+
{cmd = "fs --run-as deploy read-text-file --path /home/deploy/app.log", description = "Run a verb as another OS user (chosen per invocation)"},
|
|
1191
|
+
]
|
|
1192
|
+
|
|
1193
|
+
# ── global args (inherited by every verb) ──────────────────────────────────
|
|
1194
|
+
# Per-invocation run-as: the user/agent picks which OS user each verb runs as,
|
|
1195
|
+
# like runspec-linux's venv runnables. The operation is re-invoked as that user
|
|
1196
|
+
# via sudo/su, so access is bounded by THAT user's OS permissions — no jail.
|
|
1197
|
+
[fs.args]
|
|
1198
|
+
run-as = {type = "str", required = false, description = "Run this operation as another OS user (via sudo -n -u / su, chosen per invocation). Access is then bounded by that user's OS permissions. POSIX only; needs passwordless sudo for the target user."}
|
|
1199
|
+
become-method = {type = "choice", options = ["sudo", "su"], default = "sudo", description = "How to escalate to --run-as."}
|
|
1200
|
+
|
|
1201
|
+
# ── reads / metadata → autonomous ──────────────────────────────────────────
|
|
1202
|
+
|
|
1203
|
+
[fs.commands.read-text-file]
|
|
1204
|
+
description = "Read a file as UTF-8 text; optionally only the first N (--head) or last N (--tail) lines"
|
|
1205
|
+
autonomy = "autonomous"
|
|
1206
|
+
[fs.commands.read-text-file.args]
|
|
1207
|
+
path = {type = "path", required = true, description = "File to read"}
|
|
1208
|
+
head = {type = "int", required = false, description = "Return only the first N lines"}
|
|
1209
|
+
tail = {type = "int", required = false, description = "Return only the last N lines"}
|
|
1210
|
+
|
|
1211
|
+
[fs.commands.read-media-file]
|
|
1212
|
+
description = "Read a binary/media file and return it base64-encoded with its MIME type"
|
|
1213
|
+
autonomy = "autonomous"
|
|
1214
|
+
[fs.commands.read-media-file.args]
|
|
1215
|
+
path = {type = "path", required = true, description = "File to read"}
|
|
1216
|
+
|
|
1217
|
+
[fs.commands.read-multiple-files]
|
|
1218
|
+
description = "Read several files at once; a failed read is reported per-file without aborting the batch"
|
|
1219
|
+
autonomy = "autonomous"
|
|
1220
|
+
[fs.commands.read-multiple-files.args]
|
|
1221
|
+
paths = {type = "path", multiple = true, required = true, description = "Files to read (repeat --paths per file)"}
|
|
1222
|
+
|
|
1223
|
+
[fs.commands.list-directory]
|
|
1224
|
+
description = "List a directory's immediate contents, each tagged file or directory"
|
|
1225
|
+
autonomy = "autonomous"
|
|
1226
|
+
[fs.commands.list-directory.args]
|
|
1227
|
+
path = {type = "path", required = true, description = "Directory to list"}
|
|
1228
|
+
|
|
1229
|
+
[fs.commands.list-directory-with-sizes]
|
|
1230
|
+
description = "List a directory's contents with per-entry sizes and totals"
|
|
1231
|
+
autonomy = "autonomous"
|
|
1232
|
+
[fs.commands.list-directory-with-sizes.args]
|
|
1233
|
+
path = {type = "path", required = true, description = "Directory to list"}
|
|
1234
|
+
sort-by = {type = "choice", options = ["name", "size"], default = "name", description = "Sort order (size is descending)"}
|
|
1235
|
+
|
|
1236
|
+
[fs.commands.directory-tree]
|
|
1237
|
+
description = "Return a recursive JSON tree of a directory"
|
|
1238
|
+
autonomy = "autonomous"
|
|
1239
|
+
[fs.commands.directory-tree.args]
|
|
1240
|
+
path = {type = "path", required = true, description = "Directory to walk"}
|
|
1241
|
+
exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
|
|
1242
|
+
|
|
1243
|
+
[fs.commands.search-files]
|
|
1244
|
+
description = "Recursively find files/directories whose name matches a pattern (glob, or case-insensitive substring)"
|
|
1245
|
+
autonomy = "autonomous"
|
|
1246
|
+
[fs.commands.search-files.args]
|
|
1247
|
+
path = {type = "path", required = true, description = "Directory to search under"}
|
|
1248
|
+
pattern = {type = "str", required = true, description = "Glob (e.g. '*.py') or a case-insensitive substring"}
|
|
1249
|
+
exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
|
|
1250
|
+
|
|
1251
|
+
[fs.commands.get-file-info]
|
|
1252
|
+
description = "Return detailed metadata for a file or directory (size, timestamps, permissions)"
|
|
1253
|
+
autonomy = "autonomous"
|
|
1254
|
+
[fs.commands.get-file-info.args]
|
|
1255
|
+
path = {type = "path", required = true, description = "File or directory to stat"}
|
|
1256
|
+
|
|
1257
|
+
[fs.commands.list-allowed-directories]
|
|
1258
|
+
description = "Report the effective user and starting directories (informational — no directory jail is enforced)"
|
|
1259
|
+
autonomy = "autonomous"
|
|
1260
|
+
|
|
1261
|
+
# ── mutations → confirm ────────────────────────────────────────────────────
|
|
1262
|
+
|
|
1263
|
+
[fs.commands.write-file]
|
|
1264
|
+
description = "Create a new file or overwrite an existing one with UTF-8 text"
|
|
1265
|
+
autonomy = "confirm"
|
|
1266
|
+
[fs.commands.write-file.args]
|
|
1267
|
+
path = {type = "path", required = true, description = "File to write"}
|
|
1268
|
+
content = {type = "str", required = true, description = "File contents"}
|
|
1269
|
+
|
|
1270
|
+
[fs.commands.edit-file]
|
|
1271
|
+
description = "Apply ordered old→new substring replacements to a file; returns a unified diff. Pair each --old-text with a --new-text by position."
|
|
1272
|
+
autonomy = "confirm"
|
|
1273
|
+
[fs.commands.edit-file.args]
|
|
1274
|
+
path = {type = "path", required = true, description = "File to edit"}
|
|
1275
|
+
old-text = {type = "str", multiple = true, required = true, description = "Text to find (repeat --old-text; paired by position with --new-text)"}
|
|
1276
|
+
new-text = {type = "str", multiple = true, required = true, description = "Replacement text (repeat --new-text; paired by position with --old-text)"}
|
|
1277
|
+
dry-run = {type = "flag", default = false, description = "Compute and diff the edits without writing"}
|
|
1278
|
+
|
|
1279
|
+
[fs.commands.create-directory]
|
|
1280
|
+
description = "Create a directory (and any missing parents); idempotent"
|
|
1281
|
+
autonomy = "confirm"
|
|
1282
|
+
[fs.commands.create-directory.args]
|
|
1283
|
+
path = {type = "path", required = true, description = "Directory to create"}
|
|
1284
|
+
|
|
1285
|
+
[fs.commands.move-file]
|
|
1286
|
+
description = "Move or rename a file/directory; fails if the destination already exists"
|
|
1287
|
+
autonomy = "confirm"
|
|
1288
|
+
[fs.commands.move-file.args]
|
|
1289
|
+
source = {type = "path", required = true, description = "Path to move"}
|
|
1290
|
+
destination = {type = "path", required = true, description = "New path (must not already exist)"}
|
|
@@ -5,6 +5,8 @@ import runspec as rs
|
|
|
5
5
|
from runspec_linux_core import check_service, list_services, restart_service
|
|
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_check_service() -> None:
|
|
10
12
|
spec = rs.parse("check-service")
|
|
@@ -29,8 +31,9 @@ def main_list_services() -> None:
|
|
|
29
31
|
def main_restart_service() -> None:
|
|
30
32
|
spec = rs.parse("restart-service")
|
|
31
33
|
service = spec.service.value
|
|
34
|
+
run_as, method = resolve_escalation(spec)
|
|
32
35
|
try:
|
|
33
|
-
print(json.dumps(restart_service(service)))
|
|
36
|
+
print(json.dumps(restart_service(service, run_as=run_as, become_method=method)))
|
|
34
37
|
except CommandError as e:
|
|
35
38
|
print(json.dumps({"service": service, "restarted": False, "error": str(e)}))
|
|
36
39
|
sys.exit(1)
|
|
@@ -0,0 +1,34 @@
|
|
|
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")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The `fs` runnable folded into runspec-linux (backed by runspec-fs-core).
|
|
2
|
+
|
|
3
|
+
Smoke coverage that the folded verbs run in-process and that the run-as worker
|
|
4
|
+
path builds the right escalation command. Full verb behaviour is covered by
|
|
5
|
+
runspec-fs-core's own suite.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from runspec_linux.fs import _dispatch, cli
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_dispatch_read_and_list(tmp_path) -> None:
|
|
19
|
+
f = tmp_path / "a.txt"
|
|
20
|
+
f.write_text("hello\nworld\n")
|
|
21
|
+
assert _dispatch.run_verb("read-text-file", {"path": str(f)})["content"] == "hello\nworld\n"
|
|
22
|
+
listing = _dispatch.run_verb("list-directory", {"path": str(tmp_path)})
|
|
23
|
+
assert listing["count"] == 1
|
|
24
|
+
assert listing["entries"][0]["name"] == "a.txt"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_dispatch_unknown_verb() -> None:
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
_dispatch.run_verb("frobnicate", {})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_pair_edits_mismatch() -> None:
|
|
33
|
+
with pytest.raises(ValueError, match="count mismatch"):
|
|
34
|
+
cli._pair_edits(["a", "b"], ["x"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_run_as_worker_builds_sudo_command(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
38
|
+
captured: dict[str, object] = {}
|
|
39
|
+
|
|
40
|
+
def fake_run(argv, input=None, capture_output=None, text=None): # noqa: A002
|
|
41
|
+
captured["argv"] = argv
|
|
42
|
+
captured["input"] = input
|
|
43
|
+
return subprocess.CompletedProcess(argv, 0, stdout='{"ok": true}', stderr="")
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
|
46
|
+
out = cli._run_as_worker("svc", "sudo", "list-directory", {"path": "/tmp"})
|
|
47
|
+
argv = captured["argv"]
|
|
48
|
+
assert argv[:4] == ["sudo", "-n", "-u", "svc"]
|
|
49
|
+
assert argv[-2:] == ["-m", "runspec_linux.fs._worker"]
|
|
50
|
+
assert json.loads(captured["input"]) == {"verb": "list-directory", "kwargs": {"path": "/tmp"}}
|
|
51
|
+
assert json.loads(out) == {"ok": True}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import sys
|
|
3
|
-
|
|
4
|
-
import runspec as rs
|
|
5
|
-
from runspec_linux_core import check_port, ping_host, show_connections
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def main_ping_host() -> None:
|
|
9
|
-
spec = rs.parse("ping-host")
|
|
10
|
-
host = spec.host.value
|
|
11
|
-
count = spec.count.value
|
|
12
|
-
try:
|
|
13
|
-
print(json.dumps(ping_host(host, count)))
|
|
14
|
-
except Exception as e:
|
|
15
|
-
print(json.dumps({"error": str(e), "host": host}))
|
|
16
|
-
sys.exit(1)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def main_check_port() -> None:
|
|
20
|
-
spec = rs.parse("check-port")
|
|
21
|
-
host = spec.host.value
|
|
22
|
-
port = spec.port.value
|
|
23
|
-
timeout = spec.timeout.value
|
|
24
|
-
print(json.dumps(check_port(host, port, timeout)))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def main_show_connections() -> None:
|
|
28
|
-
spec = rs.parse("show-connections")
|
|
29
|
-
state_filter = spec.state.value
|
|
30
|
-
try:
|
|
31
|
-
print(json.dumps(show_connections(state_filter)))
|
|
32
|
-
except Exception as e:
|
|
33
|
-
print(json.dumps({"error": str(e)}))
|
|
34
|
-
sys.exit(1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|