apache-aiops 0.2.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.
@@ -0,0 +1,222 @@
1
+ """Bounded Apache control commands and HTTP probe; no shell interpolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import threading
10
+ from collections import Counter
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from apache_aiops.config import AppConfig, TargetConfig, load_config
17
+ from apache_aiops.governance.paths import ops_home
18
+
19
+ _OUTPUT_MAX = 12000
20
+ _ACCESS_STATUS = re.compile(
21
+ r'^\S+\s+\S+\s+\S+\s+\[[^]]+\]\s+"(?:\\.|[^"\\])*"\s+([1-5][0-9]{2})\s+'
22
+ )
23
+
24
+
25
+ class ApacheCommandError(Exception):
26
+ pass
27
+
28
+
29
+ class ApacheWriteUncertainError(ApacheCommandError):
30
+ """Graceful restart may have reached Apache before the response was lost."""
31
+
32
+
33
+ class ApacheConnection:
34
+ def __init__(
35
+ self, target: TargetConfig, runner: Any = subprocess.run, client: Any | None = None
36
+ ) -> None:
37
+ self.target = target
38
+ self._run = runner
39
+ self._client = client or httpx.Client(
40
+ timeout=target.timeout, verify=target.verify, follow_redirects=False,
41
+ trust_env=False,
42
+ )
43
+
44
+ def close(self) -> None:
45
+ self._client.close()
46
+
47
+ def command(self, *args: str) -> dict:
48
+ allowed = {("-v",), ("-V",), ("-S",), ("-M",), ("-l",), ("-L",),
49
+ ("configtest",), ("-t", "-D", "DUMP_RUN_CFG"),
50
+ ("-t", "-D", "DUMP_INCLUDES")}
51
+ if args not in allowed:
52
+ raise ValueError("Only fixed read-only apachectl options are supported.")
53
+ argv = [self.target.apachectl_bin, *args]
54
+ try:
55
+ result = self._run(
56
+ argv, capture_output=True, text=True, timeout=self.target.timeout, check=False
57
+ )
58
+ except FileNotFoundError as exc:
59
+ raise ApacheCommandError(
60
+ f"Missing '{argv[0]}'; install Apache HTTP Server or configure its path."
61
+ ) from exc
62
+ except subprocess.TimeoutExpired as exc:
63
+ raise ApacheCommandError(
64
+ f"Apache command timed out after {self.target.timeout:g}s."
65
+ ) from exc
66
+ combined = "\n".join(part for part in (result.stdout, result.stderr) if part)
67
+ if result.returncode != 0:
68
+ raise ApacheCommandError(
69
+ f"apachectl {' '.join(args)} failed (exit {result.returncode}): {combined[:800]}"
70
+ )
71
+ return {
72
+ "output": combined[:_OUTPUT_MAX] + ("…" if len(combined) > _OUTPUT_MAX else ""),
73
+ "truncated": len(combined) > _OUTPUT_MAX,
74
+ }
75
+
76
+ def graceful(self) -> dict:
77
+ argv = [self.target.apachectl_bin, "graceful"]
78
+ try:
79
+ result = self._run(
80
+ argv, capture_output=True, text=True, timeout=self.target.timeout, check=False
81
+ )
82
+ except FileNotFoundError as exc:
83
+ raise ApacheCommandError(f"Missing '{argv[0]}'.") from exc
84
+ except subprocess.TimeoutExpired as exc:
85
+ raise ApacheWriteUncertainError(
86
+ "Apache graceful reload timed out; it may have run."
87
+ ) from exc
88
+ if result.returncode != 0:
89
+ raise ApacheWriteUncertainError(
90
+ f"Apache graceful reload returned exit {result.returncode}; check state: "
91
+ f"{(result.stderr or result.stdout or '')[:800]}"
92
+ )
93
+ return {"output": (result.stdout or result.stderr or "")[:800]}
94
+
95
+ def server_status(self) -> dict:
96
+ if not self.target.status_url:
97
+ raise ValueError("No status_url configured for this Apache target.")
98
+ try:
99
+ with self._client.stream("GET", self.target.status_url) as response:
100
+ if response.status_code != 200:
101
+ raise ApacheCommandError(
102
+ f"Apache mod_status returned HTTP {response.status_code}."
103
+ )
104
+ chunks = bytearray()
105
+ truncated = False
106
+ for chunk in response.iter_bytes():
107
+ chunks.extend(chunk[: max(0, 16385 - len(chunks))])
108
+ if len(chunks) > 16384:
109
+ truncated = True
110
+ break
111
+ lines = chunks[:16384].decode("utf-8", errors="replace").splitlines()
112
+ fields = {}
113
+ for line in lines:
114
+ if ": " in line:
115
+ key, value = line.split(": ", 1)
116
+ fields[key] = value
117
+ known = {"BusyWorkers", "IdleWorkers", "Total Accesses", "Scoreboard"}
118
+ if not known & fields.keys():
119
+ raise ApacheCommandError(
120
+ "Apache status URL did not return mod_status auto data."
121
+ )
122
+ return {"fields": fields, "truncated": truncated, "url": self.target.status_url}
123
+ except httpx.HTTPError as exc:
124
+ raise ApacheCommandError(f"Apache status probe failed: {type(exc).__name__}.") from exc
125
+
126
+ def error_log(self, limit: int = 50) -> dict:
127
+ if not 1 <= limit <= 200:
128
+ raise ValueError("limit must be between 1 and 200.")
129
+ if not self.target.error_log:
130
+ raise ValueError("No error_log configured for this Apache target.")
131
+ selected, truncated = self._tail_log(Path(self.target.error_log), limit)
132
+ return {
133
+ "items": [{"line": line[:800] + ("…" if len(line) > 800 else "")}
134
+ for line in selected],
135
+ "returned": len(selected), "limit": limit,
136
+ "truncated": truncated or any(len(x) > 800 for x in selected),
137
+ }
138
+
139
+ @staticmethod
140
+ def _tail_log(path: Path, limit: int) -> tuple[list[str], bool]:
141
+ # Recheck on every call: a configured symlink may have changed since init.
142
+ resolved = path.resolve(strict=True)
143
+ if resolved.is_relative_to(ops_home().resolve()):
144
+ raise ValueError("Log path cannot read this tool's audit/config directory.")
145
+ descriptor = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW)
146
+ with os.fdopen(descriptor, "rb") as stream:
147
+ size = stream.seek(0, 2)
148
+ start = max(0, size - 65536)
149
+ stream.seek(start)
150
+ raw = stream.read(65536)
151
+ lines = raw.decode("utf-8", errors="replace").splitlines()
152
+ if start and lines:
153
+ lines = lines[1:]
154
+ selected = lines[-limit:]
155
+ return selected, bool(start or len(lines) > limit)
156
+
157
+ def access_status(self, limit: int = 200) -> dict:
158
+ """Summarize common/combined logs without returning URLs, IPs or headers."""
159
+ if not 1 <= limit <= 500:
160
+ raise ValueError("limit must be between 1 and 500.")
161
+ if not self.target.access_log:
162
+ raise ValueError("No access_log configured for this Apache target.")
163
+ selected, truncated = self._tail_log(Path(self.target.access_log), limit)
164
+ statuses = []
165
+ unparsed = 0
166
+ for line in selected:
167
+ match = _ACCESS_STATUS.match(line)
168
+ if match is None:
169
+ unparsed += 1
170
+ else:
171
+ statuses.append(int(match.group(1)))
172
+ classes = Counter(f"{code // 100}xx" for code in statuses)
173
+ return {
174
+ "items": [{"statusCode": code} for code in statuses],
175
+ "returned": len(statuses), "limit": limit, "truncated": truncated,
176
+ "sampledLines": len(selected), "unparsedLines": unparsed,
177
+ "sampleParseComplete": unparsed == 0,
178
+ "statusClasses": {f"{code}xx": classes.get(f"{code}xx", 0) for code in range(1, 6)},
179
+ }
180
+
181
+ def probe(self) -> dict:
182
+ if not self.target.probe_url:
183
+ raise ValueError("No probe_url configured for this Apache target.")
184
+ try:
185
+ with self._client.stream("GET", self.target.probe_url) as response:
186
+ return {
187
+ "url": self.target.probe_url,
188
+ "statusCode": response.status_code,
189
+ "healthy": 200 <= response.status_code < 300,
190
+ "location": response.headers.get("location"),
191
+ }
192
+ except httpx.ConnectTimeout as exc:
193
+ raise ApacheCommandError(
194
+ "Could not connect to the configured Apache probe URL."
195
+ ) from exc
196
+ except httpx.TimeoutException as exc:
197
+ raise ApacheCommandError(
198
+ "Apache HTTP probe timed out; inspect the service and backend."
199
+ ) from exc
200
+ except httpx.HTTPError as exc:
201
+ raise ApacheCommandError(f"Apache HTTP probe failed: {type(exc).__name__}.") from exc
202
+
203
+
204
+ class ConnectionManager:
205
+ def __init__(self, config: AppConfig | None = None) -> None:
206
+ self.config = config or load_config()
207
+ self._connections: dict[str, ApacheConnection] = {}
208
+ self._lock = threading.Lock()
209
+ atexit.register(self.close)
210
+
211
+ def connect(self, target: str | None = None) -> ApacheConnection:
212
+ chosen = self.config.get_target(target)
213
+ with self._lock:
214
+ if chosen.name not in self._connections:
215
+ self._connections[chosen.name] = ApacheConnection(chosen)
216
+ return self._connections[chosen.name]
217
+
218
+ def close(self) -> None:
219
+ with self._lock:
220
+ for connection in self._connections.values():
221
+ connection.close()
222
+ self._connections.clear()
@@ -0,0 +1,50 @@
1
+ """Governance harness for apache-aiops — audit, policy, budget, undo, sanitize.
2
+
3
+ A self-contained, vendored governance layer. apache-aiops has NO dependency on
4
+ any external skill family — this package is its own copy of the harness:
5
+
6
+ - ``@governed_tool`` — mandatory decorator on every MCP tool: policy pre-check,
7
+ token/runaway budget guard, graduated-autonomy risk-tier gate, audit logging,
8
+ and undo-token recording.
9
+ - unified SQLite audit log under ``~/.apache-aiops/`` (override with
10
+ ``APACHE_AIOPS_HOME``).
11
+ - ``sanitize`` — output hygiene (encoding-level defense) for API-returned text.
12
+
13
+ State lives under ``ops_home()`` (default ``~/.apache-aiops``).
14
+ """
15
+
16
+ from apache_aiops.governance.audit import AuditEngine, get_engine
17
+ from apache_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
18
+ from apache_aiops.governance.decorators import PolicyDenied, governed_tool
19
+ from apache_aiops.governance.outcome import (
20
+ UNKNOWN_FIELD,
21
+ capture_prior_state,
22
+ is_unknown,
23
+ mark_unknown,
24
+ )
25
+ from apache_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
26
+ from apache_aiops.governance.policy import get_policy_engine
27
+ from apache_aiops.governance.sanitize import opt_str, sanitize
28
+ from apache_aiops.governance.undo import UndoStore, get_undo_store
29
+
30
+ __all__ = [
31
+ "governed_tool",
32
+ "sanitize",
33
+ "opt_str",
34
+ "capture_prior_state",
35
+ "mark_unknown",
36
+ "is_unknown",
37
+ "UNKNOWN_FIELD",
38
+ "PolicyDenied",
39
+ "get_engine",
40
+ "AuditEngine",
41
+ "get_policy_engine",
42
+ "get_budget",
43
+ "BudgetTracker",
44
+ "BudgetExceeded",
45
+ "get_undo_store",
46
+ "UndoStore",
47
+ "Pattern",
48
+ "PatternMatch",
49
+ "get_pattern_engine",
50
+ ]