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.
Files changed (49) hide show
  1. ceph_aiops/__init__.py +9 -0
  2. ceph_aiops/cli/__init__.py +9 -0
  3. ceph_aiops/cli/_common.py +78 -0
  4. ceph_aiops/cli/_root.py +57 -0
  5. ceph_aiops/cli/doctor.py +21 -0
  6. ceph_aiops/cli/health.py +35 -0
  7. ceph_aiops/cli/init.py +103 -0
  8. ceph_aiops/cli/osd.py +95 -0
  9. ceph_aiops/cli/overview.py +16 -0
  10. ceph_aiops/cli/secret.py +103 -0
  11. ceph_aiops/config.py +139 -0
  12. ceph_aiops/connection.py +205 -0
  13. ceph_aiops/doctor.py +86 -0
  14. ceph_aiops/governance/__init__.py +40 -0
  15. ceph_aiops/governance/audit.py +377 -0
  16. ceph_aiops/governance/budget.py +225 -0
  17. ceph_aiops/governance/decorators.py +474 -0
  18. ceph_aiops/governance/paths.py +23 -0
  19. ceph_aiops/governance/patterns.py +378 -0
  20. ceph_aiops/governance/policy.py +411 -0
  21. ceph_aiops/governance/sanitize.py +39 -0
  22. ceph_aiops/governance/undo.py +218 -0
  23. ceph_aiops/ops/__init__.py +1 -0
  24. ceph_aiops/ops/_util.py +44 -0
  25. ceph_aiops/ops/clusterops.py +192 -0
  26. ceph_aiops/ops/filesystem.py +107 -0
  27. ceph_aiops/ops/health.py +177 -0
  28. ceph_aiops/ops/osd.py +119 -0
  29. ceph_aiops/ops/overview.py +38 -0
  30. ceph_aiops/ops/pg.py +155 -0
  31. ceph_aiops/ops/pool.py +179 -0
  32. ceph_aiops/ops/rbd.py +77 -0
  33. ceph_aiops/secretstore.py +302 -0
  34. ceph_aiops-0.1.0.dist-info/METADATA +137 -0
  35. ceph_aiops-0.1.0.dist-info/RECORD +49 -0
  36. ceph_aiops-0.1.0.dist-info/WHEEL +4 -0
  37. ceph_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  38. ceph_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. mcp_server/__init__.py +1 -0
  40. mcp_server/_shared.py +102 -0
  41. mcp_server/server.py +36 -0
  42. mcp_server/tools/__init__.py +1 -0
  43. mcp_server/tools/clusterops.py +121 -0
  44. mcp_server/tools/filesystem.py +37 -0
  45. mcp_server/tools/health.py +35 -0
  46. mcp_server/tools/osd.py +168 -0
  47. mcp_server/tools/pg.py +84 -0
  48. mcp_server/tools/pool.py +195 -0
  49. 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,9 @@
1
+ """CLI package for ceph-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``ceph-aiops = "ceph_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from ceph_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -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
+ )
@@ -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()
@@ -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))
@@ -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)))
@@ -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)