container-host-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.
- container_host_aiops/__init__.py +9 -0
- container_host_aiops/cli/__init__.py +9 -0
- container_host_aiops/cli/_common.py +78 -0
- container_host_aiops/cli/_root.py +70 -0
- container_host_aiops/cli/analyze.py +55 -0
- container_host_aiops/cli/container.py +85 -0
- container_host_aiops/cli/doctor.py +21 -0
- container_host_aiops/cli/image.py +61 -0
- container_host_aiops/cli/init.py +126 -0
- container_host_aiops/cli/manage.py +192 -0
- container_host_aiops/cli/network.py +38 -0
- container_host_aiops/cli/overview.py +16 -0
- container_host_aiops/cli/secret.py +108 -0
- container_host_aiops/cli/stack.py +48 -0
- container_host_aiops/cli/system.py +60 -0
- container_host_aiops/cli/volume.py +48 -0
- container_host_aiops/config.py +198 -0
- container_host_aiops/connection.py +227 -0
- container_host_aiops/doctor.py +98 -0
- container_host_aiops/governance/__init__.py +40 -0
- container_host_aiops/governance/audit.py +377 -0
- container_host_aiops/governance/budget.py +225 -0
- container_host_aiops/governance/decorators.py +474 -0
- container_host_aiops/governance/paths.py +23 -0
- container_host_aiops/governance/patterns.py +378 -0
- container_host_aiops/governance/policy.py +411 -0
- container_host_aiops/governance/sanitize.py +39 -0
- container_host_aiops/governance/undo.py +218 -0
- container_host_aiops/ops/__init__.py +1 -0
- container_host_aiops/ops/_metrics.py +83 -0
- container_host_aiops/ops/_util.py +90 -0
- container_host_aiops/ops/analyses.py +311 -0
- container_host_aiops/ops/containers.py +173 -0
- container_host_aiops/ops/images.py +124 -0
- container_host_aiops/ops/networks.py +53 -0
- container_host_aiops/ops/overview.py +45 -0
- container_host_aiops/ops/stacks.py +69 -0
- container_host_aiops/ops/system.py +163 -0
- container_host_aiops/ops/volumes.py +72 -0
- container_host_aiops/ops/writes.py +209 -0
- container_host_aiops/platform.py +146 -0
- container_host_aiops/secretstore.py +302 -0
- container_host_aiops-0.1.0.dist-info/METADATA +96 -0
- container_host_aiops-0.1.0.dist-info/RECORD +59 -0
- container_host_aiops-0.1.0.dist-info/WHEEL +4 -0
- container_host_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- container_host_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +104 -0
- mcp_server/server.py +38 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/analyses.py +93 -0
- mcp_server/tools/containers.py +88 -0
- mcp_server/tools/images.py +57 -0
- mcp_server/tools/networks.py +32 -0
- mcp_server/tools/stacks.py +50 -0
- mcp_server/tools/system.py +75 -0
- mcp_server/tools/volumes.py +44 -0
- mcp_server/tools/writes.py +260 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""container-host-aiops — governed Docker + Portainer container-host ops 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 ``container_host_aiops.governance`` — this package has no external
|
|
6
|
+
skill-family dependency. Preview: not yet full-coverage, mock-validated only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared helpers for container-host-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 container_host_aiops.connection import ContainerHostApiError
|
|
28
|
+
|
|
29
|
+
return (ContainerHostApiError, 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 container_host_aiops.config import load_config
|
|
54
|
+
from container_host_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,70 @@
|
|
|
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 container_host_aiops.cli._common import cli_errors
|
|
8
|
+
from container_host_aiops.cli.analyze import analyze_app
|
|
9
|
+
from container_host_aiops.cli.container import container_app
|
|
10
|
+
from container_host_aiops.cli.doctor import doctor_cmd
|
|
11
|
+
from container_host_aiops.cli.image import image_app
|
|
12
|
+
from container_host_aiops.cli.init import init_cmd
|
|
13
|
+
from container_host_aiops.cli.manage import manage_app
|
|
14
|
+
from container_host_aiops.cli.network import network_app
|
|
15
|
+
from container_host_aiops.cli.overview import overview_cmd
|
|
16
|
+
from container_host_aiops.cli.secret import secret_app
|
|
17
|
+
from container_host_aiops.cli.stack import stack_app
|
|
18
|
+
from container_host_aiops.cli.system import system_app
|
|
19
|
+
from container_host_aiops.cli.volume import volume_app
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="container-host-aiops",
|
|
23
|
+
help="Governed AI-ops for Docker + Portainer container hosts: container / "
|
|
24
|
+
"image / volume / network / system reads, flagship analyses, and guarded "
|
|
25
|
+
"lifecycle + prune writes with a built-in governance harness.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
app.add_typer(container_app, name="container")
|
|
30
|
+
app.add_typer(image_app, name="image")
|
|
31
|
+
app.add_typer(volume_app, name="volume")
|
|
32
|
+
app.add_typer(network_app, name="network")
|
|
33
|
+
app.add_typer(system_app, name="system")
|
|
34
|
+
app.add_typer(stack_app, name="stack")
|
|
35
|
+
app.add_typer(analyze_app, name="analyze")
|
|
36
|
+
app.add_typer(manage_app, name="manage")
|
|
37
|
+
app.add_typer(secret_app, name="secret")
|
|
38
|
+
app.command("init")(init_cmd)
|
|
39
|
+
app.command("overview")(overview_cmd)
|
|
40
|
+
app.command("doctor")(doctor_cmd)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("mcp")
|
|
44
|
+
@cli_errors
|
|
45
|
+
def mcp_cmd() -> None:
|
|
46
|
+
"""Start the MCP server (stdio transport).
|
|
47
|
+
|
|
48
|
+
Single-command entry point for MCP clients (does not go through uvx/PyPI
|
|
49
|
+
resolution at launch):
|
|
50
|
+
container-host-aiops mcp
|
|
51
|
+
"""
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
if sys.version_info < (3, 11):
|
|
55
|
+
typer.echo(
|
|
56
|
+
f"ERROR: container-host-aiops requires Python >= 3.11 "
|
|
57
|
+
f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
|
|
58
|
+
f"Fix: uv python install 3.12 && "
|
|
59
|
+
f"uv tool install --python 3.12 --force container-host-aiops",
|
|
60
|
+
err=True,
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(2)
|
|
63
|
+
|
|
64
|
+
from mcp_server.server import main as _mcp_main
|
|
65
|
+
|
|
66
|
+
_mcp_main()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
app()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``container-host-aiops analyze`` — the flagship signature analyses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from container_host_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
11
|
+
|
|
12
|
+
analyze_app = typer.Typer(
|
|
13
|
+
name="analyze",
|
|
14
|
+
help="Flagship analyses: restart-loop RCA, resource pressure, image/volume bloat.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@analyze_app.command("restart-loop")
|
|
20
|
+
@cli_errors
|
|
21
|
+
def analyze_restart_loop(
|
|
22
|
+
threshold: Annotated[int, typer.Option(help="Restart count = looping")] = 3,
|
|
23
|
+
target: TargetOption = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Find crash-looping containers and map each to a cause + action."""
|
|
26
|
+
from container_host_aiops.ops import analyses as ops
|
|
27
|
+
|
|
28
|
+
conn, _ = get_connection(target)
|
|
29
|
+
rows, logs = ops.pull_restart_data(conn)
|
|
30
|
+
console.print_json(json.dumps(ops.restart_loop_rca(rows, logs, threshold)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@analyze_app.command("resource-pressure")
|
|
34
|
+
@cli_errors
|
|
35
|
+
def analyze_resource_pressure(
|
|
36
|
+
cpu: Annotated[float, typer.Option(help="CPU%% = over pressure")] = 80.0,
|
|
37
|
+
mem: Annotated[float, typer.Option(help="Memory%% = over pressure")] = 80.0,
|
|
38
|
+
target: TargetOption = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Rank running containers by CPU/memory pressure vs their limits."""
|
|
41
|
+
from container_host_aiops.ops import analyses as ops
|
|
42
|
+
|
|
43
|
+
conn, _ = get_connection(target)
|
|
44
|
+
samples = ops.pull_resource_pressure(conn)
|
|
45
|
+
console.print_json(json.dumps(ops.resource_pressure_analysis(samples, cpu, mem)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@analyze_app.command("bloat")
|
|
49
|
+
@cli_errors
|
|
50
|
+
def analyze_bloat(target: TargetOption = None) -> None:
|
|
51
|
+
"""Dangling images + volumes + build cache → prune candidates."""
|
|
52
|
+
from container_host_aiops.ops import analyses as ops
|
|
53
|
+
|
|
54
|
+
conn, _ = get_connection(target)
|
|
55
|
+
console.print_json(json.dumps(ops.pull_bloat(conn)))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""``container-host-aiops container`` — container-scoped reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from container_host_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
11
|
+
|
|
12
|
+
container_app = typer.Typer(
|
|
13
|
+
name="container",
|
|
14
|
+
help="Containers: list, inspect, logs, stats, top, restart summary.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
CidArg = Annotated[str, typer.Argument(help="Container id or name")]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@container_app.command("list")
|
|
22
|
+
@cli_errors
|
|
23
|
+
def container_list(
|
|
24
|
+
running: Annotated[bool, typer.Option("--running", help="Only running")] = False,
|
|
25
|
+
target: TargetOption = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""List containers (all states by default, or only running)."""
|
|
28
|
+
from container_host_aiops.ops import containers as ops
|
|
29
|
+
|
|
30
|
+
conn, _ = get_connection(target)
|
|
31
|
+
console.print_json(json.dumps(ops.list_containers(conn, all_states=not running)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@container_app.command("inspect")
|
|
35
|
+
@cli_errors
|
|
36
|
+
def container_inspect(container_id: CidArg, target: TargetOption = None) -> None:
|
|
37
|
+
"""Full inspect of one container."""
|
|
38
|
+
from container_host_aiops.ops import containers as ops
|
|
39
|
+
|
|
40
|
+
conn, _ = get_connection(target)
|
|
41
|
+
console.print_json(json.dumps(ops.inspect_container(conn, container_id)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@container_app.command("logs")
|
|
45
|
+
@cli_errors
|
|
46
|
+
def container_logs(
|
|
47
|
+
container_id: CidArg,
|
|
48
|
+
tail: Annotated[int, typer.Option(help="Lines from the end (1..2000)")] = 100,
|
|
49
|
+
target: TargetOption = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Tail the last N log lines of a container."""
|
|
52
|
+
from container_host_aiops.ops import containers as ops
|
|
53
|
+
|
|
54
|
+
conn, _ = get_connection(target)
|
|
55
|
+
console.print_json(json.dumps(ops.container_logs(conn, container_id, tail)))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@container_app.command("stats")
|
|
59
|
+
@cli_errors
|
|
60
|
+
def container_stats(container_id: CidArg, target: TargetOption = None) -> None:
|
|
61
|
+
"""One-shot CPU%/memory% snapshot for a container."""
|
|
62
|
+
from container_host_aiops.ops import containers as ops
|
|
63
|
+
|
|
64
|
+
conn, _ = get_connection(target)
|
|
65
|
+
console.print_json(json.dumps(ops.container_stats(conn, container_id)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@container_app.command("top")
|
|
69
|
+
@cli_errors
|
|
70
|
+
def container_top(container_id: CidArg, target: TargetOption = None) -> None:
|
|
71
|
+
"""Processes running inside a container."""
|
|
72
|
+
from container_host_aiops.ops import containers as ops
|
|
73
|
+
|
|
74
|
+
conn, _ = get_connection(target)
|
|
75
|
+
console.print_json(json.dumps(ops.container_top(conn, container_id)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@container_app.command("restarts")
|
|
79
|
+
@cli_errors
|
|
80
|
+
def container_restarts(target: TargetOption = None) -> None:
|
|
81
|
+
"""Restart-count + exit-code summary across containers."""
|
|
82
|
+
from container_host_aiops.ops import containers as ops
|
|
83
|
+
|
|
84
|
+
conn, _ = get_connection(target)
|
|
85
|
+
console.print_json(json.dumps(ops.restart_summary(conn)))
|
|
@@ -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 container_host_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 container_host_aiops.doctor import run_doctor
|
|
20
|
+
|
|
21
|
+
raise typer.Exit(run_doctor(skip_auth=skip_auth))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""``container-host-aiops image`` — image-scoped reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from container_host_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
11
|
+
|
|
12
|
+
image_app = typer.Typer(
|
|
13
|
+
name="image",
|
|
14
|
+
help="Images: list, inspect (with history), dangling, disk usage.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
ImageArg = Annotated[str, typer.Argument(help="Image id or name:tag")]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@image_app.command("list")
|
|
22
|
+
@cli_errors
|
|
23
|
+
def image_list(
|
|
24
|
+
all_images: Annotated[bool, typer.Option("--all", help="Include intermediate layers")] = False,
|
|
25
|
+
target: TargetOption = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""List images (largest first)."""
|
|
28
|
+
from container_host_aiops.ops import images as ops
|
|
29
|
+
|
|
30
|
+
conn, _ = get_connection(target)
|
|
31
|
+
console.print_json(json.dumps(ops.list_images(conn, all_images)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@image_app.command("inspect")
|
|
35
|
+
@cli_errors
|
|
36
|
+
def image_inspect(image_id: ImageArg, target: TargetOption = None) -> None:
|
|
37
|
+
"""Inspect an image plus its build history."""
|
|
38
|
+
from container_host_aiops.ops import images as ops
|
|
39
|
+
|
|
40
|
+
conn, _ = get_connection(target)
|
|
41
|
+
console.print_json(json.dumps(ops.inspect_image(conn, image_id)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@image_app.command("dangling")
|
|
45
|
+
@cli_errors
|
|
46
|
+
def image_dangling(target: TargetOption = None) -> None:
|
|
47
|
+
"""Untagged (dangling) images + reclaimable bytes."""
|
|
48
|
+
from container_host_aiops.ops import images as ops
|
|
49
|
+
|
|
50
|
+
conn, _ = get_connection(target)
|
|
51
|
+
console.print_json(json.dumps(ops.dangling_images(conn)))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@image_app.command("disk-usage")
|
|
55
|
+
@cli_errors
|
|
56
|
+
def image_disk_usage(target: TargetOption = None) -> None:
|
|
57
|
+
"""Image disk usage from system/df."""
|
|
58
|
+
from container_host_aiops.ops import images as ops
|
|
59
|
+
|
|
60
|
+
conn, _ = get_connection(target)
|
|
61
|
+
console.print_json(json.dumps(ops.image_disk_usage(conn)))
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""``container-host-aiops init`` — a friendly, interactive onboarding wizard.
|
|
2
|
+
|
|
3
|
+
Walks a new user through connecting their first container host: collects the
|
|
4
|
+
non-secret connection details into ``config.yaml`` and — for a Portainer target —
|
|
5
|
+
the API token into the *encrypted* store (never plaintext on disk). A direct
|
|
6
|
+
Docker socket target needs no secret. Designed to be run on a terminal;
|
|
7
|
+
everything it needs is prompted with sensible defaults.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import getpass
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from container_host_aiops.cli._common import cli_errors, console
|
|
18
|
+
from container_host_aiops.config import CONFIG_DIR, CONFIG_FILE
|
|
19
|
+
from container_host_aiops.platform import (
|
|
20
|
+
DEFAULT_DOCKER_SOCKET,
|
|
21
|
+
DEFAULT_PORTAINER_PORT,
|
|
22
|
+
DOCKER,
|
|
23
|
+
PORTAINER,
|
|
24
|
+
)
|
|
25
|
+
from container_host_aiops.secretstore import SecretStore, resolve_master_password
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_existing_targets() -> list[dict]:
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return []
|
|
31
|
+
raw = yaml.safe_load(CONFIG_FILE.read_text("utf-8")) or {}
|
|
32
|
+
return list(raw.get("targets", []))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_targets(targets: list[dict]) -> None:
|
|
36
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
try:
|
|
38
|
+
CONFIG_DIR.chmod(0o700)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
CONFIG_FILE.write_text(yaml.safe_dump({"targets": targets}, sort_keys=False), "utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cli_errors
|
|
45
|
+
def init_cmd() -> None:
|
|
46
|
+
"""Interactively set up your first Docker or Portainer connection."""
|
|
47
|
+
console.print("[bold cyan]Container Host AIops — setup wizard[/]")
|
|
48
|
+
console.print(
|
|
49
|
+
"This collects Docker or Portainer connection details (saved to "
|
|
50
|
+
"config.yaml). A Portainer target also stores its API token "
|
|
51
|
+
"[bold]encrypted[/] in secrets.enc; a local Docker socket needs no secret.\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
targets = _load_existing_targets()
|
|
55
|
+
existing_names = {t.get("name") for t in targets}
|
|
56
|
+
store: SecretStore | None = None
|
|
57
|
+
|
|
58
|
+
while True:
|
|
59
|
+
console.print("\n[bold]Add a target[/]")
|
|
60
|
+
name = typer.prompt("Target name (e.g. prod1)").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
|
+
platform = typer.prompt(
|
|
67
|
+
f"Platform ({DOCKER} / {PORTAINER})", default=DOCKER
|
|
68
|
+
).strip().lower()
|
|
69
|
+
if platform not in (DOCKER, PORTAINER):
|
|
70
|
+
console.print("[red]Platform must be 'docker' or 'portainer'.[/]")
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
entry: dict = {"name": name, "platform": platform}
|
|
74
|
+
if platform == DOCKER:
|
|
75
|
+
use_socket = typer.confirm(
|
|
76
|
+
"Connect over a local unix socket? (No = TCP host)", default=True
|
|
77
|
+
)
|
|
78
|
+
if use_socket:
|
|
79
|
+
entry["socket_path"] = typer.prompt(
|
|
80
|
+
"Docker socket path", default=DEFAULT_DOCKER_SOCKET
|
|
81
|
+
).strip()
|
|
82
|
+
else:
|
|
83
|
+
entry["host"] = typer.prompt("Docker host (IP or FQDN)").strip()
|
|
84
|
+
entry["verify_ssl"] = typer.confirm(
|
|
85
|
+
"Use TLS (https)?", default=False
|
|
86
|
+
)
|
|
87
|
+
else: # portainer
|
|
88
|
+
entry["host"] = typer.prompt("Portainer host (IP or FQDN)").strip()
|
|
89
|
+
entry["port"] = typer.prompt(
|
|
90
|
+
"Portainer HTTPS port", default=DEFAULT_PORTAINER_PORT, type=int
|
|
91
|
+
)
|
|
92
|
+
entry["endpoint_id"] = typer.prompt(
|
|
93
|
+
"Managed Docker endpoint id (for proxied Docker reads)", default="1"
|
|
94
|
+
).strip()
|
|
95
|
+
entry["verify_ssl"] = typer.confirm(
|
|
96
|
+
"Verify TLS certificate? (No for self-signed)", default=False
|
|
97
|
+
)
|
|
98
|
+
if store is None:
|
|
99
|
+
console.print("\n[bold]Master password[/] (encrypts secrets.enc)")
|
|
100
|
+
console.print(
|
|
101
|
+
"[dim]Set it later via CONTAINER_HOST_AIOPS_MASTER_PASSWORD for "
|
|
102
|
+
"non-interactive/MCP use.[/]"
|
|
103
|
+
)
|
|
104
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
105
|
+
store = SecretStore.unlock(password)
|
|
106
|
+
token = getpass.getpass(f"Portainer API token for '{name}' (hidden): ")
|
|
107
|
+
store = store.set(name, token)
|
|
108
|
+
|
|
109
|
+
targets.append(entry)
|
|
110
|
+
existing_names.add(name)
|
|
111
|
+
_write_targets(targets)
|
|
112
|
+
console.print(f"[green]✓ Saved target '{name}' ({platform}).[/]")
|
|
113
|
+
|
|
114
|
+
if not typer.confirm("\nAdd another target?", default=False):
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
|
|
118
|
+
console.print(
|
|
119
|
+
"[dim]Tip: export CONTAINER_HOST_AIOPS_MASTER_PASSWORD=... in your shell "
|
|
120
|
+
"profile so the MCP server and CLI can unlock Portainer tokens "
|
|
121
|
+
"non-interactively.[/]"
|
|
122
|
+
)
|
|
123
|
+
if typer.confirm("Run a connectivity check now (container-host-aiops doctor)?", default=True):
|
|
124
|
+
from container_host_aiops.doctor import run_doctor
|
|
125
|
+
|
|
126
|
+
raise typer.Exit(run_doctor())
|