runspec-linux 0.1.0__py3-none-any.whl

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,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)
runspec_linux/files.py ADDED
@@ -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)
runspec_linux/logs.py ADDED
@@ -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
@@ -0,0 +1,328 @@
1
+ [config]
2
+ autonomy-default = "autonomous"
3
+
4
+ # ── System monitoring ─────────────────────────────────────────────────────────
5
+
6
+ [system-info]
7
+ serve = ["local"]
8
+ description = "Report hostname, OS, kernel version, uptime, load averages, and CPU count"
9
+ autonomy = "autonomous"
10
+ output = "json"
11
+
12
+ [disk-usage]
13
+ serve = ["local"]
14
+ description = "Report disk usage for all mounted filesystems"
15
+ autonomy = "autonomous"
16
+ output = "json"
17
+
18
+ [check-memory]
19
+ serve = ["local"]
20
+ description = "Report RAM and swap usage in megabytes"
21
+ autonomy = "autonomous"
22
+ output = "json"
23
+
24
+ [list-processes]
25
+ serve = ["local"]
26
+ description = "List running processes, optionally filtered by name"
27
+ autonomy = "autonomous"
28
+ output = "json"
29
+
30
+ [list-processes.args.filter]
31
+ type = "str"
32
+ short = "-f"
33
+ description = "Filter processes whose command contains this string"
34
+ required = false
35
+
36
+ [list-processes.args.limit]
37
+ type = "int"
38
+ short = "-n"
39
+ description = "Maximum number of processes to return"
40
+ default = 20
41
+
42
+ # ── Services ──────────────────────────────────────────────────────────────────
43
+
44
+ [check-service]
45
+ serve = ["local"]
46
+ description = "Check whether a systemd service is active and report its status"
47
+ autonomy = "autonomous"
48
+ output = "json"
49
+
50
+ [check-service.args.service]
51
+ type = "str"
52
+ short = "-s"
53
+ description = "Service name (e.g. nginx or nginx.service)"
54
+
55
+ [list-services]
56
+ serve = ["local"]
57
+ description = "List all systemd service units and their active/sub state"
58
+ autonomy = "autonomous"
59
+ output = "json"
60
+
61
+ [list-services.args.state]
62
+ type = "choice"
63
+ options = ["all", "active", "failed", "inactive"]
64
+ default = "all"
65
+ description = "Filter services by active state"
66
+
67
+ [restart-service]
68
+ serve = ["local"]
69
+ description = "Restart a systemd service"
70
+ autonomy = "confirm"
71
+ output = "json"
72
+
73
+ [restart-service.args.service]
74
+ type = "str"
75
+ short = "-s"
76
+ description = "Service name to restart"
77
+
78
+ # ── Logs ──────────────────────────────────────────────────────────────────────
79
+
80
+ [tail-log]
81
+ serve = ["local"]
82
+ description = "Return the last N lines of a log file"
83
+ autonomy = "autonomous"
84
+ output = "json"
85
+
86
+ [tail-log.args.file]
87
+ type = "path"
88
+ short = "-f"
89
+ description = "Path to the log file"
90
+
91
+ [tail-log.args.lines]
92
+ type = "int"
93
+ short = "-n"
94
+ description = "Number of lines to return"
95
+ default = 50
96
+
97
+ [search-log]
98
+ serve = ["local"]
99
+ description = "Search a log file for lines matching a pattern"
100
+ autonomy = "autonomous"
101
+ output = "json"
102
+
103
+ [search-log.args.file]
104
+ type = "path"
105
+ short = "-f"
106
+ description = "Path to the log file"
107
+
108
+ [search-log.args.pattern]
109
+ type = "str"
110
+ short = "-p"
111
+ description = "Search pattern (case-insensitive substring or regex)"
112
+
113
+ [search-log.args.limit]
114
+ type = "int"
115
+ short = "-n"
116
+ description = "Maximum number of matching lines to return"
117
+ default = 100
118
+
119
+ [journalctl]
120
+ serve = ["local"]
121
+ description = "Fetch recent journal entries for a systemd unit (systemd-only)"
122
+ autonomy = "autonomous"
123
+ output = "json"
124
+
125
+ [journalctl.args.unit]
126
+ type = "str"
127
+ short = "-u"
128
+ description = "Systemd unit name (e.g. nginx or nginx.service)"
129
+
130
+ [journalctl.args.lines]
131
+ type = "int"
132
+ short = "-n"
133
+ description = "Number of lines to return"
134
+ default = 50
135
+
136
+ [journalctl.args.since]
137
+ type = "str"
138
+ description = "Return entries since this time string (e.g. '1 hour ago', '2026-05-27 08:00')"
139
+ required = false
140
+
141
+ # ── Network ───────────────────────────────────────────────────────────────────
142
+
143
+ [ping-host]
144
+ serve = ["local"]
145
+ description = "Ping a host and report reachability and packet loss"
146
+ autonomy = "autonomous"
147
+ output = "json"
148
+
149
+ [ping-host.args.host]
150
+ type = "str"
151
+ short = "-H"
152
+ description = "Hostname or IP address to ping"
153
+
154
+ [ping-host.args.count]
155
+ type = "int"
156
+ short = "-c"
157
+ description = "Number of ICMP packets to send"
158
+ default = 4
159
+
160
+ [check-port]
161
+ serve = ["local"]
162
+ description = "Check whether a TCP port is open on a host"
163
+ autonomy = "autonomous"
164
+ output = "json"
165
+
166
+ [check-port.args.host]
167
+ type = "str"
168
+ short = "-H"
169
+ description = "Hostname or IP address"
170
+
171
+ [check-port.args.port]
172
+ type = "int"
173
+ short = "-p"
174
+ description = "TCP port number"
175
+
176
+ [check-port.args.timeout]
177
+ type = "float"
178
+ description = "Connection timeout in seconds"
179
+ default = 3.0
180
+
181
+ [show-connections]
182
+ serve = ["local"]
183
+ description = "Show active TCP/UDP connections (equivalent to ss or netstat)"
184
+ autonomy = "autonomous"
185
+ output = "json"
186
+
187
+ [show-connections.args.state]
188
+ type = "choice"
189
+ options = ["all", "established", "listening"]
190
+ default = "all"
191
+ description = "Filter connections by state"
192
+
193
+ # ── Files ─────────────────────────────────────────────────────────────────────
194
+
195
+ [find-large-files]
196
+ serve = ["local"]
197
+ description = "Find files larger than a threshold in a directory tree"
198
+ autonomy = "autonomous"
199
+ output = "json"
200
+
201
+ [find-large-files.args.path]
202
+ type = "path"
203
+ short = "-p"
204
+ description = "Directory to search"
205
+
206
+ [find-large-files.args.min-mb]
207
+ type = "float"
208
+ description = "Minimum file size in megabytes"
209
+ default = 100.0
210
+
211
+ [find-large-files.args.limit]
212
+ type = "int"
213
+ short = "-n"
214
+ description = "Maximum number of results to return (sorted by size, largest first)"
215
+ default = 20
216
+
217
+ [backup-files]
218
+ serve = ["local"]
219
+ description = "Create a timestamped tar.gz archive of a directory"
220
+ autonomy = "confirm"
221
+ output = "json"
222
+
223
+ [backup-files.args.source]
224
+ type = "path"
225
+ short = "-s"
226
+ description = "Directory to archive"
227
+
228
+ [backup-files.args.destination]
229
+ type = "path"
230
+ short = "-d"
231
+ description = "Directory where the archive will be written"
232
+ default = "/tmp"
233
+
234
+ # ── Security ──────────────────────────────────────────────────────────────────
235
+
236
+ [last-logins]
237
+ serve = ["local"]
238
+ description = "Show recent login history from the wtmp log"
239
+ autonomy = "autonomous"
240
+ output = "json"
241
+
242
+ [last-logins.args.limit]
243
+ type = "int"
244
+ short = "-n"
245
+ description = "Number of login records to return"
246
+ default = 20
247
+
248
+ [who]
249
+ serve = ["local"]
250
+ description = "List users currently logged in to the system"
251
+ autonomy = "autonomous"
252
+ output = "json"
253
+
254
+ # ── Containers ────────────────────────────────────────────────────────────────
255
+
256
+ [list-containers]
257
+ serve = ["local"]
258
+ description = "List Docker containers (requires Docker)"
259
+ autonomy = "autonomous"
260
+ output = "json"
261
+
262
+ [list-containers.args.all]
263
+ type = "flag"
264
+ short = "-a"
265
+ description = "Include stopped containers"
266
+ default = false
267
+
268
+ [container-logs]
269
+ serve = ["local"]
270
+ description = "Fetch recent log output from a Docker container"
271
+ autonomy = "autonomous"
272
+ output = "json"
273
+
274
+ [container-logs.args.container]
275
+ type = "str"
276
+ short = "-c"
277
+ description = "Container name or ID"
278
+
279
+ [container-logs.args.lines]
280
+ type = "int"
281
+ short = "-n"
282
+ description = "Number of lines to return"
283
+ default = 50
284
+
285
+ [restart-container]
286
+ serve = ["local"]
287
+ description = "Restart a Docker container"
288
+ autonomy = "confirm"
289
+ output = "json"
290
+
291
+ [restart-container.args.container]
292
+ type = "str"
293
+ short = "-c"
294
+ description = "Container name or ID"
295
+
296
+ # ── TCP interfaces (nc-command) ────────────────────────────────────────────────
297
+
298
+ [nc-command]
299
+ serve = ["local"]
300
+ description = "Send a command over a plain TCP socket and return the response (nc-style)"
301
+ autonomy = "autonomous"
302
+ output = "json"
303
+
304
+ [nc-command.args.host]
305
+ type = "str"
306
+ short = "-H"
307
+ description = "Hostname or IP address"
308
+
309
+ [nc-command.args.port]
310
+ type = "int"
311
+ short = "-p"
312
+ description = "TCP port number"
313
+
314
+ [nc-command.args.command]
315
+ type = "str"
316
+ short = "-c"
317
+ description = "Command string to send (a newline is appended automatically)"
318
+
319
+ [nc-command.args.wait]
320
+ type = "float"
321
+ short = "-w"
322
+ description = "Seconds to keep the connection open after sending (allows server response to arrive)"
323
+ default = 0.3
324
+
325
+ [nc-command.args.read-timeout]
326
+ type = "float"
327
+ description = "Per-chunk socket read timeout in seconds"
328
+ default = 0.1
@@ -0,0 +1,70 @@
1
+ import json
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+
6
+ import runspec as rs
7
+
8
+
9
+ def main_last_logins() -> None:
10
+ spec = rs.parse("last-logins")
11
+ limit = int(spec.limit)
12
+
13
+ if not shutil.which("last"):
14
+ print(json.dumps({"error": "last command not found"}))
15
+ sys.exit(1)
16
+
17
+ try:
18
+ result = subprocess.run(
19
+ ["last", "-n", str(limit), "--time-format", "iso"],
20
+ capture_output=True,
21
+ text=True,
22
+ )
23
+ rows = []
24
+ for line in result.stdout.strip().splitlines():
25
+ if not line or line.startswith("wtmp") or line.startswith("btmp"):
26
+ continue
27
+ parts = line.split()
28
+ if len(parts) < 3:
29
+ continue
30
+ rows.append(
31
+ {
32
+ "user": parts[0],
33
+ "terminal": parts[1],
34
+ "from": parts[2] if len(parts) > 2 else "",
35
+ "login_time": parts[3] if len(parts) > 3 else "",
36
+ "logout_time": parts[5] if len(parts) > 5 else "",
37
+ }
38
+ )
39
+ print(json.dumps(rows[:limit]))
40
+ except Exception as e:
41
+ print(json.dumps({"error": str(e)}))
42
+ sys.exit(1)
43
+
44
+
45
+ def main_who() -> None:
46
+ rs.parse("who")
47
+
48
+ if not shutil.which("who"):
49
+ print(json.dumps({"error": "who command not found"}))
50
+ sys.exit(1)
51
+
52
+ try:
53
+ result = subprocess.run(["who"], capture_output=True, text=True, check=True)
54
+ rows = []
55
+ for line in result.stdout.strip().splitlines():
56
+ parts = line.split()
57
+ if len(parts) < 3:
58
+ continue
59
+ rows.append(
60
+ {
61
+ "user": parts[0],
62
+ "terminal": parts[1],
63
+ "login_time": " ".join(parts[2:4]) if len(parts) > 3 else parts[2],
64
+ "from": parts[4].strip("()") if len(parts) > 4 else "",
65
+ }
66
+ )
67
+ print(json.dumps(rows))
68
+ except Exception as e:
69
+ print(json.dumps({"error": str(e)}))
70
+ sys.exit(1)
@@ -0,0 +1,119 @@
1
+ import json
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+
6
+ import runspec as rs
7
+
8
+
9
+ def _systemctl_available() -> bool:
10
+ return shutil.which("systemctl") is not None
11
+
12
+
13
+ def main_check_service() -> None:
14
+ spec = rs.parse("check-service")
15
+ service = str(spec.service)
16
+
17
+ if not _systemctl_available():
18
+ print(json.dumps({"error": "systemctl not available — not a systemd system"}))
19
+ sys.exit(1)
20
+
21
+ try:
22
+ result = subprocess.run(
23
+ [
24
+ "systemctl",
25
+ "show",
26
+ service,
27
+ "--property=ActiveState,SubState,LoadState,Description,ActiveEnterTimestamp",
28
+ ],
29
+ capture_output=True,
30
+ text=True,
31
+ )
32
+ props: dict[str, str] = {}
33
+ for line in result.stdout.strip().splitlines():
34
+ if "=" in line:
35
+ k, v = line.split("=", 1)
36
+ props[k] = v
37
+
38
+ active = props.get("ActiveState", "unknown")
39
+ print(
40
+ json.dumps(
41
+ {
42
+ "service": service,
43
+ "active": active == "active",
44
+ "active_state": active,
45
+ "sub_state": props.get("SubState", "unknown"),
46
+ "load_state": props.get("LoadState", "unknown"),
47
+ "description": props.get("Description", ""),
48
+ "since": props.get("ActiveEnterTimestamp", ""),
49
+ }
50
+ )
51
+ )
52
+ except Exception as e:
53
+ print(json.dumps({"error": str(e)}))
54
+ sys.exit(1)
55
+
56
+
57
+ def main_list_services() -> None:
58
+ spec = rs.parse("list-services")
59
+ state_filter = str(spec.state)
60
+
61
+ if not _systemctl_available():
62
+ print(json.dumps({"error": "systemctl not available — not a systemd system"}))
63
+ sys.exit(1)
64
+
65
+ try:
66
+ cmd = ["systemctl", "list-units", "--type=service", "--no-pager", "--no-legend"]
67
+ if state_filter != "all":
68
+ cmd += [f"--state={state_filter}"]
69
+
70
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
71
+ rows = []
72
+ for line in result.stdout.strip().splitlines():
73
+ parts = line.split(None, 4)
74
+ if len(parts) < 4:
75
+ continue
76
+ rows.append(
77
+ {
78
+ "unit": parts[0],
79
+ "load": parts[1],
80
+ "active": parts[2],
81
+ "sub": parts[3],
82
+ "description": parts[4] if len(parts) > 4 else "",
83
+ }
84
+ )
85
+ print(json.dumps(rows))
86
+ except Exception as e:
87
+ print(json.dumps({"error": str(e)}))
88
+ sys.exit(1)
89
+
90
+
91
+ def main_restart_service() -> None:
92
+ spec = rs.parse("restart-service")
93
+ service = str(spec.service)
94
+
95
+ if not _systemctl_available():
96
+ print(json.dumps({"error": "systemctl not available — not a systemd system"}))
97
+ sys.exit(1)
98
+
99
+ try:
100
+ result = subprocess.run(
101
+ ["systemctl", "restart", service],
102
+ capture_output=True,
103
+ text=True,
104
+ )
105
+ if result.returncode != 0:
106
+ print(
107
+ json.dumps(
108
+ {
109
+ "service": service,
110
+ "restarted": False,
111
+ "error": result.stderr.strip(),
112
+ }
113
+ )
114
+ )
115
+ sys.exit(1)
116
+ print(json.dumps({"service": service, "restarted": True}))
117
+ except Exception as e:
118
+ print(json.dumps({"error": str(e)}))
119
+ sys.exit(1)
@@ -0,0 +1,140 @@
1
+ import json
2
+ import os
3
+ import platform
4
+ import sys
5
+
6
+ import runspec as rs
7
+
8
+
9
+ def main_system_info() -> None:
10
+ rs.parse("system-info")
11
+
12
+ try:
13
+ load1, load5, load15 = os.getloadavg()
14
+ with open("/proc/uptime") as f:
15
+ uptime_seconds = float(f.read().split()[0])
16
+
17
+ print(
18
+ json.dumps(
19
+ {
20
+ "hostname": platform.node(),
21
+ "os": f"{platform.system()} {platform.release()}",
22
+ "kernel": platform.version(),
23
+ "machine": platform.machine(),
24
+ "uptime_seconds": round(uptime_seconds),
25
+ "load_1": round(load1, 2),
26
+ "load_5": round(load5, 2),
27
+ "load_15": round(load15, 2),
28
+ "cpu_count": os.cpu_count(),
29
+ }
30
+ )
31
+ )
32
+ except Exception as e:
33
+ print(json.dumps({"error": str(e)}))
34
+ sys.exit(1)
35
+
36
+
37
+ def main_disk_usage() -> None:
38
+ rs.parse("disk-usage")
39
+
40
+ try:
41
+ import subprocess
42
+
43
+ result = subprocess.run(
44
+ ["df", "-P", "-B1"],
45
+ capture_output=True,
46
+ text=True,
47
+ check=True,
48
+ )
49
+ rows = []
50
+ for line in result.stdout.strip().splitlines()[1:]:
51
+ parts = line.split()
52
+ if len(parts) < 6:
53
+ continue
54
+ size_bytes = int(parts[1])
55
+ used_bytes = int(parts[2])
56
+ avail_bytes = int(parts[3])
57
+ rows.append(
58
+ {
59
+ "filesystem": parts[0],
60
+ "size_mb": round(size_bytes / 1_048_576, 1),
61
+ "used_mb": round(used_bytes / 1_048_576, 1),
62
+ "available_mb": round(avail_bytes / 1_048_576, 1),
63
+ "use_pct": parts[4],
64
+ "mounted_on": parts[5],
65
+ }
66
+ )
67
+ print(json.dumps(rows))
68
+ except Exception as e:
69
+ print(json.dumps({"error": str(e)}))
70
+ sys.exit(1)
71
+
72
+
73
+ def main_check_memory() -> None:
74
+ rs.parse("check-memory")
75
+
76
+ try:
77
+ mem: dict[str, int] = {}
78
+ with open("/proc/meminfo") as f:
79
+ for line in f:
80
+ key, val_kb = line.split(":")
81
+ kb = int(val_kb.strip().split()[0])
82
+ mem[key.strip()] = kb
83
+
84
+ print(
85
+ json.dumps(
86
+ {
87
+ "total_mb": round(mem.get("MemTotal", 0) / 1024, 1),
88
+ "used_mb": round((mem.get("MemTotal", 0) - mem.get("MemAvailable", 0)) / 1024, 1),
89
+ "free_mb": round(mem.get("MemFree", 0) / 1024, 1),
90
+ "available_mb": round(mem.get("MemAvailable", 0) / 1024, 1),
91
+ "cached_mb": round(mem.get("Cached", 0) / 1024, 1),
92
+ "swap_total_mb": round(mem.get("SwapTotal", 0) / 1024, 1),
93
+ "swap_used_mb": round((mem.get("SwapTotal", 0) - mem.get("SwapFree", 0)) / 1024, 1),
94
+ }
95
+ )
96
+ )
97
+ except Exception as e:
98
+ print(json.dumps({"error": str(e)}))
99
+ sys.exit(1)
100
+
101
+
102
+ def main_list_processes() -> None:
103
+ spec = rs.parse("list-processes")
104
+ name_filter = str(spec.filter) if spec.filter is not None else None
105
+ limit = int(spec.limit)
106
+
107
+ try:
108
+ import subprocess
109
+
110
+ result = subprocess.run(
111
+ ["ps", "aux", "--no-headers"],
112
+ capture_output=True,
113
+ text=True,
114
+ check=True,
115
+ )
116
+ rows = []
117
+ for line in result.stdout.strip().splitlines():
118
+ parts = line.split(None, 10)
119
+ if len(parts) < 11:
120
+ continue
121
+ command = parts[10]
122
+ if name_filter and name_filter.lower() not in command.lower():
123
+ continue
124
+ rows.append(
125
+ {
126
+ "user": parts[0],
127
+ "pid": int(parts[1]),
128
+ "cpu_pct": float(parts[2]),
129
+ "mem_pct": float(parts[3]),
130
+ "vsz_kb": int(parts[4]),
131
+ "rss_kb": int(parts[5]),
132
+ "stat": parts[7],
133
+ "command": command,
134
+ }
135
+ )
136
+ rows.sort(key=lambda r: r["cpu_pct"], reverse=True)
137
+ print(json.dumps(rows[:limit]))
138
+ except Exception as e:
139
+ print(json.dumps({"error": str(e)}))
140
+ sys.exit(1)
@@ -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,14 @@
1
+ runspec_linux/__init__.py,sha256=4Ip3UjHGdqM7G8KUd9Z0M-DU89a1S0UWZCy0mrvT3MA,68
2
+ runspec_linux/containers.py,sha256=aNx6F9Wsaw7MEezRLr9UrS8_DxFsPO7LLO82qLycOdY,3279
3
+ runspec_linux/files.py,sha256=Dx6SmewG0LIvTVRedZnCET31TM7OKVWeviWrJrxjUgc,2257
4
+ runspec_linux/logs.py,sha256=QQeQT8hbLxKrVzv9myPoZW6H3CjQw--t3WL_nhy031E,2562
5
+ runspec_linux/nc_command.py,sha256=32X7IGrXq6lF3Xuvgy9wW5SqhUT7wC_FGeDkljEtySs,1733
6
+ runspec_linux/network.py,sha256=YrkQc9ISODnNUytaRYyW3ptNzFfcdxhgFxGwNiAcNG8,4750
7
+ runspec_linux/runspec.toml,sha256=IllfzWlJ9lFx_QhyMARAYLjkaAh-Zkzum_rn5Bt3dR0,9289
8
+ runspec_linux/security.py,sha256=Zy6fKLLCZXgcIQoenxcetNWsHNZxMbsJhR0x-87FLO8,2085
9
+ runspec_linux/services.py,sha256=tjZjaVLuHKS4EN9l0vKRjymfLpgvJ0t6AIpZivF71MI,3565
10
+ runspec_linux/system_info.py,sha256=CN2E_hiHeZG-BobfSCpqsFNRu_uKlp4SQgRsN084_M4,4399
11
+ runspec_linux-0.1.0.dist-info/METADATA,sha256=mkwJ0DZygQEPJD8vlz6Q85rPXWVC6YyNNwxDdcLyNE8,297
12
+ runspec_linux-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ runspec_linux-0.1.0.dist-info/entry_points.txt,sha256=_QpmTRGmjww1oJ7Ze8kl63x-_fZycJPQGf0V7Jxu8EQ,1176
14
+ runspec_linux-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ [console_scripts]
2
+ backup-files = runspec_linux.files:main_backup_files
3
+ check-memory = runspec_linux.system_info:main_check_memory
4
+ check-port = runspec_linux.network:main_check_port
5
+ check-service = runspec_linux.services:main_check_service
6
+ container-logs = runspec_linux.containers:main_container_logs
7
+ disk-usage = runspec_linux.system_info:main_disk_usage
8
+ find-large-files = runspec_linux.files:main_find_large_files
9
+ journalctl = runspec_linux.logs:main_journalctl
10
+ last-logins = runspec_linux.security:main_last_logins
11
+ list-containers = runspec_linux.containers:main_list_containers
12
+ list-processes = runspec_linux.system_info:main_list_processes
13
+ list-services = runspec_linux.services:main_list_services
14
+ nc-command = runspec_linux.nc_command:main
15
+ ping-host = runspec_linux.network:main_ping_host
16
+ restart-container = runspec_linux.containers:main_restart_container
17
+ restart-service = runspec_linux.services:main_restart_service
18
+ search-log = runspec_linux.logs:main_search_log
19
+ show-connections = runspec_linux.network:main_show_connections
20
+ system-info = runspec_linux.system_info:main_system_info
21
+ tail-log = runspec_linux.logs:main_tail_log
22
+ who = runspec_linux.security:main_who