runspec-linux-core 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,60 @@
1
+ """runspec-linux-core — pure-Python Linux system-admin helpers.
2
+
3
+ This package has **no dependency on runspec** and ships **no runspec.toml and no
4
+ entry points**, so installing it exposes the helper functions for import without
5
+ surfacing any runnables (it is invisible to ``runspec local`` / ``runspec serve``
6
+ discovery). ``runspec-linux`` depends on it and wraps each helper in a runnable.
7
+
8
+ Each function does the work and returns plain data; failures raise (see
9
+ ``runspec_linux_core.errors``).
10
+ """
11
+
12
+ from runspec_linux_core.containers import (
13
+ container_logs,
14
+ list_containers,
15
+ restart_container,
16
+ )
17
+ from runspec_linux_core.errors import CommandError, LinuxCoreError, ToolNotFoundError
18
+ from runspec_linux_core.files import backup_files, find_large_files
19
+ from runspec_linux_core.logs import journalctl, search_log, tail_log
20
+ from runspec_linux_core.nc import nc_send
21
+ from runspec_linux_core.network import check_port, ping_host, show_connections
22
+ from runspec_linux_core.security import last_logins, who
23
+ from runspec_linux_core.services import check_service, list_services, restart_service
24
+ from runspec_linux_core.system import check_memory, disk_usage, list_processes, system_info
25
+
26
+ __all__ = [
27
+ # errors
28
+ "LinuxCoreError",
29
+ "ToolNotFoundError",
30
+ "CommandError",
31
+ # system
32
+ "system_info",
33
+ "disk_usage",
34
+ "check_memory",
35
+ "list_processes",
36
+ # services
37
+ "check_service",
38
+ "list_services",
39
+ "restart_service",
40
+ # logs
41
+ "tail_log",
42
+ "search_log",
43
+ "journalctl",
44
+ # network
45
+ "ping_host",
46
+ "check_port",
47
+ "show_connections",
48
+ # files
49
+ "find_large_files",
50
+ "backup_files",
51
+ # security
52
+ "last_logins",
53
+ "who",
54
+ # containers
55
+ "list_containers",
56
+ "container_logs",
57
+ "restart_container",
58
+ # tcp
59
+ "nc_send",
60
+ ]
@@ -0,0 +1,88 @@
1
+ """Docker container helpers: list, logs, restart."""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
7
+
8
+
9
+ def _docker_available() -> bool:
10
+ return shutil.which("docker") is not None
11
+
12
+
13
+ def list_containers(include_all: bool = False) -> list[dict]:
14
+ """Return Docker containers (running, or all if ``include_all``).
15
+
16
+ Raises ToolNotFoundError if docker is not installed, or CommandError if the
17
+ ``docker ps`` command returns a non-zero exit.
18
+ """
19
+ if not _docker_available():
20
+ raise ToolNotFoundError("docker not installed or not in PATH")
21
+
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
+ raise CommandError(result.stderr.strip())
34
+
35
+ rows = []
36
+ for line in result.stdout.strip().splitlines():
37
+ parts = line.split("\t")
38
+ if len(parts) < 6:
39
+ continue
40
+ rows.append(
41
+ {
42
+ "id": parts[0],
43
+ "image": parts[1],
44
+ "command": parts[2].strip('"'),
45
+ "created": parts[3],
46
+ "status": parts[4],
47
+ "name": parts[5],
48
+ }
49
+ )
50
+ return rows
51
+
52
+
53
+ def container_logs(container: str, lines: int = 50) -> dict:
54
+ """Return the last ``lines`` log lines for a container.
55
+
56
+ Raises ToolNotFoundError if docker is not installed.
57
+ """
58
+ if not _docker_available():
59
+ raise ToolNotFoundError("docker not installed or not in PATH")
60
+
61
+ result = subprocess.run(
62
+ ["docker", "logs", "--tail", str(lines), container],
63
+ capture_output=True,
64
+ text=True,
65
+ )
66
+ # docker logs writes to stderr by default
67
+ output = result.stderr if result.stderr else result.stdout
68
+ output_lines = output.strip().splitlines()
69
+ return {"container": container, "lines": output_lines, "count": len(output_lines)}
70
+
71
+
72
+ def restart_container(container: str) -> dict:
73
+ """Restart a container. Returns ``{container, restarted: True}`` on success.
74
+
75
+ Raises ToolNotFoundError if docker is not installed, or CommandError if the
76
+ restart returns a non-zero exit (message is the captured stderr).
77
+ """
78
+ if not _docker_available():
79
+ raise ToolNotFoundError("docker not installed or not in PATH")
80
+
81
+ result = subprocess.run(
82
+ ["docker", "restart", container],
83
+ capture_output=True,
84
+ text=True,
85
+ )
86
+ if result.returncode != 0:
87
+ raise CommandError(result.stderr.strip())
88
+ return {"container": container, "restarted": True}
@@ -0,0 +1,20 @@
1
+ """Exception types raised by the pure helper functions.
2
+
3
+ The functions in this package do the work and *raise* on failure; the thin
4
+ runnable wrappers in ``runspec-linux`` catch these and turn them into the JSON
5
+ error payloads + non-zero exits that the CLI/agent surface expects. Keeping the
6
+ error types here (rather than re-deriving them from messages) lets each wrapper
7
+ format its own shape — e.g. a missing tool vs. a command that ran but failed.
8
+ """
9
+
10
+
11
+ class LinuxCoreError(Exception):
12
+ """Base class for all runspec-linux-core failures."""
13
+
14
+
15
+ class ToolNotFoundError(LinuxCoreError):
16
+ """A required system tool (systemctl, docker, ss, last, ...) is not on PATH."""
17
+
18
+
19
+ class CommandError(LinuxCoreError):
20
+ """A subprocess ran to completion but reported failure (non-zero exit)."""
@@ -0,0 +1,54 @@
1
+ """File helpers: find large files, create a timestamped tar.gz backup."""
2
+
3
+ import os
4
+ import tarfile
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+
9
+ def find_large_files(search_path: str, min_mb: float = 100.0, limit: int = 50) -> list[dict]:
10
+ """Return files under ``search_path`` at least ``min_mb`` MB, biggest first."""
11
+ min_bytes = int(min_mb * 1_048_576)
12
+
13
+ results: list[dict] = []
14
+ for dirpath, _dirs, filenames in os.walk(search_path):
15
+ for name in filenames:
16
+ fpath = os.path.join(dirpath, name)
17
+ try:
18
+ stat = os.stat(fpath, follow_symlinks=False)
19
+ if stat.st_size >= min_bytes:
20
+ results.append(
21
+ {
22
+ "path": fpath,
23
+ "size_mb": round(stat.st_size / 1_048_576, 2),
24
+ "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
25
+ }
26
+ )
27
+ except OSError:
28
+ continue
29
+
30
+ results.sort(key=lambda r: r["size_mb"], reverse=True)
31
+ return results[:limit]
32
+
33
+
34
+ def backup_files(source: str, destination: str) -> dict:
35
+ """Create a timestamped ``.tar.gz`` of ``source`` inside ``destination``.
36
+
37
+ Returns the archive path and its size.
38
+ """
39
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
40
+ source_name = Path(source).name or "backup"
41
+ archive_name = f"{source_name}_{timestamp}.tar.gz"
42
+ archive_path = os.path.join(destination, archive_name)
43
+
44
+ os.makedirs(destination, exist_ok=True)
45
+ with tarfile.open(archive_path, "w:gz") as tar:
46
+ tar.add(source, arcname=source_name)
47
+
48
+ size_bytes = os.path.getsize(archive_path)
49
+ return {
50
+ "source": source,
51
+ "destination": archive_path,
52
+ "size_bytes": size_bytes,
53
+ "size_mb": round(size_bytes / 1_048_576, 2),
54
+ }
@@ -0,0 +1,57 @@
1
+ """Log helpers: tail a file, search a file, read journald units."""
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+
7
+ from runspec_linux_core.errors import ToolNotFoundError
8
+
9
+
10
+ def tail_log(file_path: str, lines: int = 50) -> dict:
11
+ """Return the last ``lines`` lines of a text file.
12
+
13
+ Propagates OSError if the file can't be read.
14
+ """
15
+ with open(file_path, errors="replace") as f:
16
+ all_lines = f.readlines()
17
+ tail = [line.rstrip("\n") for line in all_lines[-lines:]]
18
+ return {"file": file_path, "lines": tail, "count": len(tail)}
19
+
20
+
21
+ def search_log(file_path: str, pattern: str, limit: int = 100) -> dict:
22
+ """Return up to ``limit`` (most recent) lines of a file matching ``pattern``.
23
+
24
+ Propagates OSError if the file can't be read, or re.error on a bad pattern.
25
+ """
26
+ regex = re.compile(pattern, re.IGNORECASE)
27
+ matches: list[str] = []
28
+ with open(file_path, errors="replace") as f:
29
+ for line in f:
30
+ if regex.search(line):
31
+ matches.append(line.rstrip("\n"))
32
+ trimmed = matches[-limit:] if len(matches) > limit else matches
33
+ return {
34
+ "file": file_path,
35
+ "pattern": pattern,
36
+ "matches": trimmed,
37
+ "count": len(trimmed),
38
+ "total_matches": len(matches),
39
+ }
40
+
41
+
42
+ def journalctl(unit: str, lines: int = 50, since: str | None = None) -> dict:
43
+ """Return recent journald log lines for a systemd unit.
44
+
45
+ Raises ToolNotFoundError if journalctl is not present; propagates
46
+ subprocess.CalledProcessError if journalctl exits non-zero.
47
+ """
48
+ if not shutil.which("journalctl"):
49
+ raise ToolNotFoundError("journalctl not available — not a systemd system")
50
+
51
+ cmd = ["journalctl", f"-u{unit}", f"-n{lines}", "--no-pager", "--output=short"]
52
+ if since:
53
+ cmd += [f"--since={since}"]
54
+
55
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
56
+ output_lines = result.stdout.strip().splitlines()
57
+ return {"unit": unit, "lines": output_lines, "count": len(output_lines)}
@@ -0,0 +1,36 @@
1
+ """Plain-TCP send/receive helper (the ``nc-command`` core)."""
2
+
3
+ import socket
4
+ import time
5
+
6
+
7
+ def nc_send(
8
+ host: str,
9
+ port: int,
10
+ command: str,
11
+ wait: float = 0.3,
12
+ read_timeout: float = 0.1,
13
+ ) -> str:
14
+ """Send a command over a plain TCP socket and return the response.
15
+
16
+ Appends a newline to command if not present, waits `wait` seconds for the
17
+ server to respond, then reads in chunks until the socket goes quiet.
18
+ """
19
+ with socket.create_connection((host, port), timeout=10) as sock:
20
+ payload = command if command.endswith("\n") else command + "\n"
21
+ sock.sendall(payload.encode())
22
+
23
+ time.sleep(wait)
24
+
25
+ sock.settimeout(read_timeout)
26
+ chunks: list[bytes] = []
27
+ while True:
28
+ try:
29
+ chunk = sock.recv(4096)
30
+ if not chunk:
31
+ break
32
+ chunks.append(chunk)
33
+ except TimeoutError:
34
+ break
35
+
36
+ return b"".join(chunks).decode(errors="replace")
@@ -0,0 +1,130 @@
1
+ """Network helpers: ping, TCP port check, socket connection listing."""
2
+
3
+ import re
4
+ import shutil
5
+ import socket
6
+ import subprocess
7
+ import time
8
+
9
+ from runspec_linux_core.errors import ToolNotFoundError
10
+
11
+
12
+ def ping_host(host: str, count: int = 4) -> dict:
13
+ """Ping a host ``count`` times and return reachability + packet stats."""
14
+ result = subprocess.run(
15
+ ["ping", "-c", str(count), host],
16
+ capture_output=True,
17
+ text=True,
18
+ )
19
+ output = result.stdout + result.stderr
20
+
21
+ sent = received = 0
22
+ m = re.search(r"(\d+) packets transmitted,\s*(\d+) received", output)
23
+ if m:
24
+ sent = int(m.group(1))
25
+ received = int(m.group(2))
26
+
27
+ loss_pct = 0.0
28
+ m2 = re.search(r"([\d.]+)% packet loss", output)
29
+ if m2:
30
+ loss_pct = float(m2.group(1))
31
+
32
+ return {
33
+ "host": host,
34
+ "reachable": received > 0,
35
+ "packets_sent": sent,
36
+ "packets_received": received,
37
+ "loss_pct": loss_pct,
38
+ }
39
+
40
+
41
+ def check_port(host: str, port: int, timeout: float = 3.0) -> dict:
42
+ """Check whether a TCP port accepts a connection.
43
+
44
+ A refused/timed-out connection is a valid result (``open: False``), not an
45
+ error — this function does not raise on connection failure.
46
+ """
47
+ start = time.monotonic()
48
+ try:
49
+ with socket.create_connection((host, port), timeout=timeout):
50
+ elapsed_ms = round((time.monotonic() - start) * 1000, 1)
51
+ return {"host": host, "port": port, "open": True, "response_ms": elapsed_ms}
52
+ except (OSError, TimeoutError):
53
+ elapsed_ms = round((time.monotonic() - start) * 1000, 1)
54
+ return {"host": host, "port": port, "open": False, "response_ms": elapsed_ms}
55
+
56
+
57
+ def show_connections(state_filter: str = "all") -> list[dict]:
58
+ """Return active socket connections via ``ss`` (preferred) or ``netstat``.
59
+
60
+ Raises ToolNotFoundError if neither tool is on PATH.
61
+ """
62
+ if shutil.which("ss"):
63
+ result = subprocess.run(["ss", "-tupn"], capture_output=True, text=True, check=True)
64
+ return _parse_ss(result.stdout, state_filter)
65
+ if shutil.which("netstat"):
66
+ result = subprocess.run(["netstat", "-tupn"], capture_output=True, text=True)
67
+ return _parse_netstat(result.stdout, state_filter)
68
+ raise ToolNotFoundError("Neither ss nor netstat found in PATH")
69
+
70
+
71
+ def _parse_ss(output: str, state_filter: str) -> list[dict]:
72
+ rows = []
73
+ for line in output.strip().splitlines()[1:]:
74
+ parts = line.split()
75
+ if len(parts) < 5:
76
+ continue
77
+ proto, local, peer = parts[0], parts[3], parts[4]
78
+ state = parts[5] if len(parts) > 5 and not parts[5].startswith("users:") else ""
79
+ process = ""
80
+ for p in parts:
81
+ if p.startswith("users:"):
82
+ m = re.search(r'"([^"]+)"', p)
83
+ if m:
84
+ process = m.group(1)
85
+
86
+ row_state = state.lower() if state else ("listen" if peer == "*:*" else "established")
87
+ if state_filter == "established" and "estab" not in row_state:
88
+ continue
89
+ if state_filter == "listening" and "listen" not in row_state:
90
+ continue
91
+
92
+ rows.append(
93
+ {
94
+ "proto": proto,
95
+ "local": local,
96
+ "peer": peer,
97
+ "state": state,
98
+ "process": process,
99
+ }
100
+ )
101
+ return rows
102
+
103
+
104
+ def _parse_netstat(output: str, state_filter: str) -> list[dict]:
105
+ rows = []
106
+ for line in output.strip().splitlines():
107
+ parts = line.split()
108
+ if not parts or parts[0] not in ("tcp", "tcp6", "udp", "udp6"):
109
+ continue
110
+ if len(parts) < 6:
111
+ continue
112
+ proto, local, foreign = parts[0], parts[3], parts[4]
113
+ state = parts[5] if len(parts) > 5 else ""
114
+ process = parts[-1] if len(parts) > 6 else ""
115
+
116
+ if state_filter == "established" and state.upper() != "ESTABLISHED":
117
+ continue
118
+ if state_filter == "listening" and state.upper() != "LISTEN":
119
+ continue
120
+
121
+ rows.append(
122
+ {
123
+ "proto": proto,
124
+ "local": local,
125
+ "peer": foreign,
126
+ "state": state,
127
+ "process": process,
128
+ }
129
+ )
130
+ return rows
@@ -0,0 +1,63 @@
1
+ """Security helpers: recent logins, currently logged-in users."""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ from runspec_linux_core.errors import ToolNotFoundError
7
+
8
+
9
+ def last_logins(limit: int = 20) -> list[dict]:
10
+ """Return the most recent logins via ``last``.
11
+
12
+ Raises ToolNotFoundError if ``last`` is not on PATH.
13
+ """
14
+ if not shutil.which("last"):
15
+ raise ToolNotFoundError("last command not found")
16
+
17
+ result = subprocess.run(
18
+ ["last", "-n", str(limit), "--time-format", "iso"],
19
+ capture_output=True,
20
+ text=True,
21
+ )
22
+ rows = []
23
+ for line in result.stdout.strip().splitlines():
24
+ if not line or line.startswith("wtmp") or line.startswith("btmp"):
25
+ continue
26
+ parts = line.split()
27
+ if len(parts) < 3:
28
+ continue
29
+ rows.append(
30
+ {
31
+ "user": parts[0],
32
+ "terminal": parts[1],
33
+ "from": parts[2] if len(parts) > 2 else "",
34
+ "login_time": parts[3] if len(parts) > 3 else "",
35
+ "logout_time": parts[5] if len(parts) > 5 else "",
36
+ }
37
+ )
38
+ return rows[:limit]
39
+
40
+
41
+ def who() -> list[dict]:
42
+ """Return currently logged-in users via ``who``.
43
+
44
+ Raises ToolNotFoundError if ``who`` is not on PATH.
45
+ """
46
+ if not shutil.which("who"):
47
+ raise ToolNotFoundError("who command not found")
48
+
49
+ result = subprocess.run(["who"], capture_output=True, text=True, check=True)
50
+ rows = []
51
+ for line in result.stdout.strip().splitlines():
52
+ parts = line.split()
53
+ if len(parts) < 3:
54
+ continue
55
+ rows.append(
56
+ {
57
+ "user": parts[0],
58
+ "terminal": parts[1],
59
+ "login_time": " ".join(parts[2:4]) if len(parts) > 3 else parts[2],
60
+ "from": parts[4].strip("()") if len(parts) > 4 else "",
61
+ }
62
+ )
63
+ return rows
@@ -0,0 +1,95 @@
1
+ """systemd service helpers: check status, list units, restart."""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
7
+
8
+
9
+ def _systemctl_available() -> bool:
10
+ return shutil.which("systemctl") is not None
11
+
12
+
13
+ def check_service(service: str) -> dict:
14
+ """Return the status of a systemd service.
15
+
16
+ Raises ToolNotFoundError if systemctl is not present.
17
+ """
18
+ if not _systemctl_available():
19
+ raise ToolNotFoundError("systemctl not available — not a systemd system")
20
+
21
+ result = subprocess.run(
22
+ [
23
+ "systemctl",
24
+ "show",
25
+ service,
26
+ "--property=ActiveState,SubState,LoadState,Description,ActiveEnterTimestamp",
27
+ ],
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+ props: dict[str, str] = {}
32
+ for line in result.stdout.strip().splitlines():
33
+ if "=" in line:
34
+ k, v = line.split("=", 1)
35
+ props[k] = v
36
+
37
+ active = props.get("ActiveState", "unknown")
38
+ return {
39
+ "service": service,
40
+ "active": active == "active",
41
+ "active_state": active,
42
+ "sub_state": props.get("SubState", "unknown"),
43
+ "load_state": props.get("LoadState", "unknown"),
44
+ "description": props.get("Description", ""),
45
+ "since": props.get("ActiveEnterTimestamp", ""),
46
+ }
47
+
48
+
49
+ def list_services(state_filter: str = "all") -> list[dict]:
50
+ """Return systemd service units, optionally filtered by state.
51
+
52
+ Raises ToolNotFoundError if systemctl is not present.
53
+ """
54
+ if not _systemctl_available():
55
+ raise ToolNotFoundError("systemctl not available — not a systemd system")
56
+
57
+ cmd = ["systemctl", "list-units", "--type=service", "--no-pager", "--no-legend"]
58
+ if state_filter != "all":
59
+ cmd += [f"--state={state_filter}"]
60
+
61
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
62
+ rows = []
63
+ for line in result.stdout.strip().splitlines():
64
+ parts = line.split(None, 4)
65
+ if len(parts) < 4:
66
+ continue
67
+ rows.append(
68
+ {
69
+ "unit": parts[0],
70
+ "load": parts[1],
71
+ "active": parts[2],
72
+ "sub": parts[3],
73
+ "description": parts[4] if len(parts) > 4 else "",
74
+ }
75
+ )
76
+ return rows
77
+
78
+
79
+ def restart_service(service: str) -> dict:
80
+ """Restart a systemd service. Returns ``{service, restarted: True}`` on success.
81
+
82
+ Raises ToolNotFoundError if systemctl is not present, or CommandError if the
83
+ restart command returns a non-zero exit (message is the captured stderr).
84
+ """
85
+ if not _systemctl_available():
86
+ raise ToolNotFoundError("systemctl not available — not a systemd system")
87
+
88
+ result = subprocess.run(
89
+ ["systemctl", "restart", service],
90
+ capture_output=True,
91
+ text=True,
92
+ )
93
+ if result.returncode != 0:
94
+ raise CommandError(result.stderr.strip())
95
+ return {"service": service, "restarted": True}
@@ -0,0 +1,110 @@
1
+ """System monitoring helpers: host info, disk, memory, processes."""
2
+
3
+ import os
4
+ import platform
5
+ import subprocess
6
+ from typing import cast
7
+
8
+
9
+ def system_info() -> dict:
10
+ """Return hostname, OS/kernel, uptime, load averages, and CPU count."""
11
+ load1, load5, load15 = os.getloadavg()
12
+ with open("/proc/uptime") as f:
13
+ uptime_seconds = float(f.read().split()[0])
14
+
15
+ return {
16
+ "hostname": platform.node(),
17
+ "os": f"{platform.system()} {platform.release()}",
18
+ "kernel": platform.version(),
19
+ "machine": platform.machine(),
20
+ "uptime_seconds": round(uptime_seconds),
21
+ "load_1": round(load1, 2),
22
+ "load_5": round(load5, 2),
23
+ "load_15": round(load15, 2),
24
+ "cpu_count": os.cpu_count(),
25
+ }
26
+
27
+
28
+ def disk_usage() -> list[dict]:
29
+ """Return per-filesystem usage (sizes in MB) parsed from ``df``."""
30
+ result = subprocess.run(
31
+ ["df", "-P", "-B1"],
32
+ capture_output=True,
33
+ text=True,
34
+ check=True,
35
+ )
36
+ rows = []
37
+ for line in result.stdout.strip().splitlines()[1:]:
38
+ parts = line.split()
39
+ if len(parts) < 6:
40
+ continue
41
+ size_bytes = int(parts[1])
42
+ used_bytes = int(parts[2])
43
+ avail_bytes = int(parts[3])
44
+ rows.append(
45
+ {
46
+ "filesystem": parts[0],
47
+ "size_mb": round(size_bytes / 1_048_576, 1),
48
+ "used_mb": round(used_bytes / 1_048_576, 1),
49
+ "available_mb": round(avail_bytes / 1_048_576, 1),
50
+ "use_pct": parts[4],
51
+ "mounted_on": parts[5],
52
+ }
53
+ )
54
+ return rows
55
+
56
+
57
+ def check_memory() -> dict:
58
+ """Return memory and swap usage (in MB) parsed from ``/proc/meminfo``."""
59
+ mem: dict[str, int] = {}
60
+ with open("/proc/meminfo") as f:
61
+ for line in f:
62
+ key, val_kb = line.split(":")
63
+ kb = int(val_kb.strip().split()[0])
64
+ mem[key.strip()] = kb
65
+
66
+ return {
67
+ "total_mb": round(mem.get("MemTotal", 0) / 1024, 1),
68
+ "used_mb": round((mem.get("MemTotal", 0) - mem.get("MemAvailable", 0)) / 1024, 1),
69
+ "free_mb": round(mem.get("MemFree", 0) / 1024, 1),
70
+ "available_mb": round(mem.get("MemAvailable", 0) / 1024, 1),
71
+ "cached_mb": round(mem.get("Cached", 0) / 1024, 1),
72
+ "swap_total_mb": round(mem.get("SwapTotal", 0) / 1024, 1),
73
+ "swap_used_mb": round((mem.get("SwapTotal", 0) - mem.get("SwapFree", 0)) / 1024, 1),
74
+ }
75
+
76
+
77
+ def list_processes(name_filter: str | None = None, limit: int = 20) -> list[dict]:
78
+ """Return running processes (from ``ps aux``) sorted by CPU, capped at ``limit``.
79
+
80
+ If ``name_filter`` is given, only processes whose command contains it
81
+ (case-insensitive) are included.
82
+ """
83
+ result = subprocess.run(
84
+ ["ps", "aux", "--no-headers"],
85
+ capture_output=True,
86
+ text=True,
87
+ check=True,
88
+ )
89
+ rows = []
90
+ for line in result.stdout.strip().splitlines():
91
+ parts = line.split(None, 10)
92
+ if len(parts) < 11:
93
+ continue
94
+ command = parts[10]
95
+ if name_filter and name_filter.lower() not in command.lower():
96
+ continue
97
+ rows.append(
98
+ {
99
+ "user": parts[0],
100
+ "pid": int(parts[1]),
101
+ "cpu_pct": float(parts[2]),
102
+ "mem_pct": float(parts[3]),
103
+ "vsz_kb": int(parts[4]),
104
+ "rss_kb": int(parts[5]),
105
+ "stat": parts[7],
106
+ "command": command,
107
+ }
108
+ )
109
+ rows.sort(key=lambda r: cast(float, r["cpu_pct"]), reverse=True)
110
+ return rows[:limit]
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-linux-core
3
+ Version: 0.1.0
4
+ Summary: Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)
5
+ Requires-Python: >=3.10
6
+ Provides-Extra: dev
7
+ Requires-Dist: mypy; extra == 'dev'
8
+ Requires-Dist: pytest>=8.0; extra == 'dev'
9
+ Requires-Dist: ruff; extra == 'dev'
@@ -0,0 +1,13 @@
1
+ runspec_linux_core/__init__.py,sha256=K_zW7iQDoVYcXE-AlAuA9eXzNAdJUK5zj6kFNwAmcJ8,1807
2
+ runspec_linux_core/containers.py,sha256=VlIa1Z9eCx-FFz0I9XMtqyDP92ew6ARXjf-yiJmWeGE,2746
3
+ runspec_linux_core/errors.py,sha256=v5XNWR5AAs-K5gmL0FOf-jnXp1XxTmIXbK0OrBlxde4,792
4
+ runspec_linux_core/files.py,sha256=TxY6czJwzcoiJXWnP03jW6T40AwhYr6hGAtjCH7U83A,1911
5
+ runspec_linux_core/logs.py,sha256=MTEBLo9BYNL7OosR6uF-yh3NqKnQlndUDH2-LlD2jqA,2018
6
+ runspec_linux_core/nc.py,sha256=8jgfXeXqL00QrCfPhOSMJYpN8YDaXRst6AUlP5FFh2k,1005
7
+ runspec_linux_core/network.py,sha256=VmcrIEPHiT4OPAUaJtCYXEqZlGp7L_Djg35TLpskaBk,4283
8
+ runspec_linux_core/security.py,sha256=ywFGjeLasVrF2UA_XvM3wjotNTXx00tYDmEzLiAltjc,1907
9
+ runspec_linux_core/services.py,sha256=IJZkWaIdoqer97BnQZ2x1FbrTanD6D884htGGESui-c,3009
10
+ runspec_linux_core/system.py,sha256=m8lcZFyr40hsUivjDxjySY-TedsNgcOT-h1tjt6Jya0,3642
11
+ runspec_linux_core-0.1.0.dist-info/METADATA,sha256=6wQs-dXtZSRnyzDV_wVPQbwxurdYG0St6wEuSLfbU2A,352
12
+ runspec_linux_core-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ runspec_linux_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any