devaudit-cli 0.3.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.
- dev_audit/__init__.py +0 -0
- dev_audit/__main__.py +4 -0
- dev_audit/audit.py +51 -0
- dev_audit/cli.py +110 -0
- dev_audit/collectors/__init__.py +0 -0
- dev_audit/collectors/devtools.py +113 -0
- dev_audit/collectors/network.py +172 -0
- dev_audit/collectors/resources.py +154 -0
- dev_audit/collectors/system.py +72 -0
- dev_audit/command.py +51 -0
- dev_audit/output.py +273 -0
- dev_audit/status.py +30 -0
- devaudit_cli-0.3.0.dist-info/METADATA +347 -0
- devaudit_cli-0.3.0.dist-info/RECORD +18 -0
- devaudit_cli-0.3.0.dist-info/WHEEL +5 -0
- devaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
- devaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- devaudit_cli-0.3.0.dist-info/top_level.txt +1 -0
dev_audit/__init__.py
ADDED
|
File without changes
|
dev_audit/__main__.py
ADDED
dev_audit/audit.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def check_devtools(results: dict) -> list[str]:
|
|
5
|
+
findings: list[str] = []
|
|
6
|
+
|
|
7
|
+
devtools = results.get("devtools", {})
|
|
8
|
+
warnings = devtools.get("warnings", [])
|
|
9
|
+
|
|
10
|
+
findings.extend(warnings)
|
|
11
|
+
|
|
12
|
+
return findings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_network(results: dict) -> list[str]:
|
|
16
|
+
findings: list[str] = []
|
|
17
|
+
|
|
18
|
+
network = results.get("network", {})
|
|
19
|
+
data = network.get("data", {})
|
|
20
|
+
|
|
21
|
+
ports = data.get("listening_ports", [])
|
|
22
|
+
|
|
23
|
+
for port in ports:
|
|
24
|
+
protocol = port.get("protocol")
|
|
25
|
+
address = port.get("local_address", "")
|
|
26
|
+
|
|
27
|
+
if protocol == "tcp" and address.startswith("*:22"):
|
|
28
|
+
findings.append(
|
|
29
|
+
"SSH is listening on all interfaces."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if protocol == "tcp" and address.startswith("0.0.0.0:80"):
|
|
33
|
+
findings.append(
|
|
34
|
+
"HTTP service exposed on all IPv4 interfaces."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if protocol == "tcp" and address.startswith("[::]:80"):
|
|
38
|
+
findings.append(
|
|
39
|
+
"HTTP service exposed on all IPv6 interfaces."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return findings
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_audit(results: dict) -> list[str]:
|
|
46
|
+
findings: list[str] = []
|
|
47
|
+
|
|
48
|
+
findings.extend(check_devtools(results))
|
|
49
|
+
findings.extend(check_network(results))
|
|
50
|
+
|
|
51
|
+
return findings
|
dev_audit/cli.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from dev_audit.collectors.devtools import collect_devtools_info
|
|
4
|
+
from dev_audit.collectors.network import collect_network_info
|
|
5
|
+
from dev_audit.collectors.resources import collect_resource_info
|
|
6
|
+
from dev_audit.collectors.system import collect_system_info
|
|
7
|
+
from dev_audit.output import (
|
|
8
|
+
print_devtools,
|
|
9
|
+
print_findings,
|
|
10
|
+
print_json,
|
|
11
|
+
print_network,
|
|
12
|
+
print_resources,
|
|
13
|
+
print_system,
|
|
14
|
+
)
|
|
15
|
+
from dev_audit.audit import run_audit
|
|
16
|
+
from dev_audit.status import determine_exit_code
|
|
17
|
+
|
|
18
|
+
VALID_SECTIONS = ("system", "resources", "network", "devtools")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="dev-audit",
|
|
24
|
+
description="Inspect Linux system health and developer environment setup.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"section",
|
|
29
|
+
nargs="?",
|
|
30
|
+
choices=VALID_SECTIONS,
|
|
31
|
+
help="Audit section to run. If omitted, all sections run.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--json",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Output results as JSON.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--verbose",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Show detailed diagnostic information.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_full_audit(
|
|
50
|
+
json_output: bool = False,
|
|
51
|
+
verbose: bool = False,
|
|
52
|
+
) -> int:
|
|
53
|
+
results = {
|
|
54
|
+
"system": collect_system_info(),
|
|
55
|
+
"resources": collect_resource_info(),
|
|
56
|
+
"network": collect_network_info(),
|
|
57
|
+
"devtools": collect_devtools_info(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
findings = run_audit(results)
|
|
61
|
+
|
|
62
|
+
if json_output:
|
|
63
|
+
results["findings"] = findings
|
|
64
|
+
print_json(results)
|
|
65
|
+
return determine_exit_code(results, findings)
|
|
66
|
+
|
|
67
|
+
print_system(results["system"], verbose=verbose)
|
|
68
|
+
print()
|
|
69
|
+
|
|
70
|
+
print_resources(results["resources"], verbose=verbose)
|
|
71
|
+
print()
|
|
72
|
+
|
|
73
|
+
print_network(results["network"], verbose=verbose)
|
|
74
|
+
print()
|
|
75
|
+
|
|
76
|
+
print_devtools(results["devtools"], verbose=verbose)
|
|
77
|
+
|
|
78
|
+
print_findings(findings)
|
|
79
|
+
return determine_exit_code(results, findings)
|
|
80
|
+
|
|
81
|
+
def main() -> int:
|
|
82
|
+
parser = build_parser()
|
|
83
|
+
args = parser.parse_args()
|
|
84
|
+
|
|
85
|
+
if args.section is None:
|
|
86
|
+
return run_full_audit(
|
|
87
|
+
json_output=args.json,
|
|
88
|
+
verbose=args.verbose,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if args.section == "system":
|
|
93
|
+
result = collect_system_info()
|
|
94
|
+
print_json(result) if args.json else print_system(result, verbose=args.verbose)
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
if args.section == "resources":
|
|
98
|
+
result = collect_resource_info()
|
|
99
|
+
print_json(result) if args.json else print_resources(result, verbose=args.verbose)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
if args.section == "network":
|
|
103
|
+
result = collect_network_info()
|
|
104
|
+
print_json(result) if args.json else print_network(result, verbose=args.verbose)
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
if args.section == "devtools":
|
|
108
|
+
result = collect_devtools_info()
|
|
109
|
+
print_json(result) if args.json else print_devtools(result, verbose=args.verbose)
|
|
110
|
+
return 0
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from dev_audit.command import run_command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def inspect_tool(
|
|
9
|
+
name: str,
|
|
10
|
+
executable: str,
|
|
11
|
+
version_command: list[str],
|
|
12
|
+
) -> dict[str, object]:
|
|
13
|
+
path = shutil.which(executable)
|
|
14
|
+
|
|
15
|
+
if path is None:
|
|
16
|
+
return {
|
|
17
|
+
"name": name,
|
|
18
|
+
"installed": False,
|
|
19
|
+
"version": None,
|
|
20
|
+
"path": None,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
version_output = run_command(version_command)
|
|
24
|
+
|
|
25
|
+
version = None
|
|
26
|
+
|
|
27
|
+
if version_output["ok"]:
|
|
28
|
+
version = (
|
|
29
|
+
version_output.get("stdout")
|
|
30
|
+
or version_output.get("stderr")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if version:
|
|
34
|
+
version = version.strip()
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"name": name,
|
|
38
|
+
"installed": True,
|
|
39
|
+
"version": version,
|
|
40
|
+
"path": path,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def collect_devtools_info() -> dict[str, object]:
|
|
45
|
+
tools = [
|
|
46
|
+
inspect_tool(
|
|
47
|
+
name="Python",
|
|
48
|
+
executable="python3",
|
|
49
|
+
version_command=["python3", "--version"],
|
|
50
|
+
),
|
|
51
|
+
inspect_tool(
|
|
52
|
+
name="pip",
|
|
53
|
+
executable="pip",
|
|
54
|
+
version_command=["pip", "--version"],
|
|
55
|
+
),
|
|
56
|
+
inspect_tool(
|
|
57
|
+
name="Git",
|
|
58
|
+
executable="git",
|
|
59
|
+
version_command=["git", "--version"],
|
|
60
|
+
),
|
|
61
|
+
inspect_tool(
|
|
62
|
+
name="Docker",
|
|
63
|
+
executable="docker",
|
|
64
|
+
version_command=["docker", "--version"],
|
|
65
|
+
),
|
|
66
|
+
inspect_tool(
|
|
67
|
+
name="Docker Compose",
|
|
68
|
+
executable="docker",
|
|
69
|
+
version_command=["docker", "compose", "version"],
|
|
70
|
+
),
|
|
71
|
+
inspect_tool(
|
|
72
|
+
name="Node.js",
|
|
73
|
+
executable="node",
|
|
74
|
+
version_command=["node", "--version"],
|
|
75
|
+
),
|
|
76
|
+
inspect_tool(
|
|
77
|
+
name="npm",
|
|
78
|
+
executable="npm",
|
|
79
|
+
version_command=["npm", "--version"],
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
warnings: list[str] = []
|
|
84
|
+
|
|
85
|
+
node_installed = False
|
|
86
|
+
npm_installed = False
|
|
87
|
+
|
|
88
|
+
for tool in tools:
|
|
89
|
+
if tool["name"] == "Node.js":
|
|
90
|
+
node_installed = bool(tool["installed"])
|
|
91
|
+
|
|
92
|
+
if tool["name"] == "npm":
|
|
93
|
+
npm_installed = bool(tool["installed"])
|
|
94
|
+
|
|
95
|
+
path = tool.get("path")
|
|
96
|
+
|
|
97
|
+
if isinstance(path, str) and path.startswith("/mnt/c/"):
|
|
98
|
+
warnings.append(
|
|
99
|
+
"npm is being resolved from a Windows installation."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if npm_installed and not node_installed:
|
|
103
|
+
warnings.append(
|
|
104
|
+
"npm detected but Node.js executable not found."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"success": True,
|
|
109
|
+
"data": {
|
|
110
|
+
"tools": tools,
|
|
111
|
+
},
|
|
112
|
+
"warnings": warnings,
|
|
113
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dev_audit.command import command_exists, run_command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_dns_servers() -> list[str]:
|
|
9
|
+
path = Path("/etc/resolv.conf")
|
|
10
|
+
servers: list[str] = []
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
for line in path.read_text().splitlines():
|
|
14
|
+
line = line.strip()
|
|
15
|
+
|
|
16
|
+
if line.startswith("nameserver"):
|
|
17
|
+
parts = line.split()
|
|
18
|
+
if len(parts) >= 2:
|
|
19
|
+
servers.append(parts[1])
|
|
20
|
+
|
|
21
|
+
except OSError:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
return servers
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_interfaces(ip_addr_output: str) -> list[dict[str, str]]:
|
|
28
|
+
interfaces: list[dict[str, str]] = []
|
|
29
|
+
current_interface: dict[str, str] | None = None
|
|
30
|
+
|
|
31
|
+
for line in ip_addr_output.splitlines():
|
|
32
|
+
line = line.strip()
|
|
33
|
+
|
|
34
|
+
if not line:
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
if ": " in line and line[0].isdigit():
|
|
38
|
+
parts = line.split(":", 2)
|
|
39
|
+
|
|
40
|
+
if len(parts) < 2:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
name = parts[1].strip()
|
|
44
|
+
|
|
45
|
+
current_interface = {
|
|
46
|
+
"name": name,
|
|
47
|
+
"ipv4": "",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interfaces.append(current_interface)
|
|
51
|
+
|
|
52
|
+
elif line.startswith("inet ") and current_interface is not None:
|
|
53
|
+
ip = line.split()[1].split("/")[0]
|
|
54
|
+
current_interface["ipv4"] = ip
|
|
55
|
+
|
|
56
|
+
return interfaces
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_default_route(route_output: str) -> dict[str, str] | None:
|
|
60
|
+
for line in route_output.splitlines():
|
|
61
|
+
if not line.startswith("default"):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
parts = line.split()
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
gateway = parts[2]
|
|
68
|
+
interface = parts[4]
|
|
69
|
+
except IndexError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"gateway": gateway,
|
|
74
|
+
"interface": interface,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_listening_ports(ss_output: str) -> list[dict[str, str]]:
|
|
81
|
+
ports: list[dict[str, str]] = []
|
|
82
|
+
|
|
83
|
+
for line in ss_output.splitlines():
|
|
84
|
+
line = line.strip()
|
|
85
|
+
|
|
86
|
+
if not line:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if line.startswith("Netid"):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
parts = line.split()
|
|
93
|
+
|
|
94
|
+
if len(parts) < 5:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
ports.append(
|
|
98
|
+
{
|
|
99
|
+
"protocol": parts[0],
|
|
100
|
+
"local_address": parts[4],
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return ports
|
|
105
|
+
|
|
106
|
+
def collect_network_info() -> dict[str, object]:
|
|
107
|
+
warnings: list[str] = []
|
|
108
|
+
|
|
109
|
+
ip_addr = None
|
|
110
|
+
ip_route = None
|
|
111
|
+
listening_ports = None
|
|
112
|
+
|
|
113
|
+
if not command_exists("ip"):
|
|
114
|
+
warnings.append("Missing required command: ip")
|
|
115
|
+
else:
|
|
116
|
+
ip_addr = run_command(["ip", "addr"])
|
|
117
|
+
ip_route = run_command(["ip", "route"])
|
|
118
|
+
|
|
119
|
+
if not ip_addr["ok"]:
|
|
120
|
+
warnings.append("Failed to run: ip addr")
|
|
121
|
+
|
|
122
|
+
if not ip_route["ok"]:
|
|
123
|
+
warnings.append("Failed to run: ip route")
|
|
124
|
+
|
|
125
|
+
if not command_exists("ss"):
|
|
126
|
+
warnings.append("Missing required command: ss")
|
|
127
|
+
else:
|
|
128
|
+
listening_ports = run_command(["ss", "-tuln"])
|
|
129
|
+
|
|
130
|
+
if not listening_ports["ok"]:
|
|
131
|
+
warnings.append("Failed to run: ss -tuln")
|
|
132
|
+
|
|
133
|
+
dns_servers = read_dns_servers()
|
|
134
|
+
|
|
135
|
+
if not dns_servers:
|
|
136
|
+
warnings.append("Could not read DNS servers from /etc/resolv.conf")
|
|
137
|
+
|
|
138
|
+
interfaces = (
|
|
139
|
+
parse_interfaces(ip_addr["stdout"])
|
|
140
|
+
if ip_addr and ip_addr["stdout"]
|
|
141
|
+
else []
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
default_route = (
|
|
145
|
+
parse_default_route(ip_route["stdout"])
|
|
146
|
+
if ip_route and ip_route["stdout"]
|
|
147
|
+
else None
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
parsed_ports = (
|
|
151
|
+
parse_listening_ports(listening_ports["stdout"])
|
|
152
|
+
if listening_ports and listening_ports["stdout"]
|
|
153
|
+
else []
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
data = {
|
|
157
|
+
"interfaces": interfaces,
|
|
158
|
+
"default_route": default_route,
|
|
159
|
+
"dns_servers": dns_servers,
|
|
160
|
+
"listening_ports": parsed_ports,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"success": bool(
|
|
165
|
+
interfaces
|
|
166
|
+
or default_route
|
|
167
|
+
or dns_servers
|
|
168
|
+
or parsed_ports
|
|
169
|
+
),
|
|
170
|
+
"data": data,
|
|
171
|
+
"warnings": warnings,
|
|
172
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_load_average() -> dict[str, float] | None:
|
|
9
|
+
path = Path("/proc/loadavg")
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
values = path.read_text().split()
|
|
13
|
+
return {
|
|
14
|
+
"1_min": float(values[0]),
|
|
15
|
+
"5_min": float(values[1]),
|
|
16
|
+
"15_min": float(values[2]),
|
|
17
|
+
}
|
|
18
|
+
except (OSError, IndexError, ValueError):
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_meminfo() -> dict[str, int]:
|
|
23
|
+
path = Path("/proc/meminfo")
|
|
24
|
+
data: dict[str, int] = {}
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
for line in path.read_text().splitlines():
|
|
28
|
+
key, value = line.split(":", 1)
|
|
29
|
+
parts = value.strip().split()
|
|
30
|
+
|
|
31
|
+
if not parts:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
data[key] = int(parts[0]) * 1024
|
|
35
|
+
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bytes_to_gib(value: int | None) -> float | None:
|
|
43
|
+
if value is None:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
return round(value / (1024**3), 2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_memory_usage(meminfo: dict[str, int]) -> dict[str, float | None]:
|
|
50
|
+
total = meminfo.get("MemTotal")
|
|
51
|
+
available = meminfo.get("MemAvailable")
|
|
52
|
+
|
|
53
|
+
used = None
|
|
54
|
+
percent = None
|
|
55
|
+
|
|
56
|
+
if total is not None and available is not None:
|
|
57
|
+
used = total - available
|
|
58
|
+
percent = round((used / total) * 100, 1)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"total_gib": bytes_to_gib(total),
|
|
62
|
+
"available_gib": bytes_to_gib(available),
|
|
63
|
+
"used_gib": bytes_to_gib(used),
|
|
64
|
+
"used_percent": percent,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_swap_usage(meminfo: dict[str, int]) -> dict[str, float | None]:
|
|
69
|
+
total = meminfo.get("SwapTotal")
|
|
70
|
+
free = meminfo.get("SwapFree")
|
|
71
|
+
|
|
72
|
+
used = None
|
|
73
|
+
percent = None
|
|
74
|
+
|
|
75
|
+
if total is not None and free is not None:
|
|
76
|
+
used = total - free
|
|
77
|
+
percent = round((used / total) * 100, 1) if total else 0.0
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"total_gib": bytes_to_gib(total),
|
|
81
|
+
"free_gib": bytes_to_gib(free),
|
|
82
|
+
"used_gib": bytes_to_gib(used),
|
|
83
|
+
"used_percent": percent,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_root_disk_usage() -> dict[str, float]:
|
|
88
|
+
usage = shutil.disk_usage("/")
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"total_gib": bytes_to_gib(usage.total),
|
|
92
|
+
"used_gib": bytes_to_gib(usage.used),
|
|
93
|
+
"free_gib": bytes_to_gib(usage.free),
|
|
94
|
+
"used_percent": round((usage.used / usage.total) * 100, 1),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_mounts() -> list[dict[str, str]]:
|
|
99
|
+
path = Path("/proc/mounts")
|
|
100
|
+
mounts: list[dict[str, str]] = []
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
for line in path.read_text().splitlines():
|
|
104
|
+
parts = line.split()
|
|
105
|
+
|
|
106
|
+
if len(parts) < 3:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
device, mount_point, filesystem = parts[:3]
|
|
110
|
+
|
|
111
|
+
mounts.append(
|
|
112
|
+
{
|
|
113
|
+
"device": device,
|
|
114
|
+
"mount_point": mount_point,
|
|
115
|
+
"filesystem": filesystem,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
except OSError:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
return mounts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def collect_resource_info() -> dict[str, object]:
|
|
126
|
+
warnings: list[str] = []
|
|
127
|
+
|
|
128
|
+
load_average = read_load_average()
|
|
129
|
+
meminfo = read_meminfo()
|
|
130
|
+
mounts = read_mounts()
|
|
131
|
+
|
|
132
|
+
if load_average is None:
|
|
133
|
+
warnings.append("Could not read /proc/loadavg")
|
|
134
|
+
|
|
135
|
+
if not meminfo:
|
|
136
|
+
warnings.append("Could not read /proc/meminfo")
|
|
137
|
+
|
|
138
|
+
if not mounts:
|
|
139
|
+
warnings.append("Could not read /proc/mounts")
|
|
140
|
+
|
|
141
|
+
data = {
|
|
142
|
+
"cpu_count": os.cpu_count(),
|
|
143
|
+
"load_average": load_average,
|
|
144
|
+
"memory": get_memory_usage(meminfo),
|
|
145
|
+
"swap": get_swap_usage(meminfo),
|
|
146
|
+
"root_disk": get_root_disk_usage(),
|
|
147
|
+
"mounts": mounts,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"success": bool(meminfo) and load_average is not None,
|
|
152
|
+
"data": data,
|
|
153
|
+
"warnings": warnings,
|
|
154
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_os_release() -> dict[str, str]:
|
|
11
|
+
os_release_path = Path("/etc/os-release")
|
|
12
|
+
|
|
13
|
+
if not os_release_path.exists():
|
|
14
|
+
return {}
|
|
15
|
+
|
|
16
|
+
data: dict[str, str] = {}
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
for line in os_release_path.read_text().splitlines():
|
|
20
|
+
if "=" not in line:
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
key, value = line.split("=", 1)
|
|
24
|
+
data[key] = value.strip().strip('"')
|
|
25
|
+
|
|
26
|
+
except OSError:
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_uptime_seconds() -> float | None:
|
|
33
|
+
uptime_path = Path("/proc/uptime")
|
|
34
|
+
|
|
35
|
+
if not uptime_path.exists():
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
uptime_text = uptime_path.read_text().split()[0]
|
|
40
|
+
return float(uptime_text)
|
|
41
|
+
except (IndexError, ValueError, OSError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_system_info() -> dict[str, object]:
|
|
46
|
+
warnings: list[str] = []
|
|
47
|
+
|
|
48
|
+
os_release = read_os_release()
|
|
49
|
+
uptime_seconds = read_uptime_seconds()
|
|
50
|
+
|
|
51
|
+
if not os_release:
|
|
52
|
+
warnings.append("Could not read /etc/os-release")
|
|
53
|
+
|
|
54
|
+
if uptime_seconds is None:
|
|
55
|
+
warnings.append("Could not read /proc/uptime")
|
|
56
|
+
|
|
57
|
+
data = {
|
|
58
|
+
"hostname": socket.gethostname(),
|
|
59
|
+
"os_name": os_release.get("NAME", platform.system()),
|
|
60
|
+
"os_version": os_release.get("VERSION", "unknown"),
|
|
61
|
+
"kernel": platform.release(),
|
|
62
|
+
"architecture": platform.machine(),
|
|
63
|
+
"user": getpass.getuser(),
|
|
64
|
+
"shell": os.environ.get("SHELL", "unknown"),
|
|
65
|
+
"uptime_seconds": uptime_seconds,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"success": uptime_seconds is not None and bool(os_release),
|
|
70
|
+
"data": data,
|
|
71
|
+
"warnings": warnings,
|
|
72
|
+
}
|