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.
- runspec_linux_core/__init__.py +60 -0
- runspec_linux_core/containers.py +88 -0
- runspec_linux_core/errors.py +20 -0
- runspec_linux_core/files.py +54 -0
- runspec_linux_core/logs.py +57 -0
- runspec_linux_core/nc.py +36 -0
- runspec_linux_core/network.py +130 -0
- runspec_linux_core/security.py +63 -0
- runspec_linux_core/services.py +95 -0
- runspec_linux_core/system.py +110 -0
- runspec_linux_core-0.1.0.dist-info/METADATA +9 -0
- runspec_linux_core-0.1.0.dist-info/RECORD +13 -0
- runspec_linux_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)}
|
runspec_linux_core/nc.py
ADDED
|
@@ -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,,
|