runspec-linux-core 0.7.0__tar.gz → 0.9.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.

Potentially problematic release.


This version of runspec-linux-core might be problematic. Click here for more details.

Files changed (46) hide show
  1. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/CHANGELOG.md +58 -0
  2. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/__init__.py +6 -2
  5. runspec_linux_core-0.9.0/runspec_linux_core/_mounts.py +172 -0
  6. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/containers.py +21 -5
  7. runspec_linux_core-0.9.0/runspec_linux_core/files.py +107 -0
  8. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/investigate.py +30 -1
  9. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/network.py +76 -1
  10. runspec_linux_core-0.9.0/runspec_linux_core/system.py +203 -0
  11. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_containers_runas.py +36 -0
  12. runspec_linux_core-0.9.0/tests/test_files.py +105 -0
  13. runspec_linux_core-0.9.0/tests/test_inventory.py +226 -0
  14. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_investigate.py +37 -0
  15. runspec_linux_core-0.9.0/tests/test_mounts.py +112 -0
  16. runspec_linux_core-0.7.0/runspec_linux_core/files.py +0 -54
  17. runspec_linux_core-0.7.0/runspec_linux_core/system.py +0 -110
  18. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/.gitignore +0 -0
  19. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/_paths.py +0 -0
  20. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/become.py +0 -0
  21. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/commands.py +0 -0
  22. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/cron.py +0 -0
  23. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/errors.py +0 -0
  24. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/logs.py +0 -0
  25. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/nc.py +0 -0
  26. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/packages.py +0 -0
  27. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/perf.py +0 -0
  28. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/power.py +0 -0
  29. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/security.py +0 -0
  30. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/services.py +0 -0
  31. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/sudoers.py +0 -0
  32. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/venvs.py +0 -0
  33. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/__init__.py +0 -0
  34. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_become.py +0 -0
  35. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_commands.py +0 -0
  36. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_cron.py +0 -0
  37. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_logs_services_runas.py +0 -0
  38. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_nc_send.py +0 -0
  39. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_network.py +0 -0
  40. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_packages.py +0 -0
  41. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_paths.py +0 -0
  42. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_perf.py +0 -0
  43. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_power.py +0 -0
  44. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_preconditions.py +0 -0
  45. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_sudoers.py +0 -0
  46. {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_venvs.py +0 -0
@@ -1,5 +1,63 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.9.0] — 2026-09-26
4
+
5
+ ### Added
6
+
7
+ - **`open_ports` flags owners it couldn't see.** `ss -p` only reports other
8
+ users' processes to root, so an unprivileged probe returned most listeners
9
+ with an empty `processes` list, which is easy to misread as "nothing owns this
10
+ port". When the probe didn't run as root and any owner is missing, the
11
+ result now carries a `hint` ("N of M listeners have no owning process …
12
+ Re-run with --sudo"). No hint as root, via `run_as="root"`, or when every
13
+ owner is known.
14
+ - **Docker socket permission errors name the fix.** `list_containers`,
15
+ `container_logs` and `restart_container` turn an unescalated "permission
16
+ denied … docker.sock" into a `CommandError` that says to re-run with
17
+ `--sudo`, use `--run-as` with a docker-group user, or add the user to the
18
+ docker group. `container_logs` now raises on that error instead of returning
19
+ the daemon's refusal as log lines. Escalated failures keep the existing
20
+ passwordless-sudo message.
21
+
22
+ ## [0.8.0] — 2026-09-26
23
+
24
+ ### Added
25
+
26
+ - **`block_devices(include_loop=False)`** — the disk inventory behind the new
27
+ `block-devices` runnable. Runs `lsblk --json` and returns a tree: each disk
28
+ with its partitions, LVM volumes and crypt/RAID layers under `children`, with
29
+ size, `rotational` (the kernel's SSD-vs-spinning flag), transport, model,
30
+ serial, filesystem, label, UUID and mount points. Normalises both the typed JSON
31
+ of util-linux >= 2.33 and the all-strings output of older versions, and falls
32
+ back from the `MOUNTPOINTS` column (>= 2.37) to `MOUNTPOINT`. Loop devices are
33
+ hidden unless `include_loop`.
34
+ - **`network_interfaces(physical_only=False)`** — the NIC inventory behind the new
35
+ `network-interfaces` runnable, from `ip -json address` plus `/sys/class/net`:
36
+ state, MAC, MTU, bridge/bond `master`, and IPv4/IPv6 addresses (`dynamic` for
37
+ DHCP/SLAAC), plus link speed and driver for physical cards. `physical` marks an
38
+ interface backed by hardware; `physical_only` drops loopback, bridges, bonds,
39
+ VLANs, veth pairs and tunnels, which clutter a container or Kubernetes host.
40
+ - **`find_large_files_report(search_path, min_mb, limit, *, cross_mounts=False)`**
41
+ — `find_large_files` as a report: `{path, min_mb, files, match_count,
42
+ skipped_mounts?}`, where `match_count` counts matches before `limit`.
43
+
44
+ ### Changed
45
+
46
+ - **`find_large_files` stays on one filesystem.** It recursed across every mount
47
+ below `search_path`, so a scan of `/` walked `/proc` and ranked `/proc/kcore`
48
+ (a ~128 TiB virtual file) as the largest file on the host. It now behaves like
49
+ `find -xdev`. `cross_mounts=True` opts back into other mounted filesystems (a
50
+ separate disk, a network share), but kernel virtual filesystems (`proc`,
51
+ `sysfs`, `devtmpfs`, `cgroup2` …) are never scanned. They are recognised from
52
+ `/proc/self/mountinfo`, then `/proc/mounts`, then the `/proc` / `/sys` / `/dev`
53
+ paths. The guard is `runspec_linux_core._mounts`, a byte-for-byte twin of
54
+ `runspec_fs_core._mounts` (each core stays dependency-free) kept identical by a
55
+ parity test.
56
+ - **A bad `search_path` is an error.** A missing path raises `FileNotFoundError`
57
+ and a file raises `NotADirectoryError`; both used to return an empty list,
58
+ indistinguishable from "no large files". A path on a virtual filesystem raises
59
+ `ValueError`.
60
+
3
61
  ## [0.7.0] — 2026-09-25
4
62
 
5
63
  ### 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.9.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.9.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
@@ -12,13 +12,28 @@ import shutil
12
12
  import subprocess
13
13
 
14
14
  from runspec_linux_core.become import build_become, check_become_error
15
- from runspec_linux_core.errors import ToolNotFoundError
15
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
16
16
 
17
17
 
18
18
  def _docker_available() -> bool:
19
19
  return shutil.which("docker") is not None
20
20
 
21
21
 
22
+ def _check_docker_error(what: str, stderr: str, run_as: str | None) -> None:
23
+ """Raise :class:`CommandError` for a failed ``docker`` command.
24
+
25
+ An unescalated call refused by the daemon socket (root-owned, or limited to
26
+ the ``docker`` group) gets an actionable message naming the fix, so the
27
+ caller retries escalated instead of reporting "no containers". Anything else
28
+ goes through :func:`check_become_error` (passwordless-sudo hint when escalated).
29
+ """
30
+ err = stderr.strip()
31
+ low = err.lower()
32
+ if not run_as and "permission denied" in low and "docker" in low:
33
+ raise CommandError(f"{what}: {err} — this user can't reach the Docker socket. Re-run with --sudo (or --run-as a user in the docker group), or add this user to the docker group.")
34
+ check_become_error(what, stderr, run_as)
35
+
36
+
22
37
  def list_containers(include_all: bool = False, *, run_as: str | None = None, become_method: str = "sudo") -> list[dict]:
23
38
  """Return Docker containers (running, or all if ``include_all``).
24
39
 
@@ -40,7 +55,7 @@ def list_containers(include_all: bool = False, *, run_as: str | None = None, bec
40
55
 
41
56
  result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
42
57
  if result.returncode != 0:
43
- check_become_error("list containers", result.stderr, run_as)
58
+ _check_docker_error("list containers", result.stderr, run_as)
44
59
 
45
60
  rows = []
46
61
  for line in result.stdout.strip().splitlines():
@@ -72,8 +87,9 @@ def container_logs(container: str, lines: int = 50, *, run_as: str | None = None
72
87
 
73
88
  argv = build_become(["docker", "logs", "--tail", str(lines), container], run_as, become_method)
74
89
  result = subprocess.run(argv, capture_output=True, text=True)
75
- if run_as and result.returncode != 0:
76
- check_become_error("container logs", result.stderr, run_as)
90
+ if result.returncode != 0 and (run_as or "permission denied" in result.stderr.lower()):
91
+ # Escalation failures, and a refused Docker socket, are errors — not log lines.
92
+ _check_docker_error("container logs", result.stderr, run_as)
77
93
  # docker logs writes to stderr by default
78
94
  output = result.stderr if result.stderr else result.stdout
79
95
  output_lines = output.strip().splitlines()
@@ -95,7 +111,7 @@ def restart_container(container: str, *, run_as: str | None = None, become_metho
95
111
 
96
112
  result = subprocess.run(build_become(["docker", "restart", container], run_as, become_method), capture_output=True, text=True)
97
113
  if result.returncode != 0:
98
- check_become_error("restart container", result.stderr, run_as)
114
+ _check_docker_error("restart container", result.stderr, run_as)
99
115
  out = {"container": container, "restarted": True}
100
116
  if run_as:
101
117
  out["run_as"] = run_as
@@ -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
+ }
@@ -15,6 +15,7 @@ real tools.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import os
18
19
  import re
19
20
  import shutil
20
21
  import subprocess
@@ -153,12 +154,40 @@ def open_ports(proto: str = "tcp", *, run_as: str | None = None, become_method:
153
154
  check_become_error("open ports", result.stderr, run_as)
154
155
 
155
156
  rows = _parse_ss_listening(result.stdout)
156
- out = {"proto": proto, "count": len(rows), "listeners": rows}
157
+ out: dict = {"proto": proto, "count": len(rows), "listeners": rows}
157
158
  if run_as:
158
159
  out["run_as"] = run_as
160
+ hint = _ports_hint(rows, run_as)
161
+ if hint:
162
+ out["hint"] = hint
159
163
  return out
160
164
 
161
165
 
166
+ def _probe_is_root(run_as: str | None) -> bool:
167
+ if run_as:
168
+ return run_as == "root"
169
+ geteuid = getattr(os, "geteuid", None)
170
+ return geteuid is not None and geteuid() == 0
171
+
172
+
173
+ def _ports_hint(rows: list[dict], run_as: str | None) -> str | None:
174
+ """Say so when owning processes are missing because the probe wasn't root.
175
+
176
+ ``ss -p`` only reports processes the caller may inspect, so an unprivileged
177
+ probe returns most listeners with an empty ``processes`` list — easy to
178
+ misread as "nothing owns this port" and to guess the owner from the number.
179
+ """
180
+ if _probe_is_root(run_as):
181
+ return None
182
+ missing = sum(1 for r in rows if not r["processes"])
183
+ if not missing:
184
+ return None
185
+ return (
186
+ f"{missing} of {len(rows)} listeners have no owning process because ss only shows other users' "
187
+ "processes to root — an empty list does not mean nothing owns the port. Re-run with --sudo to see them."
188
+ )
189
+
190
+
162
191
  def owning_package(path: str) -> dict:
163
192
  """Return which installed package owns ``path`` (dpkg on Debian/Ubuntu, rpm on
164
193
  RHEL/SUSE). Raises ToolNotFoundError if neither dpkg nor rpm is present;
@@ -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 = {}