ceph-aiops 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.
- ceph_aiops/__init__.py +9 -0
- ceph_aiops/cli/__init__.py +9 -0
- ceph_aiops/cli/_common.py +78 -0
- ceph_aiops/cli/_root.py +57 -0
- ceph_aiops/cli/doctor.py +21 -0
- ceph_aiops/cli/health.py +35 -0
- ceph_aiops/cli/init.py +103 -0
- ceph_aiops/cli/osd.py +95 -0
- ceph_aiops/cli/overview.py +16 -0
- ceph_aiops/cli/secret.py +103 -0
- ceph_aiops/config.py +139 -0
- ceph_aiops/connection.py +205 -0
- ceph_aiops/doctor.py +86 -0
- ceph_aiops/governance/__init__.py +40 -0
- ceph_aiops/governance/audit.py +377 -0
- ceph_aiops/governance/budget.py +225 -0
- ceph_aiops/governance/decorators.py +474 -0
- ceph_aiops/governance/paths.py +23 -0
- ceph_aiops/governance/patterns.py +378 -0
- ceph_aiops/governance/policy.py +411 -0
- ceph_aiops/governance/sanitize.py +39 -0
- ceph_aiops/governance/undo.py +218 -0
- ceph_aiops/ops/__init__.py +1 -0
- ceph_aiops/ops/_util.py +44 -0
- ceph_aiops/ops/clusterops.py +192 -0
- ceph_aiops/ops/filesystem.py +107 -0
- ceph_aiops/ops/health.py +177 -0
- ceph_aiops/ops/osd.py +119 -0
- ceph_aiops/ops/overview.py +38 -0
- ceph_aiops/ops/pg.py +155 -0
- ceph_aiops/ops/pool.py +179 -0
- ceph_aiops/ops/rbd.py +77 -0
- ceph_aiops/secretstore.py +302 -0
- ceph_aiops-0.1.0.dist-info/METADATA +137 -0
- ceph_aiops-0.1.0.dist-info/RECORD +49 -0
- ceph_aiops-0.1.0.dist-info/WHEEL +4 -0
- ceph_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- ceph_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +102 -0
- mcp_server/server.py +36 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/clusterops.py +121 -0
- mcp_server/tools/filesystem.py +37 -0
- mcp_server/tools/health.py +35 -0
- mcp_server/tools/osd.py +168 -0
- mcp_server/tools/pg.py +84 -0
- mcp_server/tools/pool.py +195 -0
- mcp_server/tools/rbd.py +106 -0
ceph_aiops/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""ceph-aiops — governed Ceph SCALE operations for AI agents.
|
|
2
|
+
|
|
3
|
+
Standalone and self-contained: the governance harness (audit, token budget,
|
|
4
|
+
undo-token recording, graduated risk tiers, prompt-injection sanitize) is
|
|
5
|
+
bundled under ``ceph_aiops.governance`` — this package has no external
|
|
6
|
+
skill-family dependency. Preview: not yet full-coverage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared helpers for ceph-aiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# ─── Shared Option types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
TargetOption = Annotated[
|
|
18
|
+
str | None, typer.Option("--target", "-t", help="Target name from config")
|
|
19
|
+
]
|
|
20
|
+
DryRunOption = Annotated[
|
|
21
|
+
bool, typer.Option("--dry-run", help="Print the API call without executing")
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
26
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback."""
|
|
27
|
+
from ceph_aiops.connection import CephApiError
|
|
28
|
+
|
|
29
|
+
return (CephApiError, KeyError, OSError, ValueError)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
33
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
34
|
+
|
|
35
|
+
@functools.wraps(fn)
|
|
36
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
37
|
+
try:
|
|
38
|
+
return fn(*args, **kwargs)
|
|
39
|
+
except (typer.Exit, typer.Abort):
|
|
40
|
+
raise
|
|
41
|
+
except _cli_error_types() as e:
|
|
42
|
+
message = str(e)
|
|
43
|
+
if isinstance(e, KeyError):
|
|
44
|
+
message = f"Missing required key or environment variable: {message}"
|
|
45
|
+
console.print(f"[red]Error: {message}[/]")
|
|
46
|
+
raise typer.Exit(1) from e
|
|
47
|
+
|
|
48
|
+
return wrapper
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_connection(target: str | None, config_path: Path | None = None):
|
|
52
|
+
"""Return a (conn, config) tuple for the given target."""
|
|
53
|
+
from ceph_aiops.config import load_config
|
|
54
|
+
from ceph_aiops.connection import ConnectionManager
|
|
55
|
+
|
|
56
|
+
cfg = load_config(config_path)
|
|
57
|
+
mgr = ConnectionManager(cfg)
|
|
58
|
+
return mgr.connect(target), cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
|
|
62
|
+
"""Print a dry-run preview of the API call that would be made."""
|
|
63
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
|
|
64
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
65
|
+
console.print(f"[magenta] API Call: {api_call}[/]")
|
|
66
|
+
for k, v in (parameters or {}).items():
|
|
67
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
68
|
+
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
72
|
+
"""Require two confirmations for a destructive operation."""
|
|
73
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
74
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
75
|
+
typer.confirm(
|
|
76
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
77
|
+
abort=True,
|
|
78
|
+
)
|
ceph_aiops/cli/_root.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Top-level Typer app: assembles sub-apps and top-level commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ceph_aiops.cli._common import cli_errors
|
|
8
|
+
from ceph_aiops.cli.doctor import doctor_cmd
|
|
9
|
+
from ceph_aiops.cli.health import health_app
|
|
10
|
+
from ceph_aiops.cli.init import init_cmd
|
|
11
|
+
from ceph_aiops.cli.osd import osd_app
|
|
12
|
+
from ceph_aiops.cli.overview import overview_cmd
|
|
13
|
+
from ceph_aiops.cli.secret import secret_app
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="ceph-aiops",
|
|
17
|
+
help="Governed AI-ops for Ceph (mgr Dashboard REST): health RCA, OSD/PG/pool, "
|
|
18
|
+
"RBD/CephFS/RGW, recovery, capacity.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app.add_typer(health_app, name="health")
|
|
23
|
+
app.add_typer(osd_app, name="osd")
|
|
24
|
+
app.add_typer(secret_app, name="secret")
|
|
25
|
+
app.command("init")(init_cmd)
|
|
26
|
+
app.command("overview")(overview_cmd)
|
|
27
|
+
app.command("doctor")(doctor_cmd)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("mcp")
|
|
31
|
+
@cli_errors
|
|
32
|
+
def mcp_cmd() -> None:
|
|
33
|
+
"""Start the MCP server (stdio transport).
|
|
34
|
+
|
|
35
|
+
Single-command entry point for MCP clients (does not go through uvx/PyPI
|
|
36
|
+
resolution at launch):
|
|
37
|
+
ceph-aiops mcp
|
|
38
|
+
"""
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
if sys.version_info < (3, 11):
|
|
42
|
+
typer.echo(
|
|
43
|
+
f"ERROR: ceph-aiops requires Python >= 3.11 "
|
|
44
|
+
f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
|
|
45
|
+
f"Fix: uv python install 3.12 && "
|
|
46
|
+
f"uv tool install --python 3.12 --force ceph-aiops",
|
|
47
|
+
err=True,
|
|
48
|
+
)
|
|
49
|
+
raise typer.Exit(2)
|
|
50
|
+
|
|
51
|
+
from mcp_server.server import main as _mcp_main
|
|
52
|
+
|
|
53
|
+
_mcp_main()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
app()
|
ceph_aiops/cli/doctor.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Doctor top-level command: environment and connectivity check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ceph_aiops.cli._common import cli_errors
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cli_errors
|
|
13
|
+
def doctor_cmd(
|
|
14
|
+
skip_auth: Annotated[
|
|
15
|
+
bool, typer.Option("--skip-auth", help="Skip connectivity check (faster)")
|
|
16
|
+
] = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Check environment, config, secrets, and connectivity."""
|
|
19
|
+
from ceph_aiops.doctor import run_doctor
|
|
20
|
+
|
|
21
|
+
raise typer.Exit(run_doctor(skip_auth=skip_auth))
|
ceph_aiops/cli/health.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""``ceph-aiops health`` — cluster health + status reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ceph_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
|
|
11
|
+
health_app = typer.Typer(
|
|
12
|
+
name="health",
|
|
13
|
+
help="Cluster health (RCA) and status.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@health_app.command("detail")
|
|
19
|
+
@cli_errors
|
|
20
|
+
def health_detail(target: TargetOption = None) -> None:
|
|
21
|
+
"""HEALTH status with a cause + suggested action per active check."""
|
|
22
|
+
from ceph_aiops.ops import health as ops
|
|
23
|
+
|
|
24
|
+
conn, _ = get_connection(target)
|
|
25
|
+
console.print_json(json.dumps(ops.cluster_health(conn)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@health_app.command("status")
|
|
29
|
+
@cli_errors
|
|
30
|
+
def health_status(target: TargetOption = None) -> None:
|
|
31
|
+
"""Compact ceph -s summary (mons, OSDs, PGs, usage)."""
|
|
32
|
+
from ceph_aiops.ops import health as ops
|
|
33
|
+
|
|
34
|
+
conn, _ = get_connection(target)
|
|
35
|
+
console.print_json(json.dumps(ops.cluster_status(conn)))
|
ceph_aiops/cli/init.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``ceph-aiops init`` — a friendly, interactive onboarding wizard.
|
|
2
|
+
|
|
3
|
+
Walks a new user through connecting their first Ceph SCALE target: collects
|
|
4
|
+
the non-secret connection details into ``config.yaml`` and the API key into the
|
|
5
|
+
*encrypted* store (never plaintext on disk). Designed to be run on a terminal;
|
|
6
|
+
everything it needs is prompted with sensible defaults.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from ceph_aiops.cli._common import cli_errors, console
|
|
17
|
+
from ceph_aiops.config import CONFIG_DIR, CONFIG_FILE, DEFAULT_MGR_PORT, DEFAULT_USERNAME
|
|
18
|
+
from ceph_aiops.secretstore import SecretStore, resolve_master_password
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_existing_targets() -> list[dict]:
|
|
22
|
+
if not CONFIG_FILE.exists():
|
|
23
|
+
return []
|
|
24
|
+
raw = yaml.safe_load(CONFIG_FILE.read_text("utf-8")) or {}
|
|
25
|
+
return list(raw.get("targets", []))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_targets(targets: list[dict]) -> None:
|
|
29
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
try:
|
|
31
|
+
CONFIG_DIR.chmod(0o700)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
CONFIG_FILE.write_text(yaml.safe_dump({"targets": targets}, sort_keys=False), "utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cli_errors
|
|
38
|
+
def init_cmd() -> None:
|
|
39
|
+
"""Interactively set up your first Ceph connection."""
|
|
40
|
+
console.print("[bold cyan]Ceph AIops — setup wizard[/]")
|
|
41
|
+
console.print(
|
|
42
|
+
"This collects ceph-mgr Dashboard connection details (saved to "
|
|
43
|
+
"config.yaml) and your Dashboard password (saved [bold]encrypted[/] to "
|
|
44
|
+
"secrets.enc).\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
console.print("[bold]Step 1 — master password[/]")
|
|
48
|
+
console.print(
|
|
49
|
+
"[dim]Encrypts secrets.enc. You'll set it via the "
|
|
50
|
+
"CEPH_AIOPS_MASTER_PASSWORD env var for non-interactive/MCP use.[/]"
|
|
51
|
+
)
|
|
52
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
53
|
+
store = SecretStore.unlock(password)
|
|
54
|
+
|
|
55
|
+
targets = _load_existing_targets()
|
|
56
|
+
existing_names = {t.get("name") for t in targets}
|
|
57
|
+
|
|
58
|
+
while True:
|
|
59
|
+
console.print("\n[bold]Step 2 — add a Ceph cluster[/]")
|
|
60
|
+
name = typer.prompt("Target name (e.g. lab1)").strip()
|
|
61
|
+
if name in existing_names:
|
|
62
|
+
if not typer.confirm(f"'{name}' already exists — overwrite?", default=False):
|
|
63
|
+
continue
|
|
64
|
+
targets = [t for t in targets if t.get("name") != name]
|
|
65
|
+
|
|
66
|
+
host = typer.prompt("Host (IP or FQDN of the active ceph-mgr)").strip()
|
|
67
|
+
port = typer.prompt("Dashboard HTTPS port", default=DEFAULT_MGR_PORT, type=int)
|
|
68
|
+
username = typer.prompt("Dashboard username", default=DEFAULT_USERNAME).strip()
|
|
69
|
+
verify_ssl = typer.confirm(
|
|
70
|
+
"Verify TLS certificate? (No for self-signed lab certs)", default=False
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
console.print(
|
|
74
|
+
"[dim]Enter the ceph-mgr Dashboard password for this account "
|
|
75
|
+
"(input hidden). The mgr 'dashboard' module must be enabled.[/]"
|
|
76
|
+
)
|
|
77
|
+
secret = getpass.getpass(f"Password for '{username}@{name}' (hidden): ")
|
|
78
|
+
store = store.set(name, secret)
|
|
79
|
+
|
|
80
|
+
entry = {
|
|
81
|
+
"name": name,
|
|
82
|
+
"host": host,
|
|
83
|
+
"port": port,
|
|
84
|
+
"username": username,
|
|
85
|
+
"verify_ssl": verify_ssl,
|
|
86
|
+
}
|
|
87
|
+
targets.append(entry)
|
|
88
|
+
existing_names.add(name)
|
|
89
|
+
_write_targets(targets)
|
|
90
|
+
console.print(f"[green]✓ Saved target '{name}' (password stored encrypted).[/]")
|
|
91
|
+
|
|
92
|
+
if not typer.confirm("\nAdd another target?", default=False):
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
|
|
96
|
+
console.print(
|
|
97
|
+
"[dim]Tip: export CEPH_AIOPS_MASTER_PASSWORD=... in your shell profile "
|
|
98
|
+
"so the MCP server and CLI can unlock secrets non-interactively.[/]"
|
|
99
|
+
)
|
|
100
|
+
if typer.confirm("Run a connectivity check now (ceph-aiops doctor)?", default=True):
|
|
101
|
+
from ceph_aiops.doctor import run_doctor
|
|
102
|
+
|
|
103
|
+
raise typer.Exit(run_doctor())
|
ceph_aiops/cli/osd.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""``ceph-aiops osd`` — OSD reads + guarded lifecycle writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ceph_aiops.cli._common import (
|
|
11
|
+
DryRunOption,
|
|
12
|
+
TargetOption,
|
|
13
|
+
cli_errors,
|
|
14
|
+
console,
|
|
15
|
+
double_confirm,
|
|
16
|
+
dry_run_print,
|
|
17
|
+
get_connection,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
osd_app = typer.Typer(
|
|
21
|
+
name="osd",
|
|
22
|
+
help="OSDs: tree/df/perf, reweight, mark in/out, purge.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
IdArg = Annotated[int, typer.Argument(help="Numeric OSD id (from 'osd tree')")]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@osd_app.command("tree")
|
|
30
|
+
@cli_errors
|
|
31
|
+
def osd_tree(target: TargetOption = None) -> None:
|
|
32
|
+
"""List OSDs (up/in, weight, host, device class)."""
|
|
33
|
+
from ceph_aiops.ops import osd as ops
|
|
34
|
+
|
|
35
|
+
conn, _ = get_connection(target)
|
|
36
|
+
console.print_json(json.dumps(ops.osd_tree(conn)))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@osd_app.command("df")
|
|
40
|
+
@cli_errors
|
|
41
|
+
def osd_df(target: TargetOption = None) -> None:
|
|
42
|
+
"""Per-OSD utilisation (most-full first)."""
|
|
43
|
+
from ceph_aiops.ops import osd as ops
|
|
44
|
+
|
|
45
|
+
conn, _ = get_connection(target)
|
|
46
|
+
console.print_json(json.dumps(ops.osd_df(conn)))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@osd_app.command("reweight")
|
|
50
|
+
@cli_errors
|
|
51
|
+
def osd_reweight(
|
|
52
|
+
osd_id: IdArg,
|
|
53
|
+
weight: Annotated[float, typer.Argument(help="New reweight 0.0–1.0 (0.0 = drain)")],
|
|
54
|
+
target: TargetOption = None,
|
|
55
|
+
dry_run: DryRunOption = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Set an OSD's reweight (0.0 drains it)."""
|
|
58
|
+
from ceph_aiops.ops import osd as ops
|
|
59
|
+
|
|
60
|
+
if dry_run:
|
|
61
|
+
dry_run_print(operation="osd_reweight",
|
|
62
|
+
api_call=f"POST /api/osd/{osd_id}/reweight",
|
|
63
|
+
parameters={"weight": weight})
|
|
64
|
+
return
|
|
65
|
+
conn, _ = get_connection(target)
|
|
66
|
+
console.print_json(json.dumps(ops.osd_reweight(conn, osd_id, weight)))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@osd_app.command("out")
|
|
70
|
+
@cli_errors
|
|
71
|
+
def osd_out(osd_id: IdArg, target: TargetOption = None, dry_run: DryRunOption = False) -> None:
|
|
72
|
+
"""Mark an OSD out — drains its data (dry-run + double confirm)."""
|
|
73
|
+
from ceph_aiops.ops import osd as ops
|
|
74
|
+
|
|
75
|
+
if dry_run:
|
|
76
|
+
dry_run_print(operation="mark_osd_out", api_call=f"POST /api/osd/{osd_id}/mark",
|
|
77
|
+
parameters={"action": "out"})
|
|
78
|
+
return
|
|
79
|
+
double_confirm("mark out OSD", str(osd_id))
|
|
80
|
+
conn, _ = get_connection(target)
|
|
81
|
+
console.print_json(json.dumps(ops.mark_osd(conn, osd_id, "out")))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@osd_app.command("purge")
|
|
85
|
+
@cli_errors
|
|
86
|
+
def osd_purge(osd_id: IdArg, target: TargetOption = None, dry_run: DryRunOption = False) -> None:
|
|
87
|
+
"""Purge an OSD — irreversible (dry-run + double confirm)."""
|
|
88
|
+
from ceph_aiops.ops import osd as ops
|
|
89
|
+
|
|
90
|
+
if dry_run:
|
|
91
|
+
dry_run_print(operation="osd_purge", api_call=f"DELETE /api/osd/{osd_id}")
|
|
92
|
+
return
|
|
93
|
+
double_confirm("purge OSD", str(osd_id))
|
|
94
|
+
conn, _ = get_connection(target)
|
|
95
|
+
console.print_json(json.dumps(ops.osd_purge(conn, osd_id)))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""``ceph-aiops overview`` — one-shot fleet health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from ceph_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cli_errors
|
|
11
|
+
def overview_cmd(target: TargetOption = None) -> None:
|
|
12
|
+
"""One-shot cluster summary: HEALTH status + active checks + OSD up/in counts."""
|
|
13
|
+
from ceph_aiops.ops import overview as ops
|
|
14
|
+
|
|
15
|
+
conn, _ = get_connection(target)
|
|
16
|
+
console.print_json(json.dumps(ops.fleet_overview(conn)))
|
ceph_aiops/cli/secret.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``ceph-aiops secret`` — manage the encrypted credential store.
|
|
2
|
+
|
|
3
|
+
Secrets (Ceph API keys) are stored in ``~/.ceph-aiops/secrets.enc``
|
|
4
|
+
(Fernet, key derived from a master password via scrypt). Nothing here ever
|
|
5
|
+
prints a secret value.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from ceph_aiops.cli._common import cli_errors, console
|
|
16
|
+
from ceph_aiops.config import SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX
|
|
17
|
+
from ceph_aiops.secretstore import (
|
|
18
|
+
SECRETS_FILE,
|
|
19
|
+
SecretStore,
|
|
20
|
+
check_permissions,
|
|
21
|
+
migrate_legacy_env,
|
|
22
|
+
resolve_master_password,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
secret_app = typer.Typer(
|
|
26
|
+
name="secret",
|
|
27
|
+
help="Manage the encrypted credential store (secrets.enc).",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
NameArg = Annotated[str, typer.Argument(help="Target name the API key belongs to")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@secret_app.command("set")
|
|
35
|
+
@cli_errors
|
|
36
|
+
def secret_set(
|
|
37
|
+
name: NameArg,
|
|
38
|
+
value: Annotated[
|
|
39
|
+
str | None,
|
|
40
|
+
typer.Option("--value", help="API key value (omit to be prompted, hidden)"),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Store (or replace) the API key for a target — value is read hidden."""
|
|
44
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
45
|
+
if value is None:
|
|
46
|
+
value = getpass.getpass(f"API key for '{name}' (hidden): ")
|
|
47
|
+
store = SecretStore.unlock(password)
|
|
48
|
+
store.set(name, value)
|
|
49
|
+
console.print(f"[green]✓ Stored encrypted API key for '{name}' in {SECRETS_FILE}[/]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@secret_app.command("list")
|
|
53
|
+
@cli_errors
|
|
54
|
+
def secret_list() -> None:
|
|
55
|
+
"""List target names that have a stored API key (values never shown)."""
|
|
56
|
+
store = SecretStore.unlock()
|
|
57
|
+
names = store.names()
|
|
58
|
+
if not names:
|
|
59
|
+
console.print("[yellow]No secrets stored yet. Add one: ceph-aiops secret set <name>[/]")
|
|
60
|
+
return
|
|
61
|
+
console.print("[bold]Stored secrets:[/]")
|
|
62
|
+
for n in names:
|
|
63
|
+
console.print(f" • {n}")
|
|
64
|
+
warning = check_permissions()
|
|
65
|
+
if warning:
|
|
66
|
+
console.print(f"[yellow]! {warning}[/]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@secret_app.command("rm")
|
|
70
|
+
@cli_errors
|
|
71
|
+
def secret_rm(name: NameArg) -> None:
|
|
72
|
+
"""Delete a stored API key."""
|
|
73
|
+
store = SecretStore.unlock()
|
|
74
|
+
store.delete(name)
|
|
75
|
+
console.print(f"[green]✓ Deleted API key for '{name}'[/]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@secret_app.command("migrate")
|
|
79
|
+
@cli_errors
|
|
80
|
+
def secret_migrate() -> None:
|
|
81
|
+
"""Import API keys from a legacy plaintext .env into the encrypted store."""
|
|
82
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
83
|
+
imported = migrate_legacy_env(SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX, password)
|
|
84
|
+
if not imported:
|
|
85
|
+
console.print("[yellow]Nothing to migrate (no legacy .env secrets found).[/]")
|
|
86
|
+
return
|
|
87
|
+
console.print(f"[green]✓ Imported {len(imported)} secret(s): {', '.join(imported)}[/]")
|
|
88
|
+
console.print("[dim]The old .env was renamed to .env.migrated — delete it once verified.[/]")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@secret_app.command("rotate-password")
|
|
92
|
+
@cli_errors
|
|
93
|
+
def secret_rotate_password() -> None:
|
|
94
|
+
"""Re-encrypt the whole store under a new master password."""
|
|
95
|
+
console.print("[bold]Unlock with the current master password:[/]")
|
|
96
|
+
store = SecretStore.unlock()
|
|
97
|
+
new_pw = getpass.getpass("New master password: ")
|
|
98
|
+
confirm = getpass.getpass("Confirm new master password: ")
|
|
99
|
+
if new_pw != confirm:
|
|
100
|
+
console.print("[red]Passwords did not match. Aborted.[/]")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
store.with_password(new_pw)
|
|
103
|
+
console.print("[green]✓ Master password rotated. Update CEPH_AIOPS_MASTER_PASSWORD.[/]")
|
ceph_aiops/config.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Configuration management for Ceph AIops.
|
|
2
|
+
|
|
3
|
+
Loads Ceph mgr (Dashboard REST) connection targets from a YAML config file. The
|
|
4
|
+
secret (the Ceph Dashboard account **password**) is NEVER stored in the config
|
|
5
|
+
file and never on disk in plaintext: it lives in the encrypted store
|
|
6
|
+
``~/.ceph-aiops/secrets.enc`` (see :mod:`ceph_aiops.secretstore`). For backward
|
|
7
|
+
compatibility a legacy plaintext env var (``CEPH_<TARGET>_PASSWORD``) is still
|
|
8
|
+
honoured as a fallback, with a warning nudging migration to the encrypted store.
|
|
9
|
+
|
|
10
|
+
A "target" is one Ceph cluster reached through its **ceph-mgr Dashboard REST
|
|
11
|
+
API** (default HTTPS port ``8443``). Auth is username + password exchanged for a
|
|
12
|
+
short-lived JWT at ``POST /api/auth`` (handled in
|
|
13
|
+
:mod:`ceph_aiops.connection`). ``username`` lives in the config file (it is not a
|
|
14
|
+
secret); the password lives encrypted.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from ceph_aiops.secretstore import SecretStoreError, get_secret, has_store
|
|
27
|
+
|
|
28
|
+
CONFIG_DIR = Path.home() / ".ceph-aiops"
|
|
29
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
30
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
31
|
+
|
|
32
|
+
# Ceph mgr Dashboard defaults.
|
|
33
|
+
DEFAULT_MGR_PORT = 8443
|
|
34
|
+
DEFAULT_USERNAME = "admin"
|
|
35
|
+
|
|
36
|
+
# Legacy env-var prefix/suffix; also used by the migration helper.
|
|
37
|
+
SECRET_ENV_PREFIX = "CEPH_" # nosec B105 — env-var name, not a secret
|
|
38
|
+
SECRET_ENV_SUFFIX = "_PASSWORD" # nosec B105 — env-var name, not a secret
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger("ceph-aiops.config")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _secret_env_key(name: str) -> str:
|
|
44
|
+
"""Legacy per-target password env var name, e.g. CEPH_LAB1_PASSWORD."""
|
|
45
|
+
return f"{SECRET_ENV_PREFIX}{name.upper().replace('-', '_')}{SECRET_ENV_SUFFIX}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_secret(name: str) -> str:
|
|
49
|
+
"""Return a target's password: encrypted store first, then legacy env var."""
|
|
50
|
+
if has_store():
|
|
51
|
+
try:
|
|
52
|
+
return get_secret(name)
|
|
53
|
+
except SecretStoreError:
|
|
54
|
+
pass # fall through to legacy env var
|
|
55
|
+
legacy = os.environ.get(_secret_env_key(name))
|
|
56
|
+
if legacy:
|
|
57
|
+
_log.warning(
|
|
58
|
+
"Using plaintext env var %s. Migrate to the encrypted store with "
|
|
59
|
+
"'ceph-aiops secret migrate'.",
|
|
60
|
+
_secret_env_key(name),
|
|
61
|
+
)
|
|
62
|
+
return legacy
|
|
63
|
+
raise OSError(
|
|
64
|
+
f"No password for target '{name}'. Add one with "
|
|
65
|
+
f"'ceph-aiops secret set {name}' (stored encrypted), or run "
|
|
66
|
+
f"'ceph-aiops init'."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class TargetConfig:
|
|
72
|
+
"""A connection target for one Ceph cluster's mgr Dashboard REST API.
|
|
73
|
+
|
|
74
|
+
The password is sourced from the encrypted secret store (see ``password``),
|
|
75
|
+
never the config file. ``host`` is the active mgr host/FQDN; ``port``
|
|
76
|
+
defaults to ``8443``; ``username`` (default ``admin``) is the Dashboard
|
|
77
|
+
account and is not a secret.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
host: str
|
|
82
|
+
port: int = DEFAULT_MGR_PORT
|
|
83
|
+
username: str = DEFAULT_USERNAME
|
|
84
|
+
verify_ssl: bool = True
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def password(self) -> str:
|
|
88
|
+
return _resolve_secret(self.name)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def base_url(self) -> str:
|
|
92
|
+
return f"https://{self.host}:{self.port}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class AppConfig:
|
|
97
|
+
"""Top-level application config."""
|
|
98
|
+
|
|
99
|
+
targets: tuple[TargetConfig, ...] = ()
|
|
100
|
+
|
|
101
|
+
def get_target(self, name: str) -> TargetConfig:
|
|
102
|
+
for t in self.targets:
|
|
103
|
+
if t.name == name:
|
|
104
|
+
return t
|
|
105
|
+
available = ", ".join(t.name for t in self.targets) or "(none)"
|
|
106
|
+
raise KeyError(f"Target '{name}' not found. Available: {available}")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def default_target(self) -> TargetConfig:
|
|
110
|
+
if not self.targets:
|
|
111
|
+
raise ValueError("No targets configured. Check config.yaml")
|
|
112
|
+
return self.targets[0]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_config(config_path: Path | None = None) -> AppConfig:
|
|
116
|
+
"""Load config from YAML; the password comes from the encrypted store."""
|
|
117
|
+
path = config_path or CONFIG_FILE
|
|
118
|
+
if not path.exists():
|
|
119
|
+
raise FileNotFoundError(
|
|
120
|
+
f"Config file not found: {path}\n"
|
|
121
|
+
f"Run 'ceph-aiops init' to set up a Ceph cluster target and store its "
|
|
122
|
+
f"password encrypted, or create {CONFIG_FILE} with a 'targets' list."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
with open(path) as f:
|
|
126
|
+
raw = yaml.safe_load(f) or {}
|
|
127
|
+
|
|
128
|
+
targets = tuple(
|
|
129
|
+
TargetConfig(
|
|
130
|
+
name=t["name"],
|
|
131
|
+
host=t["host"],
|
|
132
|
+
port=t.get("port", DEFAULT_MGR_PORT),
|
|
133
|
+
username=t.get("username", DEFAULT_USERNAME),
|
|
134
|
+
verify_ssl=t.get("verify_ssl", True),
|
|
135
|
+
)
|
|
136
|
+
for t in raw.get("targets", [])
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return AppConfig(targets=targets)
|