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,8 @@
1
+ """Apache AIops: governed Apache HTTP Server operations."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("apache-aiops")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,9 @@
1
+ """CLI package for apache-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``apache-aiops = "apache_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from apache_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -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()
@@ -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"]))