runspec-linux-core 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/CHANGELOG.md +39 -0
  2. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/__init__.py +6 -2
  5. runspec_linux_core-0.8.0/runspec_linux_core/_mounts.py +172 -0
  6. runspec_linux_core-0.8.0/runspec_linux_core/files.py +107 -0
  7. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/network.py +76 -1
  8. runspec_linux_core-0.8.0/runspec_linux_core/system.py +203 -0
  9. runspec_linux_core-0.8.0/tests/test_files.py +105 -0
  10. runspec_linux_core-0.8.0/tests/test_inventory.py +226 -0
  11. runspec_linux_core-0.8.0/tests/test_mounts.py +112 -0
  12. runspec_linux_core-0.7.0/runspec_linux_core/files.py +0 -54
  13. runspec_linux_core-0.7.0/runspec_linux_core/system.py +0 -110
  14. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/.gitignore +0 -0
  15. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/_paths.py +0 -0
  16. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/become.py +0 -0
  17. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/commands.py +0 -0
  18. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/containers.py +0 -0
  19. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/cron.py +0 -0
  20. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/errors.py +0 -0
  21. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/investigate.py +0 -0
  22. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/logs.py +0 -0
  23. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/nc.py +0 -0
  24. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/packages.py +0 -0
  25. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/perf.py +0 -0
  26. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/power.py +0 -0
  27. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/security.py +0 -0
  28. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/services.py +0 -0
  29. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/sudoers.py +0 -0
  30. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/runspec_linux_core/venvs.py +0 -0
  31. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/__init__.py +0 -0
  32. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_become.py +0 -0
  33. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_commands.py +0 -0
  34. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_containers_runas.py +0 -0
  35. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_cron.py +0 -0
  36. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_investigate.py +0 -0
  37. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_logs_services_runas.py +0 -0
  38. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_nc_send.py +0 -0
  39. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_network.py +0 -0
  40. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_packages.py +0 -0
  41. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_paths.py +0 -0
  42. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_perf.py +0 -0
  43. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_power.py +0 -0
  44. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_preconditions.py +0 -0
  45. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_sudoers.py +0 -0
  46. {runspec_linux_core-0.7.0 → runspec_linux_core-0.8.0}/tests/test_venvs.py +0 -0
@@ -1,5 +1,44 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.8.0] — 2026-09-26
4
+
5
+ ### Added
6
+
7
+ - **`block_devices(include_loop=False)`** — the disk inventory behind the new
8
+ `block-devices` runnable. Runs `lsblk --json` and returns a tree: each disk
9
+ with its partitions, LVM volumes and crypt/RAID layers under `children`, with
10
+ size, `rotational` (the kernel's SSD-vs-spinning flag), transport, model,
11
+ serial, filesystem, label, UUID and mount points. Normalises both the typed JSON
12
+ of util-linux >= 2.33 and the all-strings output of older versions, and falls
13
+ back from the `MOUNTPOINTS` column (>= 2.37) to `MOUNTPOINT`. Loop devices are
14
+ hidden unless `include_loop`.
15
+ - **`network_interfaces(physical_only=False)`** — the NIC inventory behind the new
16
+ `network-interfaces` runnable, from `ip -json address` plus `/sys/class/net`:
17
+ state, MAC, MTU, bridge/bond `master`, and IPv4/IPv6 addresses (`dynamic` for
18
+ DHCP/SLAAC), plus link speed and driver for physical cards. `physical` marks an
19
+ interface backed by hardware; `physical_only` drops loopback, bridges, bonds,
20
+ VLANs, veth pairs and tunnels, which clutter a container or Kubernetes host.
21
+ - **`find_large_files_report(search_path, min_mb, limit, *, cross_mounts=False)`**
22
+ — `find_large_files` as a report: `{path, min_mb, files, match_count,
23
+ skipped_mounts?}`, where `match_count` counts matches before `limit`.
24
+
25
+ ### Changed
26
+
27
+ - **`find_large_files` stays on one filesystem.** It recursed across every mount
28
+ below `search_path`, so a scan of `/` walked `/proc` and ranked `/proc/kcore`
29
+ (a ~128 TiB virtual file) as the largest file on the host. It now behaves like
30
+ `find -xdev`. `cross_mounts=True` opts back into other mounted filesystems (a
31
+ separate disk, a network share), but kernel virtual filesystems (`proc`,
32
+ `sysfs`, `devtmpfs`, `cgroup2` …) are never scanned. They are recognised from
33
+ `/proc/self/mountinfo`, then `/proc/mounts`, then the `/proc` / `/sys` / `/dev`
34
+ paths. The guard is `runspec_linux_core._mounts`, a byte-for-byte twin of
35
+ `runspec_fs_core._mounts` (each core stays dependency-free) kept identical by a
36
+ parity test.
37
+ - **A bad `search_path` is an error.** A missing path raises `FileNotFoundError`
38
+ and a file raises `NotADirectoryError`; both used to return an empty list,
39
+ indistinguishable from "no large files". A path on a virtual filesystem raises
40
+ `ValueError`.
41
+
3
42
  ## [0.7.0] — 2026-09-25
4
43
 
5
44
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspec-linux-core
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux-core"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)"
10
10
  dependencies = []
@@ -31,7 +31,7 @@ from runspec_linux_core.cron import (
31
31
  validate_schedule,
32
32
  )
33
33
  from runspec_linux_core.errors import CommandError, LinuxCoreError, ToolNotFoundError
34
- from runspec_linux_core.files import backup_files, find_large_files
34
+ from runspec_linux_core.files import backup_files, find_large_files, find_large_files_report
35
35
  from runspec_linux_core.investigate import (
36
36
  failed_units,
37
37
  open_ports,
@@ -43,6 +43,7 @@ from runspec_linux_core.nc import nc_send
43
43
  from runspec_linux_core.network import (
44
44
  check_port,
45
45
  dns_config,
46
+ network_interfaces,
46
47
  ping_host,
47
48
  route_table,
48
49
  show_connections,
@@ -62,7 +63,7 @@ from runspec_linux_core.power import reboot_host
62
63
  from runspec_linux_core.security import last_logins, who
63
64
  from runspec_linux_core.services import check_service, list_services, restart_service
64
65
  from runspec_linux_core.sudoers import enable_passwordless_sudo
65
- from runspec_linux_core.system import check_memory, disk_usage, list_processes, system_info
66
+ from runspec_linux_core.system import block_devices, check_memory, disk_usage, list_processes, system_info
66
67
  from runspec_linux_core.venvs import configure_pip, create_venv, install_into_venv, share_venv_logs
67
68
 
68
69
  __all__ = [
@@ -80,6 +81,7 @@ __all__ = [
80
81
  # system
81
82
  "system_info",
82
83
  "disk_usage",
84
+ "block_devices",
83
85
  "check_memory",
84
86
  "list_processes",
85
87
  # services
@@ -98,6 +100,7 @@ __all__ = [
98
100
  "route_table",
99
101
  "socket_stats",
100
102
  "dns_config",
103
+ "network_interfaces",
101
104
  # perf tuning & analysis
102
105
  "get_sysctl",
103
106
  "set_sysctl",
@@ -110,6 +113,7 @@ __all__ = [
110
113
  "failed_units",
111
114
  # files
112
115
  "find_large_files",
116
+ "find_large_files_report",
113
117
  "backup_files",
114
118
  # security
115
119
  "last_logins",
@@ -0,0 +1,172 @@
1
+ """Mount-boundary guard for recursive disk scans.
2
+
3
+ Keeps a scan that walks a directory tree from wandering into other filesystems.
4
+ By default it stays on the filesystem it started on (the ``du -x`` /
5
+ ``find -xdev`` rule). Kernel virtual filesystems (``/proc``, ``/sys``, ``/dev``
6
+ …) are never entered, even when crossing mounts is allowed: they hold no data on
7
+ disk, and ``/proc/kcore`` alone reports a size of ~128 TiB.
8
+
9
+ Deliberately duplicated, byte for byte, in ``runspec-linux-core`` and
10
+ ``runspec-fs-core`` (each core stays dependency-free); a parity test in each
11
+ package keeps the two copies identical.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+
19
+ #: Kernel pseudo filesystems with no disk behind them — never scanned.
20
+ VIRTUAL_FSTYPES = frozenset(
21
+ {
22
+ "autofs",
23
+ "binfmt_misc",
24
+ "bpf",
25
+ "cgroup",
26
+ "cgroup2",
27
+ "configfs",
28
+ "debugfs",
29
+ "devpts",
30
+ "devtmpfs",
31
+ "efivarfs",
32
+ "fusectl",
33
+ "hugetlbfs",
34
+ "mqueue",
35
+ "nsfs",
36
+ "proc",
37
+ "pstore",
38
+ "rpc_pipefs",
39
+ "securityfs",
40
+ "selinuxfs",
41
+ "sysfs",
42
+ "tracefs",
43
+ }
44
+ )
45
+
46
+ #: The standard virtual trees and their usual type, used when the mount table
47
+ #: can't be read or doesn't list a path (POSIX only).
48
+ FALLBACK_VIRTUAL_PREFIXES = {"/proc": "proc", "/sys": "sysfs", "/dev": "devtmpfs"}
49
+
50
+ _OCTAL_ESCAPE = re.compile(r"\\([0-7]{3})")
51
+
52
+
53
+ def _unescape(field: str) -> str:
54
+ """Decode the octal escapes (``\\040`` for a space …) used in mount tables."""
55
+ return _OCTAL_ESCAPE.sub(lambda m: chr(int(m.group(1), 8)), field)
56
+
57
+
58
+ def parse_mountinfo(text: str) -> dict[str, str]:
59
+ """Map mount point → filesystem type from ``/proc/self/mountinfo`` text.
60
+
61
+ Each line is ``id parent major:minor root mount-point options [optional …]
62
+ - fstype source super-options``. When a mount point is listed more than once
63
+ the last line wins — it is the mount on top, the one a path resolves to.
64
+ """
65
+ types: dict[str, str] = {}
66
+ for line in text.splitlines():
67
+ left, sep, right = line.partition(" - ")
68
+ fields, tail = left.split(), right.split()
69
+ if sep and len(fields) >= 5 and tail:
70
+ types[_unescape(fields[4])] = tail[0]
71
+ return types
72
+
73
+
74
+ def parse_proc_mounts(text: str) -> dict[str, str]:
75
+ """Map mount point → filesystem type from ``/proc/mounts`` text
76
+ (``source mount-point fstype options dump pass``)."""
77
+ types: dict[str, str] = {}
78
+ for line in text.splitlines():
79
+ fields = line.split()
80
+ if len(fields) >= 3:
81
+ types[_unescape(fields[1])] = fields[2]
82
+ return types
83
+
84
+
85
+ def read_mount_types(
86
+ mountinfo: str = "/proc/self/mountinfo",
87
+ mounts: str = "/proc/mounts",
88
+ ) -> dict[str, str] | None:
89
+ """The live mount table as ``{mount point: fstype}``, or ``None`` when it
90
+ can't be read (not Linux, or a restricted ``/proc``)."""
91
+ for path, parse in ((mountinfo, parse_mountinfo), (mounts, parse_proc_mounts)):
92
+ try:
93
+ with open(path, encoding="utf-8", errors="surrogateescape") as f:
94
+ types = parse(f.read())
95
+ except OSError:
96
+ continue
97
+ if types:
98
+ return types
99
+ return None
100
+
101
+
102
+ class MountGuard:
103
+ """Decides which directories a recursive scan may enter.
104
+
105
+ With ``cross_mounts=False`` (the default) the scan stays on the filesystem it
106
+ started on. With ``True`` it may enter other mounted filesystems (a separate
107
+ ``/home`` disk, a network share), but never a virtual one. Each directory the
108
+ scan refuses to enter is counted in :attr:`skipped_mounts`.
109
+
110
+ Raises :class:`ValueError` when ``root`` itself is on a virtual filesystem —
111
+ there is nothing on disk there to measure. On Windows only the device
112
+ comparison applies; there is no mount table to consult.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ root: str | os.PathLike[str],
118
+ *,
119
+ cross_mounts: bool = False,
120
+ mount_types: dict[str, str] | None = None,
121
+ ) -> None:
122
+ self.cross_mounts = cross_mounts
123
+ self.skipped_mounts = 0
124
+ self._posix = os.name == "posix"
125
+ if mount_types is not None:
126
+ self._types: dict[str, str] | None = mount_types
127
+ else:
128
+ self._types = read_mount_types() if self._posix else None
129
+ fstype = self._virtual_type(os.fspath(root), containing=True)
130
+ if fstype:
131
+ raise ValueError(f"{os.fspath(root)} is on a virtual filesystem ({fstype}); it holds no files on disk to measure")
132
+
133
+ def enter(self, path: str, dev: int, parent_dev: int) -> bool:
134
+ """Whether the scan may descend into directory ``path`` (on device
135
+ ``dev``) from its parent directory (on device ``parent_dev``). A refusal
136
+ is counted in :attr:`skipped_mounts`."""
137
+ if dev == parent_dev:
138
+ return True
139
+ if self.cross_mounts and self._virtual_type(path, containing=False) is None:
140
+ return True
141
+ self.skipped_mounts += 1
142
+ return False
143
+
144
+ def _virtual_type(self, path: str, *, containing: bool) -> str | None:
145
+ """The virtual filesystem type at ``path`` — the mount point itself, or
146
+ with ``containing`` the mount the path lies in — or ``None`` if the
147
+ filesystem there is a real one."""
148
+ if not self._posix:
149
+ return None
150
+ real = os.path.realpath(path)
151
+ fstype = self._mount_type(real, containing=containing)
152
+ if fstype is not None:
153
+ return fstype if fstype in VIRTUAL_FSTYPES else None
154
+ for prefix, prefix_type in FALLBACK_VIRTUAL_PREFIXES.items():
155
+ if real == prefix or real.startswith(prefix + "/"):
156
+ return prefix_type
157
+ return None
158
+
159
+ def _mount_type(self, real: str, *, containing: bool) -> str | None:
160
+ """The table's fstype for mount point ``real``, or with ``containing``
161
+ for the longest mount point that ``real`` lies under."""
162
+ if not self._types:
163
+ return None
164
+ if not containing:
165
+ return self._types.get(real)
166
+ best: str | None = None
167
+ best_len = -1
168
+ for mount_point, fstype in self._types.items():
169
+ under = mount_point if mount_point.endswith("/") else mount_point + "/"
170
+ if (real == mount_point or real.startswith(under)) and len(mount_point) > best_len:
171
+ best, best_len = fstype, len(mount_point)
172
+ return best
@@ -0,0 +1,107 @@
1
+ """File helpers: find large files, create a timestamped tar.gz backup."""
2
+
3
+ import os
4
+ import stat
5
+ import tarfile
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from runspec_linux_core._mounts import MountGuard
10
+
11
+
12
+ def find_large_files_report(search_path: str, min_mb: float = 100.0, limit: int = 50, *, cross_mounts: bool = False) -> dict:
13
+ """Find files under ``search_path`` at least ``min_mb`` MB, biggest first.
14
+
15
+ The scan stays on the filesystem ``search_path`` is on, like
16
+ ``find -xdev``, unless ``cross_mounts`` is set. Kernel virtual filesystems
17
+ (``/proc``, ``/sys``, ``/dev`` …) are never scanned either way, so a scan of
18
+ ``/`` can't report ``/proc/kcore`` as a ~128 TiB file. Directories not
19
+ entered for either reason are counted in ``skipped_mounts`` (present only
20
+ when non-zero).
21
+
22
+ Returns ``{path, min_mb, files: [{path, size_mb, modified}], match_count,
23
+ skipped_mounts?}``, where ``match_count`` is the number of matching files
24
+ before ``limit`` is applied. Raises ``FileNotFoundError`` /
25
+ ``NotADirectoryError`` for a bad ``search_path`` and ``ValueError`` when it
26
+ is on a virtual filesystem.
27
+ """
28
+ if not os.path.isdir(search_path):
29
+ if not os.path.exists(search_path):
30
+ raise FileNotFoundError(f"no such directory: {search_path}")
31
+ raise NotADirectoryError(f"not a directory: {search_path}")
32
+ guard = MountGuard(search_path, cross_mounts=cross_mounts)
33
+ min_bytes = int(min_mb * 1_048_576)
34
+
35
+ results: list[dict] = []
36
+ for dirpath, dirnames, filenames in os.walk(search_path):
37
+ try:
38
+ dir_dev = os.stat(dirpath).st_dev
39
+ except OSError:
40
+ dirnames[:] = []
41
+ continue
42
+ admitted = []
43
+ for name in dirnames:
44
+ sub = os.path.join(dirpath, name)
45
+ try:
46
+ sub_stat = os.lstat(sub)
47
+ except OSError:
48
+ continue
49
+ if stat.S_ISLNK(sub_stat.st_mode):
50
+ continue # os.walk never follows a symlinked directory anyway
51
+ if guard.enter(sub, sub_stat.st_dev, dir_dev):
52
+ admitted.append(name)
53
+ dirnames[:] = admitted # prune in place: os.walk descends only into these
54
+
55
+ for name in filenames:
56
+ fpath = os.path.join(dirpath, name)
57
+ try:
58
+ file_stat = os.stat(fpath, follow_symlinks=False)
59
+ if file_stat.st_size >= min_bytes:
60
+ results.append(
61
+ {
62
+ "path": fpath,
63
+ "size_mb": round(file_stat.st_size / 1_048_576, 2),
64
+ "modified": datetime.fromtimestamp(file_stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
65
+ }
66
+ )
67
+ except OSError:
68
+ continue
69
+
70
+ results.sort(key=lambda r: r["size_mb"], reverse=True)
71
+ report: dict = {"path": search_path, "min_mb": min_mb, "files": results[:limit], "match_count": len(results)}
72
+ if guard.skipped_mounts:
73
+ report["skipped_mounts"] = guard.skipped_mounts
74
+ return report
75
+
76
+
77
+ def find_large_files(search_path: str, min_mb: float = 100.0, limit: int = 50, *, cross_mounts: bool = False) -> list[dict]:
78
+ """Return files under ``search_path`` at least ``min_mb`` MB, biggest first.
79
+
80
+ The ``files`` list of :func:`find_large_files_report`, with the same
81
+ filesystem boundary (one filesystem unless ``cross_mounts``; virtual
82
+ filesystems never) and the same errors for a bad ``search_path``.
83
+ """
84
+ return find_large_files_report(search_path, min_mb, limit, cross_mounts=cross_mounts)["files"]
85
+
86
+
87
+ def backup_files(source: str, destination: str) -> dict:
88
+ """Create a timestamped ``.tar.gz`` of ``source`` inside ``destination``.
89
+
90
+ Returns the archive path and its size.
91
+ """
92
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
93
+ source_name = Path(source).name or "backup"
94
+ archive_name = f"{source_name}_{timestamp}.tar.gz"
95
+ archive_path = os.path.join(destination, archive_name)
96
+
97
+ os.makedirs(destination, exist_ok=True)
98
+ with tarfile.open(archive_path, "w:gz") as tar:
99
+ tar.add(source, arcname=source_name)
100
+
101
+ size_bytes = os.path.getsize(archive_path)
102
+ return {
103
+ "source": source,
104
+ "destination": archive_path,
105
+ "size_bytes": size_bytes,
106
+ "size_mb": round(size_bytes / 1_048_576, 2),
107
+ }
@@ -2,13 +2,19 @@
2
2
  diagnostics (traceroute, route table, socket summary, DNS config) for tracing
3
3
  where an app's connectivity breaks."""
4
4
 
5
+ import json
6
+ import os
5
7
  import re
6
8
  import shutil
7
9
  import socket
8
10
  import subprocess
9
11
  import time
10
12
 
11
- from runspec_linux_core.errors import ToolNotFoundError
13
+ from runspec_linux_core._paths import which
14
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
15
+
16
+ #: Where the kernel exposes per-interface attributes (speed, driver, device link).
17
+ SYS_CLASS_NET = "/sys/class/net"
12
18
 
13
19
 
14
20
  def ping_host(host: str, count: int = 4) -> dict:
@@ -207,6 +213,75 @@ def route_table() -> dict:
207
213
  return {"routes": _parse_ip_route(result.stdout)}
208
214
 
209
215
 
216
+ def network_interfaces(physical_only: bool = False) -> dict:
217
+ """List network interfaces (``ip -json address``): state, MAC address, MTU,
218
+ bridge/bond ``master`` and IPv4/IPv6 addresses, plus link speed and driver
219
+ for physical cards (from ``/sys/class/net``).
220
+
221
+ ``physical`` is true for an interface backed by a hardware device; loopback,
222
+ bridges, bonds, VLANs, veth pairs and tunnels are virtual. ``physical_only``
223
+ drops the virtual ones — container and Kubernetes hosts carry many veth links.
224
+ An address has ``dynamic: true`` when it came from DHCP or SLAAC.
225
+
226
+ Raises ToolNotFoundError if ``ip`` is not installed, CommandError if it fails.
227
+ """
228
+ ip = which("ip")
229
+ if not ip:
230
+ raise ToolNotFoundError("ip not available (iproute2)")
231
+ result = subprocess.run([ip, "-json", "address", "show"], capture_output=True, text=True)
232
+ if result.returncode != 0:
233
+ raise CommandError(result.stderr.strip() or f"ip exited {result.returncode}")
234
+ try:
235
+ links = json.loads(result.stdout)
236
+ except json.JSONDecodeError as exc:
237
+ raise CommandError(f"ip printed output that isn't JSON (iproute2 may be too old for -json): {exc}") from exc
238
+ # Some iproute2 versions emit empty objects in the list; skip anything unnamed.
239
+ interfaces = [_interface_row(link) for link in links if link.get("ifname")]
240
+ if physical_only:
241
+ interfaces = [row for row in interfaces if row["physical"]]
242
+ return {"interfaces": interfaces}
243
+
244
+
245
+ def _sysfs_attr(name: str, attr: str) -> str | None:
246
+ try:
247
+ with open(os.path.join(SYS_CLASS_NET, name, attr)) as f:
248
+ return f.read().strip()
249
+ except OSError:
250
+ return None # absent, or unreadable (speed raises EINVAL on a down link)
251
+
252
+
253
+ def _interface_row(link: dict) -> dict:
254
+ name = link["ifname"]
255
+ physical = os.path.exists(os.path.join(SYS_CLASS_NET, name, "device"))
256
+ addresses = []
257
+ for addr in link.get("addr_info", []):
258
+ if not addr.get("local"):
259
+ continue
260
+ entry = {"family": addr.get("family"), "address": addr["local"], "prefix": addr.get("prefixlen"), "scope": addr.get("scope")}
261
+ if addr.get("dynamic"):
262
+ entry["dynamic"] = True
263
+ addresses.append(entry)
264
+ row: dict = {
265
+ "name": name,
266
+ "state": link.get("operstate"),
267
+ "physical": physical,
268
+ "type": link.get("link_type"),
269
+ "mac": link.get("address") if link.get("link_type") == "ether" else None,
270
+ "mtu": link.get("mtu"),
271
+ "master": link.get("master"),
272
+ "addresses": addresses,
273
+ }
274
+ if physical:
275
+ speed = _sysfs_attr(name, "speed")
276
+ mbps = int(speed) if speed and speed.lstrip("-").isdigit() else -1
277
+ row["speed_mbps"] = mbps if mbps > 0 else None # the kernel reports -1 with no link
278
+ try:
279
+ row["driver"] = os.path.basename(os.readlink(os.path.join(SYS_CLASS_NET, name, "device", "driver")))
280
+ except OSError:
281
+ row["driver"] = None
282
+ return {key: value for key, value in row.items() if value is not None}
283
+
284
+
210
285
  def _parse_ss_summary(output: str) -> dict:
211
286
  """Parse ``ss -s`` summary output into ``{total, tcp: {...}, ...}``."""
212
287
  summary: dict = {}