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 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