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.
- apache_aiops/__init__.py +8 -0
- apache_aiops/cli/__init__.py +9 -0
- apache_aiops/cli/_common.py +118 -0
- apache_aiops/cli/_root.py +206 -0
- apache_aiops/cli/undo.py +66 -0
- apache_aiops/config.py +74 -0
- apache_aiops/connection.py +222 -0
- apache_aiops/governance/__init__.py +50 -0
- apache_aiops/governance/audit.py +526 -0
- apache_aiops/governance/budget.py +222 -0
- apache_aiops/governance/decorators.py +599 -0
- apache_aiops/governance/outcome.py +102 -0
- apache_aiops/governance/paths.py +23 -0
- apache_aiops/governance/patterns.py +396 -0
- apache_aiops/governance/policy.py +67 -0
- apache_aiops/governance/sanitize.py +79 -0
- apache_aiops/governance/undo.py +259 -0
- apache_aiops/mcp_server/__init__.py +1 -0
- apache_aiops/mcp_server/_annotations.py +66 -0
- apache_aiops/mcp_server/_shared.py +67 -0
- apache_aiops/mcp_server/server.py +14 -0
- apache_aiops/mcp_server/tools/__init__.py +1 -0
- apache_aiops/mcp_server/tools/httpd.py +143 -0
- apache_aiops/mcp_server/tools/undo.py +204 -0
- apache_aiops/ops/__init__.py +1 -0
- apache_aiops/ops/httpd.py +81 -0
- apache_aiops-0.2.0.dist-info/METADATA +50 -0
- apache_aiops-0.2.0.dist-info/RECORD +31 -0
- apache_aiops-0.2.0.dist-info/WHEEL +4 -0
- apache_aiops-0.2.0.dist-info/entry_points.txt +3 -0
- apache_aiops-0.2.0.dist-info/licenses/LICENSE +21 -0
apache_aiops/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Shared helpers for apache-aiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# ─── Shared Option types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
TargetOption = Annotated[str | None, typer.Option("--target", "-t", help="Target name from config")]
|
|
18
|
+
DryRunOption = Annotated[
|
|
19
|
+
bool, typer.Option("--dry-run", help="Print the API call without executing")
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
24
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback.
|
|
25
|
+
|
|
26
|
+
``BudgetExceeded`` (and the retained ``PolicyDenied`` type) are raised by
|
|
27
|
+
``@governed_tool`` OUTSIDE the tool body, so ``tool_errors`` never sees them
|
|
28
|
+
and they never arrive as an ``{"error": ...}`` dict. Their message is the
|
|
29
|
+
teaching text (e.g. which budget was hit) — without them here such a refusal
|
|
30
|
+
reaches the CLI as a traceback instead.
|
|
31
|
+
"""
|
|
32
|
+
from apache_aiops.connection import ApacheCommandError
|
|
33
|
+
from apache_aiops.governance import BudgetExceeded, PolicyDenied
|
|
34
|
+
|
|
35
|
+
return (ApacheCommandError, PolicyDenied, BudgetExceeded, KeyError, OSError, ValueError)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
39
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
40
|
+
|
|
41
|
+
@functools.wraps(fn)
|
|
42
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
43
|
+
try:
|
|
44
|
+
return fn(*args, **kwargs)
|
|
45
|
+
except (typer.Exit, typer.Abort):
|
|
46
|
+
raise
|
|
47
|
+
except _cli_error_types() as e:
|
|
48
|
+
message = str(e)
|
|
49
|
+
if isinstance(e, KeyError):
|
|
50
|
+
message = f"Missing required key or environment variable: {message}"
|
|
51
|
+
# Upstream text can contain "[/bold]"; unescaped, rich raises on it and the
|
|
52
|
+
# real error is replaced by a MarkupError traceback.
|
|
53
|
+
console.print(f"[red]Error: {escape(message)}[/]")
|
|
54
|
+
raise typer.Exit(1) from e
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: Exit status for a write whose outcome could not be determined — kept distinct
|
|
60
|
+
#: from 0 (confirmed) and 1 (failed) so a script can tell all three apart.
|
|
61
|
+
EXIT_UNDETERMINED = 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def governed(result: Any) -> dict:
|
|
65
|
+
"""Return a governed tool's result, or print its error and exit 1.
|
|
66
|
+
|
|
67
|
+
The ``apache_aiops.mcp_server.tools`` twins never raise: ``@tool_errors`` flattens every
|
|
68
|
+
failure — an unreachable service, a refused login, a bad argument — into
|
|
69
|
+
``{"error": ...}``. A CLI command that drops that on the floor prints an empty
|
|
70
|
+
table and exits 0 as if the service had nothing to report. Route every governed
|
|
71
|
+
call through here.
|
|
72
|
+
"""
|
|
73
|
+
# ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
|
|
74
|
+
# write whose response was lost carries BOTH keys, and it is audited
|
|
75
|
+
# `unknown` precisely because it may have taken effect. Reporting that as a
|
|
76
|
+
# plain failure would tell a script the change did not happen and invite the
|
|
77
|
+
# double-apply the payload's own note warns about.
|
|
78
|
+
if isinstance(result, dict) and result.get("outcomeUnknown"):
|
|
79
|
+
console.print(f"[yellow]Outcome undetermined: {escape(str(result.get('note') or ''))}[/]")
|
|
80
|
+
raise typer.Exit(EXIT_UNDETERMINED)
|
|
81
|
+
if isinstance(result, dict) and result.get("error"):
|
|
82
|
+
console.print(f"[red]Error: {escape(str(result['error']))}[/]")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
return result if isinstance(result, dict) else {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
|
|
88
|
+
"""Print a dry-run preview of the API call that would be made."""
|
|
89
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
|
|
90
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
91
|
+
console.print(f"[magenta] API Call: {api_call}[/]")
|
|
92
|
+
for k, v in (parameters or {}).items():
|
|
93
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
94
|
+
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
98
|
+
"""Require two confirmations for a destructive operation."""
|
|
99
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
100
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
101
|
+
typer.confirm(
|
|
102
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
103
|
+
abort=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def print_truncation_note(result: dict, noun: str) -> None:
|
|
108
|
+
"""Print a visible warning when a listing envelope was capped.
|
|
109
|
+
|
|
110
|
+
The JSON already carries ``truncated``, but a human scrolling a long dump
|
|
111
|
+
will not spot it — and neither will a model summarising the terminal. Say
|
|
112
|
+
it in words, on the last line, where it cannot be missed.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(result, dict) and result.get("truncated"):
|
|
115
|
+
console.print(
|
|
116
|
+
f"[yellow]… showing {result.get('returned')} of more {noun} — "
|
|
117
|
+
f"truncated, re-run with a higher --limit[/]"
|
|
118
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Apache AIops CLI; service reads call governed MCP twins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from apache_aiops.cli._common import cli_errors, console, double_confirm, governed
|
|
13
|
+
from apache_aiops.cli.undo import undo_app
|
|
14
|
+
from apache_aiops.governance.paths import ops_home
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="apache-aiops", no_args_is_help=True, help="Audited Apache HTTP Server diagnosis."
|
|
18
|
+
)
|
|
19
|
+
app.add_typer(undo_app, name="undo")
|
|
20
|
+
Target = Annotated[str | None, typer.Option("--target", "-t", help="Configured Apache target")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _print(result: dict) -> None:
|
|
24
|
+
console.print_json(json.dumps(governed(result)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("config-includes")
|
|
28
|
+
@cli_errors
|
|
29
|
+
def config_includes(target: Target = None) -> None:
|
|
30
|
+
"""Show the parsed Apache config include tree."""
|
|
31
|
+
from apache_aiops.mcp_server.tools.httpd import apache_config_includes
|
|
32
|
+
_print(apache_config_includes(target=target))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("compiled-modules")
|
|
36
|
+
@cli_errors
|
|
37
|
+
def compiled_modules(target: Target = None) -> None:
|
|
38
|
+
"""List modules compiled into Apache."""
|
|
39
|
+
from apache_aiops.mcp_server.tools.httpd import apache_compiled_modules
|
|
40
|
+
_print(apache_compiled_modules(target=target))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("static-directives")
|
|
44
|
+
@cli_errors
|
|
45
|
+
def static_directives(target: Target = None) -> None:
|
|
46
|
+
"""List directives supplied by static Apache modules."""
|
|
47
|
+
from apache_aiops.mcp_server.tools.httpd import apache_static_directives
|
|
48
|
+
_print(apache_static_directives(target=target))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("build")
|
|
52
|
+
@cli_errors
|
|
53
|
+
def build(target: Target = None) -> None:
|
|
54
|
+
"""Show Apache build flags and paths."""
|
|
55
|
+
from apache_aiops.mcp_server.tools.httpd import apache_build
|
|
56
|
+
_print(apache_build(target=target))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("runtime-config")
|
|
60
|
+
@cli_errors
|
|
61
|
+
def runtime_config(target: Target = None) -> None:
|
|
62
|
+
"""Show parsed Apache runtime configuration."""
|
|
63
|
+
from apache_aiops.mcp_server.tools.httpd import apache_runtime_config
|
|
64
|
+
_print(apache_runtime_config(target=target))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("server-status")
|
|
68
|
+
@cli_errors
|
|
69
|
+
def server_status(target: Target = None) -> None:
|
|
70
|
+
"""Show configured mod_status auto metrics."""
|
|
71
|
+
from apache_aiops.mcp_server.tools.httpd import apache_server_status
|
|
72
|
+
_print(apache_server_status(target=target))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command("error-log")
|
|
76
|
+
@cli_errors
|
|
77
|
+
def error_log(limit: int = 50, target: Target = None) -> None:
|
|
78
|
+
"""Tail the operator-configured Apache error log."""
|
|
79
|
+
from apache_aiops.mcp_server.tools.httpd import apache_error_log
|
|
80
|
+
_print(apache_error_log(limit=limit, target=target))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("access-status")
|
|
84
|
+
@cli_errors
|
|
85
|
+
def access_status(limit: int = 200, target: Target = None) -> None:
|
|
86
|
+
"""Summarize recent common/combined access-log status codes."""
|
|
87
|
+
from apache_aiops.mcp_server.tools.httpd import apache_access_status
|
|
88
|
+
_print(apache_access_status(limit=limit, target=target))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command("graceful-reload")
|
|
92
|
+
@cli_errors
|
|
93
|
+
def graceful_reload(dry_run: bool = False, target: Target = None) -> None:
|
|
94
|
+
"""Check config and request an Apache graceful reload."""
|
|
95
|
+
from apache_aiops.mcp_server.tools.httpd import apache_graceful_reload
|
|
96
|
+
if not dry_run:
|
|
97
|
+
double_confirm("gracefully reload Apache", target or "default")
|
|
98
|
+
_print(apache_graceful_reload(dry_run=dry_run, target=target))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command("init")
|
|
102
|
+
@cli_errors
|
|
103
|
+
def init() -> None:
|
|
104
|
+
"""Configure a local Apache HTTP Server target."""
|
|
105
|
+
from apache_aiops.config import TargetConfig
|
|
106
|
+
|
|
107
|
+
name = typer.prompt("Target name", default="local")
|
|
108
|
+
probe_url = typer.prompt("HTTP health URL (optional)", default="")
|
|
109
|
+
status_url = typer.prompt("mod_status auto URL (optional)", default="")
|
|
110
|
+
error_log = typer.prompt("Absolute error log path (optional)", default="")
|
|
111
|
+
access_log = typer.prompt("Absolute access log path (optional)", default="")
|
|
112
|
+
chosen = TargetConfig(
|
|
113
|
+
name=name, probe_url=probe_url or None, status_url=status_url or None,
|
|
114
|
+
error_log=error_log or None, access_log=access_log or None,
|
|
115
|
+
)
|
|
116
|
+
path = ops_home() / "config.yaml"
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
if path.exists():
|
|
119
|
+
raise ValueError(f"Config exists at {path}; edit it to add a target.")
|
|
120
|
+
path.write_text(
|
|
121
|
+
yaml.safe_dump({"targets": [{
|
|
122
|
+
"name": chosen.name, "probe_url": chosen.probe_url,
|
|
123
|
+
"status_url": chosen.status_url, "error_log": chosen.error_log,
|
|
124
|
+
"access_log": chosen.access_log,
|
|
125
|
+
}]})
|
|
126
|
+
)
|
|
127
|
+
path.chmod(0o600)
|
|
128
|
+
console.print(f"Configured {name} at {path}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("version")
|
|
132
|
+
@cli_errors
|
|
133
|
+
def version(target: Target = None) -> None:
|
|
134
|
+
"""Show Apache version."""
|
|
135
|
+
from apache_aiops.mcp_server.tools.httpd import apache_version
|
|
136
|
+
|
|
137
|
+
_print(apache_version(target=target))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command("config-check")
|
|
141
|
+
@cli_errors
|
|
142
|
+
def config_check(target: Target = None) -> None:
|
|
143
|
+
"""Run Apache config syntax test."""
|
|
144
|
+
from apache_aiops.mcp_server.tools.httpd import apache_config_check
|
|
145
|
+
|
|
146
|
+
_print(apache_config_check(target=target))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("vhosts")
|
|
150
|
+
@cli_errors
|
|
151
|
+
def vhosts(target: Target = None) -> None:
|
|
152
|
+
"""Show parsed virtual-host map."""
|
|
153
|
+
from apache_aiops.mcp_server.tools.httpd import apache_vhosts
|
|
154
|
+
|
|
155
|
+
_print(apache_vhosts(target=target))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command("modules")
|
|
159
|
+
@cli_errors
|
|
160
|
+
def modules(target: Target = None) -> None:
|
|
161
|
+
"""List loaded Apache modules."""
|
|
162
|
+
from apache_aiops.mcp_server.tools.httpd import apache_modules
|
|
163
|
+
|
|
164
|
+
_print(apache_modules(target=target))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command("probe")
|
|
168
|
+
@cli_errors
|
|
169
|
+
def probe(target: Target = None) -> None:
|
|
170
|
+
"""Probe configured HTTP health URL."""
|
|
171
|
+
from apache_aiops.mcp_server.tools.httpd import apache_probe
|
|
172
|
+
|
|
173
|
+
_print(apache_probe(target=target))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command("diagnose")
|
|
177
|
+
@cli_errors
|
|
178
|
+
def diagnose(target: Target = None) -> None:
|
|
179
|
+
"""Correlate Apache config and HTTP probes."""
|
|
180
|
+
from apache_aiops.mcp_server.tools.httpd import apache_diagnose
|
|
181
|
+
|
|
182
|
+
_print(apache_diagnose(target=target))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command("doctor")
|
|
186
|
+
@cli_errors
|
|
187
|
+
def doctor(target: Target = None) -> None:
|
|
188
|
+
"""Test access to apachectl -v."""
|
|
189
|
+
from apache_aiops.mcp_server.tools.httpd import apache_version
|
|
190
|
+
|
|
191
|
+
_print(apache_version(target=target))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command("mcp")
|
|
195
|
+
@cli_errors
|
|
196
|
+
def mcp_cmd() -> None:
|
|
197
|
+
"""Start stdio MCP server."""
|
|
198
|
+
if sys.version_info < (3, 11):
|
|
199
|
+
raise typer.Exit(2)
|
|
200
|
+
from apache_aiops.mcp_server.server import main
|
|
201
|
+
|
|
202
|
+
main()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
app()
|
apache_aiops/cli/undo.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""``apache-aiops undo`` — list recorded undo tokens and apply them.
|
|
2
|
+
|
|
3
|
+
Real execution is delegated to the ``@governed_tool``-wrapped functions in
|
|
4
|
+
``apache_aiops.mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
|
|
5
|
+
path as any other write (the inverse tool it dispatches is itself governed).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from apache_aiops.cli._common import (
|
|
16
|
+
DryRunOption,
|
|
17
|
+
TargetOption,
|
|
18
|
+
cli_errors,
|
|
19
|
+
console,
|
|
20
|
+
double_confirm,
|
|
21
|
+
dry_run_print,
|
|
22
|
+
governed,
|
|
23
|
+
print_truncation_note,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
undo_app = typer.Typer(
|
|
27
|
+
name="undo",
|
|
28
|
+
help="List recorded undo tokens and apply their inverse operations.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
UndoIdArg = Annotated[str, typer.Argument(help="Undo id from 'undo list'")]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@undo_app.command("list")
|
|
36
|
+
@cli_errors
|
|
37
|
+
def undo_list_cmd(
|
|
38
|
+
limit: Annotated[int, typer.Option("--limit", help="Max tokens to show")] = 50,
|
|
39
|
+
target: TargetOption = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""List recorded, not-yet-applied undo tokens."""
|
|
42
|
+
from apache_aiops.mcp_server.tools import undo as gov
|
|
43
|
+
|
|
44
|
+
result = governed(gov.undo_list(limit=limit, target=target))
|
|
45
|
+
console.print_json(json.dumps(result))
|
|
46
|
+
print_truncation_note(result, "undo tokens")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@undo_app.command("apply")
|
|
50
|
+
@cli_errors
|
|
51
|
+
def undo_apply_cmd(
|
|
52
|
+
undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
|
|
55
|
+
from apache_aiops.mcp_server.tools import undo as gov
|
|
56
|
+
|
|
57
|
+
if dry_run:
|
|
58
|
+
preview = governed(gov.undo_apply(undo_id=undo_id, dry_run=True, target=target))
|
|
59
|
+
dry_run_print(
|
|
60
|
+
operation="undo_apply",
|
|
61
|
+
api_call=f"inverse: {preview.get('wouldApply', {}).get('tool', '?')}",
|
|
62
|
+
parameters=preview.get("wouldApply", {}).get("params", {}),
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
double_confirm("apply undo", undo_id)
|
|
66
|
+
console.print_json(json.dumps(governed(gov.undo_apply(undo_id=undo_id, target=target))))
|
apache_aiops/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Local Apache HTTP Server target configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from apache_aiops.governance.paths import ops_home
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class TargetConfig:
|
|
17
|
+
name: str
|
|
18
|
+
apachectl_bin: str = "apachectl"
|
|
19
|
+
probe_url: str | None = None
|
|
20
|
+
status_url: str | None = None
|
|
21
|
+
error_log: str | None = None
|
|
22
|
+
access_log: str | None = None
|
|
23
|
+
timeout: float = 10.0
|
|
24
|
+
verify_ssl: bool = True
|
|
25
|
+
ca_file: str | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not self.name or not self.apachectl_bin or self.apachectl_bin.startswith("-"):
|
|
29
|
+
raise ValueError("name and apachectl_bin are required.")
|
|
30
|
+
if self.timeout <= 0:
|
|
31
|
+
raise ValueError("timeout must be positive.")
|
|
32
|
+
for configured_url in (self.probe_url, self.status_url):
|
|
33
|
+
if not configured_url:
|
|
34
|
+
continue
|
|
35
|
+
url = urlsplit(configured_url)
|
|
36
|
+
if url.scheme not in ("http", "https") or not url.hostname or url.username:
|
|
37
|
+
raise ValueError("Probe/status URL must be HTTP(S) without embedded credentials.")
|
|
38
|
+
for configured_log in (self.error_log, self.access_log):
|
|
39
|
+
if not configured_log:
|
|
40
|
+
continue
|
|
41
|
+
path = Path(configured_log)
|
|
42
|
+
if not path.is_absolute():
|
|
43
|
+
raise ValueError("Log path must be an absolute operator-configured path.")
|
|
44
|
+
if path.resolve().is_relative_to(ops_home().resolve()):
|
|
45
|
+
raise ValueError("Log path cannot read this tool's audit/config directory.")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def verify(self) -> bool | str:
|
|
49
|
+
return self.ca_file or True if self.verify_ssl else False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class AppConfig:
|
|
54
|
+
targets: tuple[TargetConfig, ...] = ()
|
|
55
|
+
|
|
56
|
+
def get_target(self, name: str | None = None) -> TargetConfig:
|
|
57
|
+
if name is None:
|
|
58
|
+
if not self.targets:
|
|
59
|
+
raise ValueError("No Apache target configured. Run 'apache-aiops init'.")
|
|
60
|
+
return self.targets[0]
|
|
61
|
+
for target in self.targets:
|
|
62
|
+
if target.name == name:
|
|
63
|
+
return target
|
|
64
|
+
raise KeyError(f"Unknown Apache target '{name}'.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
68
|
+
path = path or Path(os.environ.get("APACHE_AIOPS_CONFIG", ops_home() / "config.yaml"))
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
raise FileNotFoundError(f"No Apache config at {path}. Run 'apache-aiops init'.")
|
|
71
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
72
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("targets"), list):
|
|
73
|
+
raise ValueError("Apache config must have a targets list.")
|
|
74
|
+
return AppConfig(tuple(TargetConfig(**entry) for entry in raw["targets"]))
|