sentry-v2 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sentry_v2/__init__.py +1 -0
- sentry_v2/actions.py +147 -0
- sentry_v2/bundle.py +176 -0
- sentry_v2/cgroupfs.py +81 -0
- sentry_v2/cli.py +501 -0
- sentry_v2/client.py +100 -0
- sentry_v2/collectors/__init__.py +0 -0
- sentry_v2/collectors/audit.py +451 -0
- sentry_v2/collectors/journal.py +240 -0
- sentry_v2/collectors/psi.py +230 -0
- sentry_v2/commands.py +313 -0
- sentry_v2/config.py +528 -0
- sentry_v2/config_types.py +131 -0
- sentry_v2/control.py +295 -0
- sentry_v2/correlate.py +285 -0
- sentry_v2/daemon.py +581 -0
- sentry_v2/events.py +133 -0
- sentry_v2/executor.py +484 -0
- sentry_v2/ids.py +38 -0
- sentry_v2/metrics.py +358 -0
- sentry_v2/policy.py +181 -0
- sentry_v2/reporting.py +198 -0
- sentry_v2/sinks/__init__.py +158 -0
- sentry_v2/sinks/base.py +73 -0
- sentry_v2/sinks/elastic.py +54 -0
- sentry_v2/sinks/file.py +78 -0
- sentry_v2/sinks/http.py +43 -0
- sentry_v2/sinks/splunk.py +45 -0
- sentry_v2/sinks/spool.py +101 -0
- sentry_v2/sinks/syslog.py +101 -0
- sentry_v2/sinks/webhook.py +119 -0
- sentry_v2/state.py +142 -0
- sentry_v2/systemd.py +260 -0
- sentry_v2/units.py +48 -0
- sentry_v2-0.1.0.dist-info/METADATA +186 -0
- sentry_v2-0.1.0.dist-info/RECORD +40 -0
- sentry_v2-0.1.0.dist-info/WHEEL +5 -0
- sentry_v2-0.1.0.dist-info/entry_points.txt +3 -0
- sentry_v2-0.1.0.dist-info/licenses/LICENSE +661 -0
- sentry_v2-0.1.0.dist-info/top_level.txt +1 -0
sentry_v2/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
sentry_v2/actions.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""How each action kind is captured, applied and undone against systemd.
|
|
2
|
+
|
|
3
|
+
The executor decides when; this module only knows what. Capture (`prepare`)
|
|
4
|
+
is separate from `apply` so the previous values can be journalled in
|
|
5
|
+
between.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import stat
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .cgroupfs import CgroupFS
|
|
19
|
+
from .policy import ActionSpec, BackupReload, Freeze, Notify, Throttle
|
|
20
|
+
from .state import ActionRecord
|
|
21
|
+
from .systemd import SystemdError, SystemdManager, UnitNotFound
|
|
22
|
+
from .units import Percent
|
|
23
|
+
|
|
24
|
+
MEMORY_HIGH_FLOOR = 32 * 1024**2
|
|
25
|
+
UNIT_INACTIVE = "org.freedesktop.systemd1.UnitInactive"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Refused(Exception):
|
|
29
|
+
"""The action must not go ahead; the message is the record's error."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def prepare(
|
|
33
|
+
spec: ActionSpec, unit: str, mgr: SystemdManager, fs: CgroupFS
|
|
34
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
35
|
+
"""Read current state; return (previous values to restore, params to apply)."""
|
|
36
|
+
props = await mgr.get_properties(unit)
|
|
37
|
+
match spec:
|
|
38
|
+
case Throttle():
|
|
39
|
+
mh = spec.memory_high
|
|
40
|
+
if isinstance(mh, Percent):
|
|
41
|
+
mh = max(mh.of(fs.memory_current(props.cgroup)), MEMORY_HIGH_FLOOR)
|
|
42
|
+
previous: dict[str, Any] = {}
|
|
43
|
+
if mh is not None:
|
|
44
|
+
previous["memory_high"] = props.memory_high
|
|
45
|
+
if spec.cpu_weight is not None:
|
|
46
|
+
previous["cpu_weight"] = props.cpu_weight
|
|
47
|
+
return previous, {"memory_high": mh, "cpu_weight": spec.cpu_weight}
|
|
48
|
+
case Freeze():
|
|
49
|
+
if props.active_state != "active":
|
|
50
|
+
raise Refused("unit inactive")
|
|
51
|
+
if props.freezer_state != "running":
|
|
52
|
+
raise Refused(f"already {props.freezer_state}")
|
|
53
|
+
return {"freezer_state": props.freezer_state}, {"cgroup": props.cgroup}
|
|
54
|
+
case BackupReload():
|
|
55
|
+
files = [props.fragment_path, *props.drop_in_paths, *map(str, spec.paths)]
|
|
56
|
+
return {}, {"files": [f for f in files if f]}
|
|
57
|
+
case Notify(): # pragma: no cover - the executor never prepares a notify
|
|
58
|
+
return {}, {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def describe(spec: ActionSpec) -> dict[str, Any]:
|
|
62
|
+
"""What a dry-run would write, without touching systemd (a Percent stays "90%")."""
|
|
63
|
+
if not isinstance(spec, Throttle):
|
|
64
|
+
return {}
|
|
65
|
+
mh = spec.memory_high
|
|
66
|
+
return {
|
|
67
|
+
"memory_high": f"{mh.value:g}%" if isinstance(mh, Percent) else mh,
|
|
68
|
+
"cpu_weight": spec.cpu_weight,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def copy_mode_and_times(src: Path | str, dest: Path | str) -> None:
|
|
73
|
+
"""Give `dest` the permission bits and times of `src`, but not its xattrs.
|
|
74
|
+
|
|
75
|
+
shutil.copystat (and copy2) also copies security.selinux. That would
|
|
76
|
+
label a backup as a unit file, or a restored unit file as sentry-v2
|
|
77
|
+
state, and the daemon's SELinux domain is not allowed to relabel.
|
|
78
|
+
"""
|
|
79
|
+
st = os.stat(src)
|
|
80
|
+
os.chmod(dest, stat.S_IMODE(st.st_mode))
|
|
81
|
+
os.utime(dest, ns=(st.st_atime_ns, st.st_mtime_ns))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def backup(unit: str, files: list[str], backups_dir: Path, now: float) -> str:
|
|
85
|
+
"""Copy `files` under `backups_dir/<unit>/<ts>/files/<abs path>` with a sha256 manifest.
|
|
86
|
+
|
|
87
|
+
Returns the backup id `<unit>/<ts>`. Copies keep mode and mtime.
|
|
88
|
+
"""
|
|
89
|
+
backup_id = f"{unit}/{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime(now))}"
|
|
90
|
+
bdir = backups_dir / backup_id
|
|
91
|
+
copies = [(src, Path("files") / Path(src).relative_to("/")) for src in files]
|
|
92
|
+
for src, rel in copies:
|
|
93
|
+
if not (bdir / rel).resolve().is_relative_to(bdir.resolve()):
|
|
94
|
+
raise ValueError(f"{src}: escapes the backup directory")
|
|
95
|
+
backups_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
96
|
+
bdir.parent.mkdir(mode=0o700, exist_ok=True)
|
|
97
|
+
bdir.mkdir(mode=0o700)
|
|
98
|
+
manifest = []
|
|
99
|
+
for src, rel in copies:
|
|
100
|
+
dest = bdir / rel
|
|
101
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
shutil.copyfile(src, dest)
|
|
103
|
+
copy_mode_and_times(src, dest)
|
|
104
|
+
manifest.append(f"{hashlib.sha256(dest.read_bytes()).hexdigest()} {rel}\n")
|
|
105
|
+
(bdir / "MANIFEST.sha256").write_text("".join(manifest))
|
|
106
|
+
return backup_id
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def apply(spec: ActionSpec, unit: str, params: dict[str, Any], mgr: SystemdManager) -> None:
|
|
110
|
+
"""The D-Bus call that makes the action take effect."""
|
|
111
|
+
match spec:
|
|
112
|
+
case Throttle():
|
|
113
|
+
await mgr.set_properties(unit, params["memory_high"], params["cpu_weight"])
|
|
114
|
+
case Freeze():
|
|
115
|
+
await mgr.freeze(unit)
|
|
116
|
+
case BackupReload():
|
|
117
|
+
await mgr.reload(unit)
|
|
118
|
+
case Notify(): # pragma: no cover - nothing to apply
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
CHANGED_SINCE = "changed since; left as is"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def rollback(rec: ActionRecord, mgr: SystemdManager) -> str | None:
|
|
126
|
+
"""Undo `rec`. Returns a note when there was nothing (or nothing safe) to undo.
|
|
127
|
+
|
|
128
|
+
A throttle is restored only while every value it wrote is still in
|
|
129
|
+
place; if someone changed one since, their change wins and nothing is
|
|
130
|
+
written. Raises on failure.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
if rec.action == "throttle":
|
|
134
|
+
props = await mgr.get_properties(rec.target)
|
|
135
|
+
if any(getattr(props, k) != rec.params.get(k) for k in rec.previous):
|
|
136
|
+
return CHANGED_SINCE
|
|
137
|
+
prev = rec.previous
|
|
138
|
+
await mgr.set_properties(rec.target, prev.get("memory_high"), prev.get("cpu_weight"))
|
|
139
|
+
elif rec.action == "freeze":
|
|
140
|
+
await mgr.thaw(rec.target)
|
|
141
|
+
except UnitNotFound:
|
|
142
|
+
return "unit gone"
|
|
143
|
+
except SystemdError as e:
|
|
144
|
+
if e.name == UNIT_INACTIVE: # systemd thaws a unit when it stops
|
|
145
|
+
return "unit inactive"
|
|
146
|
+
raise
|
|
147
|
+
return None
|
sentry_v2/bundle.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Diagnostic bundles: one JSON document per incident, scrubbed of secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from sentry_v2.config_types import RedactionConfig
|
|
16
|
+
from sentry_v2.events import Incident
|
|
17
|
+
from sentry_v2.ids import uuid7
|
|
18
|
+
|
|
19
|
+
SCHEMA_ID = "sentry-v2/bundle/1"
|
|
20
|
+
REDACTED = "[REDACTED]"
|
|
21
|
+
|
|
22
|
+
DEFAULT_PATTERNS = (
|
|
23
|
+
# before the bearer pattern, which would leave "Authorization: [REDACTED]"
|
|
24
|
+
# for this one to swallow the next word after
|
|
25
|
+
r"(?i)authorization:\s*\S+(?:\s+\S+)?",
|
|
26
|
+
r"(?i)bearer\s+[a-z0-9._~+/-]+=*",
|
|
27
|
+
r"://[^/\s:@]+:[^/\s@]+@",
|
|
28
|
+
r"AKIA[0-9A-Z]{16}",
|
|
29
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)",
|
|
30
|
+
r"(?i)(password|passwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)=\S+",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Redactor:
|
|
35
|
+
def __init__(self, patterns: Sequence[str]) -> None:
|
|
36
|
+
self._res = [re.compile(p) for p in patterns]
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_config(cls, cfg: RedactionConfig) -> Redactor:
|
|
40
|
+
return cls((*DEFAULT_PATTERNS, *cfg.patterns) if cfg.use_defaults else cfg.patterns)
|
|
41
|
+
|
|
42
|
+
def scrub(self, text: str) -> str:
|
|
43
|
+
for r in self._res:
|
|
44
|
+
text = r.sub(REDACTED, text)
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
def scrub_tree(self, obj: Any) -> Any:
|
|
48
|
+
"""Return a copy of a JSON-like tree with every string scrubbed."""
|
|
49
|
+
if isinstance(obj, bytes):
|
|
50
|
+
return self.scrub(obj.decode(errors="replace"))
|
|
51
|
+
if isinstance(obj, str):
|
|
52
|
+
return self.scrub(obj)
|
|
53
|
+
if isinstance(obj, Mapping):
|
|
54
|
+
return {k: self.scrub_tree(v) for k, v in obj.items()}
|
|
55
|
+
if isinstance(obj, (list, tuple)):
|
|
56
|
+
return [self.scrub_tree(v) for v in obj]
|
|
57
|
+
if isinstance(obj, float):
|
|
58
|
+
return obj if math.isfinite(obj) else None # NaN/inf are not valid JSON
|
|
59
|
+
if obj is None or isinstance(obj, (bool, int)):
|
|
60
|
+
return obj
|
|
61
|
+
return self.scrub(str(obj))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class HostInfo:
|
|
66
|
+
hostname: str
|
|
67
|
+
machine_id: str | None
|
|
68
|
+
kernel: str
|
|
69
|
+
os_release: str | None
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def collect(cls, root: Path = Path("/")) -> HostInfo:
|
|
73
|
+
try:
|
|
74
|
+
machine_id: str | None = (root / "etc/machine-id").read_text().strip()
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
machine_id = None
|
|
77
|
+
os_release = None
|
|
78
|
+
try:
|
|
79
|
+
for line in (root / "etc/os-release").read_text().splitlines():
|
|
80
|
+
if line.startswith("PRETTY_NAME="):
|
|
81
|
+
os_release = line.partition("=")[2].strip().strip("\"'")
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
pass
|
|
84
|
+
return cls(os.uname().nodename, machine_id, os.uname().release, os_release)
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict[str, str | None]:
|
|
87
|
+
return {
|
|
88
|
+
"hostname": self.hostname,
|
|
89
|
+
"machine_id": self.machine_id,
|
|
90
|
+
"kernel": self.kernel,
|
|
91
|
+
"os_release": self.os_release,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class BundleContext:
|
|
97
|
+
psi: dict[str, dict[str, Any]]
|
|
98
|
+
unit_props: dict[str, Any] | None
|
|
99
|
+
journal_tail: list[dict[str, Any]]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _now() -> str:
|
|
103
|
+
return dt.datetime.now(dt.UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _action(rec: Mapping[str, Any], redactor: Redactor) -> dict[str, Any]:
|
|
107
|
+
out: dict[str, Any] = json.loads(json.dumps(rec, default=str))
|
|
108
|
+
if isinstance(out.get("error"), str):
|
|
109
|
+
out["error"] = redactor.scrub(out["error"])
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _severity(records: Sequence[Mapping[str, Any]], incident: Mapping[str, Any]) -> str:
|
|
114
|
+
if any(r.get("status") == "rollback_failed" for r in records):
|
|
115
|
+
return "critical"
|
|
116
|
+
enforced = any(
|
|
117
|
+
r.get("mode") == "enforce" and r.get("status") in ("applied", "rolled_back")
|
|
118
|
+
for r in records
|
|
119
|
+
)
|
|
120
|
+
strong = any(c["confidence"] >= 0.8 for c in incident["correlations"])
|
|
121
|
+
return "warning" if enforced or strong else "info"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_bundle(
|
|
125
|
+
incident: Incident,
|
|
126
|
+
records: Sequence[Mapping[str, Any]],
|
|
127
|
+
context: BundleContext,
|
|
128
|
+
host: HostInfo,
|
|
129
|
+
redactor: Redactor,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
inc = json.loads(json.dumps(incident.to_dict())) # tuples become lists
|
|
132
|
+
# Explicit scrub paths: ids and contexts must not be mangled.
|
|
133
|
+
scrub = redactor.scrub
|
|
134
|
+
trigger: dict[str, Any] = inc["trigger"]
|
|
135
|
+
if isinstance(trigger.get("raw"), str):
|
|
136
|
+
trigger["raw"] = scrub(trigger["raw"])
|
|
137
|
+
for ev in inc["related"]:
|
|
138
|
+
if isinstance(ev.get("raw"), str):
|
|
139
|
+
ev["raw"] = scrub(ev["raw"])
|
|
140
|
+
corrs = [{**c, "avc": {**c["avc"], "raw": scrub(c["avc"]["raw"])}} for c in inc["correlations"]]
|
|
141
|
+
inc["correlations"] = corrs
|
|
142
|
+
first = records[0] if records else {}
|
|
143
|
+
return {
|
|
144
|
+
"schema": SCHEMA_ID,
|
|
145
|
+
"id": uuid7(),
|
|
146
|
+
"ts": _now(),
|
|
147
|
+
"severity": _severity(records, inc),
|
|
148
|
+
"host": host.to_dict(),
|
|
149
|
+
"incident": inc,
|
|
150
|
+
"context": {
|
|
151
|
+
"psi": context.psi,
|
|
152
|
+
"unit": redactor.scrub_tree(context.unit_props),
|
|
153
|
+
"journal": redactor.scrub_tree(context.journal_tail),
|
|
154
|
+
},
|
|
155
|
+
"correlations": corrs,
|
|
156
|
+
"decision": {
|
|
157
|
+
"policy": first.get("policy"),
|
|
158
|
+
"mode": first.get("mode"),
|
|
159
|
+
"actions": [_action(r, redactor) for r in records],
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_alert_bundle(kind: str, detail: Mapping[str, Any], host: HostInfo) -> dict[str, Any]:
|
|
165
|
+
record = detail.get("record")
|
|
166
|
+
return {
|
|
167
|
+
"schema": SCHEMA_ID,
|
|
168
|
+
"id": uuid7(),
|
|
169
|
+
"ts": _now(),
|
|
170
|
+
"severity": "critical",
|
|
171
|
+
"host": host.to_dict(),
|
|
172
|
+
"incident": {"kind": "alert", "target": detail.get("target"), "alert": kind},
|
|
173
|
+
"context": {"psi": {}, "unit": None, "journal": []},
|
|
174
|
+
"correlations": [],
|
|
175
|
+
"decision": {"policy": None, "mode": None, "actions": [record] if record else []},
|
|
176
|
+
}
|
sentry_v2/cgroupfs.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Thin, testable access to the cgroup v2 filesystem and /proc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .events import PressureLine
|
|
10
|
+
from .policy import target_matches
|
|
11
|
+
|
|
12
|
+
UNIT_SUFFIXES = (".service", ".scope", ".slice")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_pressure(text: str) -> dict[str, PressureLine]:
|
|
16
|
+
"""Parse a *.pressure file into {"some": ..., "full": ...}."""
|
|
17
|
+
out: dict[str, PressureLine] = {}
|
|
18
|
+
for line in text.splitlines():
|
|
19
|
+
kind, *fields = line.split()
|
|
20
|
+
kv = dict(f.split("=", 1) for f in fields)
|
|
21
|
+
out[kind] = PressureLine(
|
|
22
|
+
float(kv["avg10"]), float(kv["avg60"]), float(kv["avg300"]), int(kv["total"])
|
|
23
|
+
)
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CgroupFS:
|
|
28
|
+
def __init__(self, root: Path = Path("/sys/fs/cgroup"), proc: Path = Path("/proc")) -> None:
|
|
29
|
+
self.root = root
|
|
30
|
+
self.proc = proc
|
|
31
|
+
|
|
32
|
+
def psi_available(self) -> bool:
|
|
33
|
+
return os.access(self.proc / "pressure" / "memory", os.R_OK)
|
|
34
|
+
|
|
35
|
+
def glob(self, patterns: Iterable[str]) -> list[str]:
|
|
36
|
+
"""Cgroup directories (relative paths) matching any pattern."""
|
|
37
|
+
pats = tuple(patterns)
|
|
38
|
+
found: list[str] = []
|
|
39
|
+
# Known limit: walks the whole cgroup tree, fine up to a few thousand
|
|
40
|
+
# cgroups per resolve (upgrade path: prune by each pattern's literal prefix).
|
|
41
|
+
for dirpath, _dirs, _files in os.walk(self.root):
|
|
42
|
+
rel = Path(dirpath).relative_to(self.root).as_posix()
|
|
43
|
+
if rel != "." and any(target_matches(p, rel) for p in pats):
|
|
44
|
+
found.append(rel)
|
|
45
|
+
return sorted(found)
|
|
46
|
+
|
|
47
|
+
def read_pressure(self, rel: str, resource: str) -> dict[str, PressureLine]:
|
|
48
|
+
return parse_pressure((self.root / rel / f"{resource}.pressure").read_text())
|
|
49
|
+
|
|
50
|
+
def read_system_pressure(self, resource: str) -> dict[str, PressureLine]:
|
|
51
|
+
"""System-wide pressure from /proc/pressure, for the root cgroup."""
|
|
52
|
+
return parse_pressure((self.proc / "pressure" / resource).read_text())
|
|
53
|
+
|
|
54
|
+
def memory_current(self, rel: str) -> int:
|
|
55
|
+
return int((self.root / rel / "memory.current").read_text())
|
|
56
|
+
|
|
57
|
+
def pid_cgroup(self, pid: int) -> str | None:
|
|
58
|
+
try:
|
|
59
|
+
text = (self.proc / str(pid) / "cgroup").read_text()
|
|
60
|
+
except OSError:
|
|
61
|
+
return None
|
|
62
|
+
for line in text.splitlines():
|
|
63
|
+
if line.startswith("0::"):
|
|
64
|
+
return line[3:].strip("/")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def unit_of(self, rel: str) -> str | None:
|
|
68
|
+
last = rel.rsplit("/", 1)[-1]
|
|
69
|
+
return last if last.endswith(UNIT_SUFFIXES) else None
|
|
70
|
+
|
|
71
|
+
def open_trigger(
|
|
72
|
+
self, rel: str, resource: str, kind: str, stall_us: int, window_us: int
|
|
73
|
+
) -> int:
|
|
74
|
+
"""Register a PSI trigger; the returned fd raises EPOLLPRI when it fires."""
|
|
75
|
+
fd = os.open(self.root / rel / f"{resource}.pressure", os.O_RDWR | os.O_NONBLOCK)
|
|
76
|
+
try:
|
|
77
|
+
os.write(fd, f"{kind} {stall_us} {window_us}\0".encode())
|
|
78
|
+
except OSError:
|
|
79
|
+
os.close(fd)
|
|
80
|
+
raise
|
|
81
|
+
return fd
|