runspec-linux 0.1.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.
@@ -0,0 +1,55 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
@@ -0,0 +1,25 @@
1
+ # runspec-linux Changelog
2
+
3
+ ## [0.1.0] — 2026-05-27
4
+
5
+ Initial release.
6
+
7
+ 21 runnables covering Linux system administration:
8
+
9
+ **System monitoring** — `system-info`, `disk-usage`, `check-memory`, `list-processes`
10
+
11
+ **Services** — `check-service`, `list-services`, `restart-service`
12
+
13
+ **Logs** — `tail-log`, `search-log`, `journalctl`
14
+
15
+ **Network** — `ping-host`, `check-port`, `show-connections`
16
+
17
+ **Files** — `find-large-files`, `backup-files`
18
+
19
+ **Security** — `last-logins`, `who`
20
+
21
+ **Containers** — `list-containers`, `container-logs`, `restart-container`
22
+
23
+ **TCP interfaces** — `nc-command` (also exports `nc_send()` as a public Python API for wrapper runnables)
24
+
25
+ All read-only runnables are `autonomy = "autonomous"`. State-changing runnables (`restart-service`, `backup-files`, `restart-container`) are `autonomy = "confirm"`. Missing tools (`docker`, `systemctl`, `journalctl`) return a structured JSON error rather than a stack trace.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-linux
3
+ Version: 0.1.0
4
+ Summary: Linux system admin runnables for runspec
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: runspec>=0.17.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: mypy; extra == 'dev'
9
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
+ Requires-Dist: ruff; extra == 'dev'
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-linux"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.11"
9
+ description = "Linux system admin runnables for runspec"
10
+ dependencies = [
11
+ "runspec>=0.17.0",
12
+ ]
13
+
14
+ [project.scripts]
15
+ system-info = "runspec_linux.system_info:main_system_info"
16
+ disk-usage = "runspec_linux.system_info:main_disk_usage"
17
+ check-memory = "runspec_linux.system_info:main_check_memory"
18
+ list-processes = "runspec_linux.system_info:main_list_processes"
19
+ check-service = "runspec_linux.services:main_check_service"
20
+ list-services = "runspec_linux.services:main_list_services"
21
+ restart-service = "runspec_linux.services:main_restart_service"
22
+ tail-log = "runspec_linux.logs:main_tail_log"
23
+ search-log = "runspec_linux.logs:main_search_log"
24
+ journalctl = "runspec_linux.logs:main_journalctl"
25
+ ping-host = "runspec_linux.network:main_ping_host"
26
+ check-port = "runspec_linux.network:main_check_port"
27
+ show-connections = "runspec_linux.network:main_show_connections"
28
+ find-large-files = "runspec_linux.files:main_find_large_files"
29
+ backup-files = "runspec_linux.files:main_backup_files"
30
+ last-logins = "runspec_linux.security:main_last_logins"
31
+ who = "runspec_linux.security:main_who"
32
+ list-containers = "runspec_linux.containers:main_list_containers"
33
+ container-logs = "runspec_linux.containers:main_container_logs"
34
+ restart-container = "runspec_linux.containers:main_restart_container"
35
+ nc-command = "runspec_linux.nc_command:main"
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "ruff",
40
+ "mypy",
41
+ "pytest>=8.0",
42
+ ]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+
47
+ [tool.mypy]
48
+ python_version = "3.11"
49
+
50
+ [tool.ruff]
51
+ line-length = 200
52
+ target-version = "py311"
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,3 @@
1
+ from runspec_linux.nc_command import nc_send
2
+
3
+ __all__ = ["nc_send"]
@@ -0,0 +1,117 @@
1
+ import json
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+
6
+ import runspec as rs
7
+
8
+
9
+ def _docker_available() -> bool:
10
+ return shutil.which("docker") is not None
11
+
12
+
13
+ def main_list_containers() -> None:
14
+ spec = rs.parse("list-containers")
15
+ include_all = bool(spec.all)
16
+
17
+ if not _docker_available():
18
+ print(json.dumps({"error": "docker not installed or not in PATH"}))
19
+ sys.exit(1)
20
+
21
+ try:
22
+ cmd = [
23
+ "docker",
24
+ "ps",
25
+ "--format",
26
+ "{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Names}}",
27
+ ]
28
+ if include_all:
29
+ cmd.append("--all")
30
+
31
+ result = subprocess.run(cmd, capture_output=True, text=True)
32
+ if result.returncode != 0:
33
+ print(json.dumps({"error": result.stderr.strip()}))
34
+ sys.exit(1)
35
+
36
+ rows = []
37
+ for line in result.stdout.strip().splitlines():
38
+ parts = line.split("\t")
39
+ if len(parts) < 6:
40
+ continue
41
+ rows.append(
42
+ {
43
+ "id": parts[0],
44
+ "image": parts[1],
45
+ "command": parts[2].strip('"'),
46
+ "created": parts[3],
47
+ "status": parts[4],
48
+ "name": parts[5],
49
+ }
50
+ )
51
+ print(json.dumps(rows))
52
+ except Exception as e:
53
+ print(json.dumps({"error": str(e)}))
54
+ sys.exit(1)
55
+
56
+
57
+ def main_container_logs() -> None:
58
+ spec = rs.parse("container-logs")
59
+ container = str(spec.container)
60
+ lines = int(spec.lines)
61
+
62
+ if not _docker_available():
63
+ print(json.dumps({"error": "docker not installed or not in PATH"}))
64
+ sys.exit(1)
65
+
66
+ try:
67
+ result = subprocess.run(
68
+ ["docker", "logs", "--tail", str(lines), container],
69
+ capture_output=True,
70
+ text=True,
71
+ )
72
+ # docker logs writes to stderr by default
73
+ output = result.stderr if result.stderr else result.stdout
74
+ output_lines = output.strip().splitlines()
75
+ print(
76
+ json.dumps(
77
+ {
78
+ "container": container,
79
+ "lines": output_lines,
80
+ "count": len(output_lines),
81
+ }
82
+ )
83
+ )
84
+ except Exception as e:
85
+ print(json.dumps({"error": str(e)}))
86
+ sys.exit(1)
87
+
88
+
89
+ def main_restart_container() -> None:
90
+ spec = rs.parse("restart-container")
91
+ container = str(spec.container)
92
+
93
+ if not _docker_available():
94
+ print(json.dumps({"error": "docker not installed or not in PATH"}))
95
+ sys.exit(1)
96
+
97
+ try:
98
+ result = subprocess.run(
99
+ ["docker", "restart", container],
100
+ capture_output=True,
101
+ text=True,
102
+ )
103
+ if result.returncode != 0:
104
+ print(
105
+ json.dumps(
106
+ {
107
+ "container": container,
108
+ "restarted": False,
109
+ "error": result.stderr.strip(),
110
+ }
111
+ )
112
+ )
113
+ sys.exit(1)
114
+ print(json.dumps({"container": container, "restarted": True}))
115
+ except Exception as e:
116
+ print(json.dumps({"error": str(e)}))
117
+ sys.exit(1)
@@ -0,0 +1,72 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import tarfile
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ import runspec as rs
9
+
10
+
11
+ def main_find_large_files() -> None:
12
+ spec = rs.parse("find-large-files")
13
+ search_path = str(spec.path)
14
+ min_mb = float(spec.min_mb)
15
+ limit = int(spec.limit)
16
+
17
+ min_bytes = int(min_mb * 1_048_576)
18
+
19
+ try:
20
+ results: list[dict] = []
21
+ for dirpath, _dirs, filenames in os.walk(search_path):
22
+ for name in filenames:
23
+ fpath = os.path.join(dirpath, name)
24
+ try:
25
+ stat = os.stat(fpath, follow_symlinks=False)
26
+ if stat.st_size >= min_bytes:
27
+ results.append(
28
+ {
29
+ "path": fpath,
30
+ "size_mb": round(stat.st_size / 1_048_576, 2),
31
+ "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
32
+ }
33
+ )
34
+ except OSError:
35
+ continue
36
+
37
+ results.sort(key=lambda r: r["size_mb"], reverse=True)
38
+ print(json.dumps(results[:limit]))
39
+ except Exception as e:
40
+ print(json.dumps({"error": str(e)}))
41
+ sys.exit(1)
42
+
43
+
44
+ def main_backup_files() -> None:
45
+ spec = rs.parse("backup-files")
46
+ source = str(spec.source)
47
+ destination = str(spec.destination)
48
+
49
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
50
+ source_name = Path(source).name or "backup"
51
+ archive_name = f"{source_name}_{timestamp}.tar.gz"
52
+ archive_path = os.path.join(destination, archive_name)
53
+
54
+ try:
55
+ os.makedirs(destination, exist_ok=True)
56
+ with tarfile.open(archive_path, "w:gz") as tar:
57
+ tar.add(source, arcname=source_name)
58
+
59
+ size_bytes = os.path.getsize(archive_path)
60
+ print(
61
+ json.dumps(
62
+ {
63
+ "source": source,
64
+ "destination": archive_path,
65
+ "size_bytes": size_bytes,
66
+ "size_mb": round(size_bytes / 1_048_576, 2),
67
+ }
68
+ )
69
+ )
70
+ except Exception as e:
71
+ print(json.dumps({"error": str(e)}))
72
+ sys.exit(1)
@@ -0,0 +1,81 @@
1
+ import json
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+
7
+ import runspec as rs
8
+
9
+
10
+ def main_tail_log() -> None:
11
+ spec = rs.parse("tail-log")
12
+ file_path = str(spec.file)
13
+ lines = int(spec.lines)
14
+
15
+ try:
16
+ with open(file_path, errors="replace") as f:
17
+ all_lines = f.readlines()
18
+ tail = [line.rstrip("\n") for line in all_lines[-lines:]]
19
+ print(json.dumps({"file": file_path, "lines": tail, "count": len(tail)}))
20
+ except OSError as e:
21
+ print(json.dumps({"error": str(e), "file": file_path}))
22
+ sys.exit(1)
23
+
24
+
25
+ def main_search_log() -> None:
26
+ spec = rs.parse("search-log")
27
+ file_path = str(spec.file)
28
+ pattern = str(spec.pattern)
29
+ limit = int(spec.limit)
30
+
31
+ try:
32
+ regex = re.compile(pattern, re.IGNORECASE)
33
+ matches: list[str] = []
34
+ with open(file_path, errors="replace") as f:
35
+ for line in f:
36
+ if regex.search(line):
37
+ matches.append(line.rstrip("\n"))
38
+ trimmed = matches[-limit:] if len(matches) > limit else matches
39
+ print(
40
+ json.dumps(
41
+ {
42
+ "file": file_path,
43
+ "pattern": pattern,
44
+ "matches": trimmed,
45
+ "count": len(trimmed),
46
+ "total_matches": len(matches),
47
+ }
48
+ )
49
+ )
50
+ except OSError as e:
51
+ print(json.dumps({"error": str(e), "file": file_path}))
52
+ sys.exit(1)
53
+ except re.error as e:
54
+ print(json.dumps({"error": f"Invalid regex: {e}", "pattern": pattern}))
55
+ sys.exit(1)
56
+
57
+
58
+ def main_journalctl() -> None:
59
+ spec = rs.parse("journalctl")
60
+ unit = str(spec.unit)
61
+ lines = int(spec.lines)
62
+ since = str(spec.since) if spec.since is not None else None
63
+
64
+ if not shutil.which("journalctl"):
65
+ print(json.dumps({"error": "journalctl not available — not a systemd system"}))
66
+ sys.exit(1)
67
+
68
+ try:
69
+ cmd = ["journalctl", f"-u{unit}", f"-n{lines}", "--no-pager", "--output=short"]
70
+ if since:
71
+ cmd += [f"--since={since}"]
72
+
73
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
74
+ output_lines = result.stdout.strip().splitlines()
75
+ print(json.dumps({"unit": unit, "lines": output_lines, "count": len(output_lines)}))
76
+ except subprocess.CalledProcessError as e:
77
+ print(json.dumps({"error": e.stderr.strip(), "unit": unit}))
78
+ sys.exit(1)
79
+ except Exception as e:
80
+ print(json.dumps({"error": str(e)}))
81
+ sys.exit(1)
@@ -0,0 +1,65 @@
1
+ import json
2
+ import socket
3
+ import sys
4
+ import time
5
+
6
+ import runspec as rs
7
+
8
+
9
+ def nc_send(
10
+ host: str,
11
+ port: int,
12
+ command: str,
13
+ wait: float = 0.3,
14
+ read_timeout: float = 0.1,
15
+ ) -> str:
16
+ """Send a command over a plain TCP socket and return the response.
17
+
18
+ Appends a newline to command if not present, waits `wait` seconds for the
19
+ server to respond, then reads in chunks until the socket goes quiet.
20
+ """
21
+ with socket.create_connection((host, port), timeout=10) as sock:
22
+ payload = command if command.endswith("\n") else command + "\n"
23
+ sock.sendall(payload.encode())
24
+
25
+ time.sleep(wait)
26
+
27
+ sock.settimeout(read_timeout)
28
+ chunks: list[bytes] = []
29
+ while True:
30
+ try:
31
+ chunk = sock.recv(4096)
32
+ if not chunk:
33
+ break
34
+ chunks.append(chunk)
35
+ except TimeoutError:
36
+ break
37
+
38
+ return b"".join(chunks).decode(errors="replace")
39
+
40
+
41
+ def main() -> None:
42
+ spec = rs.parse("nc-command")
43
+ host = str(spec.host)
44
+ port = int(spec.port)
45
+ command = str(spec.command)
46
+ wait = float(spec.wait)
47
+ read_timeout = float(spec.read_timeout)
48
+
49
+ try:
50
+ response = nc_send(host, port, command, wait=wait, read_timeout=read_timeout)
51
+ lines = response.splitlines()
52
+ print(
53
+ json.dumps(
54
+ {
55
+ "host": host,
56
+ "port": port,
57
+ "command": command,
58
+ "stdout": response,
59
+ "lines": lines,
60
+ }
61
+ )
62
+ )
63
+ except OSError as e:
64
+ print(json.dumps({"error": str(e), "host": host, "port": port}))
65
+ sys.exit(1)
@@ -0,0 +1,159 @@
1
+ import json
2
+ import re
3
+ import shutil
4
+ import socket
5
+ import subprocess
6
+ import sys
7
+ import time
8
+
9
+ import runspec as rs
10
+
11
+
12
+ def main_ping_host() -> None:
13
+ spec = rs.parse("ping-host")
14
+ host = str(spec.host)
15
+ count = int(spec.count)
16
+
17
+ try:
18
+ result = subprocess.run(
19
+ ["ping", "-c", str(count), host],
20
+ capture_output=True,
21
+ text=True,
22
+ )
23
+ output = result.stdout + result.stderr
24
+
25
+ sent = received = 0
26
+ m = re.search(r"(\d+) packets transmitted,\s*(\d+) received", output)
27
+ if m:
28
+ sent = int(m.group(1))
29
+ received = int(m.group(2))
30
+
31
+ loss_pct = 0.0
32
+ m2 = re.search(r"([\d.]+)% packet loss", output)
33
+ if m2:
34
+ loss_pct = float(m2.group(1))
35
+
36
+ print(
37
+ json.dumps(
38
+ {
39
+ "host": host,
40
+ "reachable": received > 0,
41
+ "packets_sent": sent,
42
+ "packets_received": received,
43
+ "loss_pct": loss_pct,
44
+ }
45
+ )
46
+ )
47
+ except Exception as e:
48
+ print(json.dumps({"error": str(e), "host": host}))
49
+ sys.exit(1)
50
+
51
+
52
+ def main_check_port() -> None:
53
+ spec = rs.parse("check-port")
54
+ host = str(spec.host)
55
+ port = int(spec.port)
56
+ timeout = float(spec.timeout)
57
+
58
+ start = time.monotonic()
59
+ try:
60
+ with socket.create_connection((host, port), timeout=timeout):
61
+ elapsed_ms = round((time.monotonic() - start) * 1000, 1)
62
+ print(
63
+ json.dumps(
64
+ {
65
+ "host": host,
66
+ "port": port,
67
+ "open": True,
68
+ "response_ms": elapsed_ms,
69
+ }
70
+ )
71
+ )
72
+ except (OSError, TimeoutError):
73
+ elapsed_ms = round((time.monotonic() - start) * 1000, 1)
74
+ print(json.dumps({"host": host, "port": port, "open": False, "response_ms": elapsed_ms}))
75
+
76
+
77
+ def main_show_connections() -> None:
78
+ spec = rs.parse("show-connections")
79
+ state_filter = str(spec.state)
80
+
81
+ try:
82
+ if shutil.which("ss"):
83
+ cmd = ["ss", "-tupn"]
84
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
85
+ rows = _parse_ss(result.stdout, state_filter)
86
+ elif shutil.which("netstat"):
87
+ cmd = ["netstat", "-tupn"]
88
+ result = subprocess.run(cmd, capture_output=True, text=True)
89
+ rows = _parse_netstat(result.stdout, state_filter)
90
+ else:
91
+ print(json.dumps({"error": "Neither ss nor netstat found in PATH"}))
92
+ sys.exit(1)
93
+
94
+ print(json.dumps(rows))
95
+ except Exception as e:
96
+ print(json.dumps({"error": str(e)}))
97
+ sys.exit(1)
98
+
99
+
100
+ def _parse_ss(output: str, state_filter: str) -> list[dict]:
101
+ rows = []
102
+ for line in output.strip().splitlines()[1:]:
103
+ parts = line.split()
104
+ if len(parts) < 5:
105
+ continue
106
+ proto, local, peer = parts[0], parts[3], parts[4]
107
+ state = parts[5] if len(parts) > 5 and not parts[5].startswith("users:") else ""
108
+ process = ""
109
+ for p in parts:
110
+ if p.startswith("users:"):
111
+ m = re.search(r'"([^"]+)"', p)
112
+ if m:
113
+ process = m.group(1)
114
+
115
+ row_state = state.lower() if state else ("listen" if peer == "*:*" else "established")
116
+ if state_filter == "established" and "estab" not in row_state:
117
+ continue
118
+ if state_filter == "listening" and "listen" not in row_state:
119
+ continue
120
+
121
+ rows.append(
122
+ {
123
+ "proto": proto,
124
+ "local": local,
125
+ "peer": peer,
126
+ "state": state,
127
+ "process": process,
128
+ }
129
+ )
130
+ return rows
131
+
132
+
133
+ def _parse_netstat(output: str, state_filter: str) -> list[dict]:
134
+ rows = []
135
+ for line in output.strip().splitlines():
136
+ parts = line.split()
137
+ if not parts or parts[0] not in ("tcp", "tcp6", "udp", "udp6"):
138
+ continue
139
+ if len(parts) < 6:
140
+ continue
141
+ proto, local, foreign = parts[0], parts[3], parts[4]
142
+ state = parts[5] if len(parts) > 5 else ""
143
+ process = parts[-1] if len(parts) > 6 else ""
144
+
145
+ if state_filter == "established" and state.upper() != "ESTABLISHED":
146
+ continue
147
+ if state_filter == "listening" and state.upper() != "LISTEN":
148
+ continue
149
+
150
+ rows.append(
151
+ {
152
+ "proto": proto,
153
+ "local": local,
154
+ "peer": foreign,
155
+ "state": state,
156
+ "process": process,
157
+ }
158
+ )
159
+ return rows