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.
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/CHANGELOG.md +58 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/PKG-INFO +1 -1
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/pyproject.toml +1 -1
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/__init__.py +6 -2
- runspec_linux_core-0.9.0/runspec_linux_core/_mounts.py +172 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/containers.py +21 -5
- runspec_linux_core-0.9.0/runspec_linux_core/files.py +107 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/investigate.py +30 -1
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/network.py +76 -1
- runspec_linux_core-0.9.0/runspec_linux_core/system.py +203 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_containers_runas.py +36 -0
- runspec_linux_core-0.9.0/tests/test_files.py +105 -0
- runspec_linux_core-0.9.0/tests/test_inventory.py +226 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_investigate.py +37 -0
- runspec_linux_core-0.9.0/tests/test_mounts.py +112 -0
- runspec_linux_core-0.7.0/runspec_linux_core/files.py +0 -54
- runspec_linux_core-0.7.0/runspec_linux_core/system.py +0 -110
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/.gitignore +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/_paths.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/become.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/commands.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/cron.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/logs.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/perf.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/power.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/services.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/sudoers.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/runspec_linux_core/venvs.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/__init__.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_become.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_commands.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_cron.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_logs_services_runas.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_network.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_packages.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_paths.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_perf.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_power.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_preconditions.py +0 -0
- {runspec_linux_core-0.7.0 → runspec_linux_core-0.9.0}/tests/test_sudoers.py +0 -0
- {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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-linux-core"
|
|
7
|
-
version = "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
|
-
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
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.
|
|
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 = {}
|