harbor-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.
- harbor_aiops/__init__.py +8 -0
- harbor_aiops/cli/__init__.py +9 -0
- harbor_aiops/cli/_common.py +122 -0
- harbor_aiops/cli/_root.py +446 -0
- harbor_aiops/cli/secret.py +106 -0
- harbor_aiops/cli/undo.py +66 -0
- harbor_aiops/config.py +95 -0
- harbor_aiops/connection.py +188 -0
- harbor_aiops/governance/__init__.py +50 -0
- harbor_aiops/governance/audit.py +521 -0
- harbor_aiops/governance/budget.py +222 -0
- harbor_aiops/governance/decorators.py +574 -0
- harbor_aiops/governance/outcome.py +104 -0
- harbor_aiops/governance/paths.py +23 -0
- harbor_aiops/governance/patterns.py +378 -0
- harbor_aiops/governance/policy.py +67 -0
- harbor_aiops/governance/sanitize.py +79 -0
- harbor_aiops/governance/undo.py +257 -0
- harbor_aiops/mcp_server/__init__.py +1 -0
- harbor_aiops/mcp_server/_annotations.py +66 -0
- harbor_aiops/mcp_server/_shared.py +87 -0
- harbor_aiops/mcp_server/server.py +14 -0
- harbor_aiops/mcp_server/tools/__init__.py +1 -0
- harbor_aiops/mcp_server/tools/registry.py +313 -0
- harbor_aiops/mcp_server/tools/undo.py +204 -0
- harbor_aiops/mcp_server/tools/writes.py +46 -0
- harbor_aiops/ops/__init__.py +1 -0
- harbor_aiops/ops/registry.py +258 -0
- harbor_aiops/ops/writes.py +117 -0
- harbor_aiops/secretstore.py +306 -0
- harbor_aiops-0.2.0.dist-info/METADATA +60 -0
- harbor_aiops-0.2.0.dist-info/RECORD +35 -0
- harbor_aiops-0.2.0.dist-info/WHEEL +4 -0
- harbor_aiops-0.2.0.dist-info/entry_points.txt +3 -0
- harbor_aiops-0.2.0.dist-info/licenses/LICENSE +21 -0
harbor_aiops/__init__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Shared helpers for harbor-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[
|
|
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
|
+
|
|
28
|
+
``BudgetExceeded`` (and the retained ``PolicyDenied`` type) are raised by
|
|
29
|
+
``@governed_tool`` OUTSIDE the tool body, so ``tool_errors`` never sees them
|
|
30
|
+
and they never arrive as an ``{"error": ...}`` dict. Their message is the
|
|
31
|
+
teaching text (e.g. which budget was hit) — without them here such a refusal
|
|
32
|
+
reaches the CLI as a traceback instead.
|
|
33
|
+
"""
|
|
34
|
+
from harbor_aiops.connection import HarborApiError
|
|
35
|
+
from harbor_aiops.governance import BudgetExceeded, PolicyDenied
|
|
36
|
+
from harbor_aiops.secretstore import SecretStoreError
|
|
37
|
+
|
|
38
|
+
return (HarborApiError, PolicyDenied, BudgetExceeded, SecretStoreError, KeyError, OSError,
|
|
39
|
+
ValueError)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
43
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
44
|
+
|
|
45
|
+
@functools.wraps(fn)
|
|
46
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
47
|
+
try:
|
|
48
|
+
return fn(*args, **kwargs)
|
|
49
|
+
except (typer.Exit, typer.Abort):
|
|
50
|
+
raise
|
|
51
|
+
except _cli_error_types() as e:
|
|
52
|
+
message = str(e)
|
|
53
|
+
if isinstance(e, KeyError):
|
|
54
|
+
message = f"Missing required key or environment variable: {message}"
|
|
55
|
+
# Upstream text can contain "[/bold]"; unescaped, rich raises on it and the
|
|
56
|
+
# real error is replaced by a MarkupError traceback.
|
|
57
|
+
console.print(f"[red]Error: {escape(message)}[/]")
|
|
58
|
+
raise typer.Exit(1) from e
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
#: Exit status for a write whose outcome could not be determined — kept distinct
|
|
64
|
+
#: from 0 (confirmed) and 1 (failed) so a script can tell all three apart.
|
|
65
|
+
EXIT_UNDETERMINED = 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def governed(result: Any) -> dict:
|
|
69
|
+
"""Return a governed tool's result, or print its error and exit 1.
|
|
70
|
+
|
|
71
|
+
The ``harbor_aiops.mcp_server.tools`` twins never raise: ``@tool_errors`` flattens every
|
|
72
|
+
failure — an unreachable service, a refused login, a bad argument — into
|
|
73
|
+
``{"error": ...}``. A CLI command that drops that on the floor prints an empty
|
|
74
|
+
table and exits 0 as if the service had nothing to report. Route every governed
|
|
75
|
+
call through here.
|
|
76
|
+
"""
|
|
77
|
+
# ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
|
|
78
|
+
# write whose response was lost carries BOTH keys, and it is audited
|
|
79
|
+
# `unknown` precisely because it may have taken effect. Reporting that as a
|
|
80
|
+
# plain failure would tell a script the change did not happen and invite the
|
|
81
|
+
# double-apply the payload's own note warns about.
|
|
82
|
+
if isinstance(result, dict) and result.get("outcomeUnknown"):
|
|
83
|
+
console.print(f"[yellow]Outcome undetermined: {escape(str(result.get('note') or ''))}[/]")
|
|
84
|
+
raise typer.Exit(EXIT_UNDETERMINED)
|
|
85
|
+
if isinstance(result, dict) and result.get("error"):
|
|
86
|
+
console.print(f"[red]Error: {escape(str(result['error']))}[/]")
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
return result if isinstance(result, dict) else {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
|
|
92
|
+
"""Print a dry-run preview of the API call that would be made."""
|
|
93
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
|
|
94
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
95
|
+
console.print(f"[magenta] API Call: {api_call}[/]")
|
|
96
|
+
for k, v in (parameters or {}).items():
|
|
97
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
98
|
+
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
102
|
+
"""Require two confirmations for a destructive operation."""
|
|
103
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
104
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
105
|
+
typer.confirm(
|
|
106
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
107
|
+
abort=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def print_truncation_note(result: dict, noun: str) -> None:
|
|
112
|
+
"""Print a visible warning when a listing envelope was capped.
|
|
113
|
+
|
|
114
|
+
The JSON already carries ``truncated``, but a human scrolling a long dump
|
|
115
|
+
will not spot it — and neither will a model summarising the terminal. Say
|
|
116
|
+
it in words, on the last line, where it cannot be missed.
|
|
117
|
+
"""
|
|
118
|
+
if isinstance(result, dict) and result.get("truncated"):
|
|
119
|
+
console.print(
|
|
120
|
+
f"[yellow]… showing {result.get('returned')} of more {noun} — "
|
|
121
|
+
f"truncated, re-run with a higher --limit[/]"
|
|
122
|
+
)
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Harbor AIops command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from harbor_aiops.cli._common import (
|
|
12
|
+
cli_errors,
|
|
13
|
+
console,
|
|
14
|
+
double_confirm,
|
|
15
|
+
governed,
|
|
16
|
+
print_truncation_note,
|
|
17
|
+
)
|
|
18
|
+
from harbor_aiops.cli.secret import secret_app
|
|
19
|
+
from harbor_aiops.cli.undo import undo_app
|
|
20
|
+
from harbor_aiops.governance.paths import ops_home
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="harbor-aiops",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
help="Audited Harbor registry diagnosis and maintenance visibility.",
|
|
26
|
+
)
|
|
27
|
+
app.add_typer(secret_app, name="secret")
|
|
28
|
+
app.add_typer(undo_app, name="undo")
|
|
29
|
+
|
|
30
|
+
Target = Annotated[str | None, typer.Option("--target", "-t", help="Configured Harbor target")]
|
|
31
|
+
Limit = Annotated[int, typer.Option("--limit", help="Max records, 1–500")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _print(result: dict, noun: str = "records") -> None:
|
|
35
|
+
result = governed(result)
|
|
36
|
+
console.print_json(json.dumps(result))
|
|
37
|
+
print_truncation_note(result, noun)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("statistics")
|
|
41
|
+
@cli_errors
|
|
42
|
+
def statistics(target: Target = None) -> None:
|
|
43
|
+
"""Show registry-wide project and repository counts."""
|
|
44
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_statistics
|
|
45
|
+
_print(harbor_statistics(target=target))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("project-members")
|
|
49
|
+
@cli_errors
|
|
50
|
+
def project_members(project: str, limit: Limit = 50, target: Target = None) -> None:
|
|
51
|
+
"""List members of a Harbor project."""
|
|
52
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_project_members
|
|
53
|
+
_print(harbor_project_members(project=project, limit=limit, target=target), "members")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("immutable-rules")
|
|
57
|
+
@cli_errors
|
|
58
|
+
def immutable_rules(project: str, limit: Limit = 50, target: Target = None) -> None:
|
|
59
|
+
"""List tag immutability rules for a project."""
|
|
60
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_immutable_rules
|
|
61
|
+
_print(harbor_immutable_rules(project=project, limit=limit, target=target), "rules")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("robot-accounts")
|
|
65
|
+
@cli_errors
|
|
66
|
+
def robot_accounts(limit: Limit = 50, target: Target = None) -> None:
|
|
67
|
+
"""List robot accounts without exposing their secrets."""
|
|
68
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_robot_accounts
|
|
69
|
+
_print(harbor_robot_accounts(limit=limit, target=target), "robot accounts")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("labels")
|
|
73
|
+
@cli_errors
|
|
74
|
+
def labels(limit: Limit = 50, target: Target = None) -> None:
|
|
75
|
+
"""List Harbor labels."""
|
|
76
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_labels
|
|
77
|
+
_print(harbor_labels(limit=limit, target=target), "labels")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command("scan-all-metrics")
|
|
81
|
+
@cli_errors
|
|
82
|
+
def scan_all_metrics(target: Target = None) -> None:
|
|
83
|
+
"""Show latest registry-wide scan metrics."""
|
|
84
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_scan_all_metrics
|
|
85
|
+
_print(harbor_scan_all_metrics(target=target))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("scheduled-scan-metrics")
|
|
89
|
+
@cli_errors
|
|
90
|
+
def scheduled_scan_metrics(target: Target = None) -> None:
|
|
91
|
+
"""Show latest scheduled scan metrics."""
|
|
92
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_scheduled_scan_metrics
|
|
93
|
+
_print(harbor_scheduled_scan_metrics(target=target))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command("scan-log")
|
|
97
|
+
@cli_errors
|
|
98
|
+
def scan_log(project: str, repository: str, reference: str, report_id: str,
|
|
99
|
+
target: Target = None) -> None:
|
|
100
|
+
"""Show a bounded artifact scan report log."""
|
|
101
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_scan_log
|
|
102
|
+
_print(harbor_scan_log(project=project, repository=repository, reference=reference,
|
|
103
|
+
report_id=report_id, target=target))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command("projects")
|
|
107
|
+
@cli_errors
|
|
108
|
+
def projects(limit: Limit = 50, target: Target = None) -> None:
|
|
109
|
+
"""List Harbor projects."""
|
|
110
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_projects
|
|
111
|
+
_print(harbor_projects(limit=limit, target=target), "projects")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command("project")
|
|
115
|
+
@cli_errors
|
|
116
|
+
def project(name: str, target: Target = None) -> None:
|
|
117
|
+
"""Show one Harbor project."""
|
|
118
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_project
|
|
119
|
+
_print(harbor_project(name=name, target=target))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("project-summary")
|
|
123
|
+
@cli_errors
|
|
124
|
+
def project_summary(name: str, target: Target = None) -> None:
|
|
125
|
+
"""Show Harbor's project usage summary."""
|
|
126
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_project_summary
|
|
127
|
+
_print(harbor_project_summary(name=name, target=target))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command("repositories")
|
|
131
|
+
@cli_errors
|
|
132
|
+
def repositories(project: str, limit: Limit = 50, target: Target = None) -> None:
|
|
133
|
+
"""List repositories in a project."""
|
|
134
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_repositories
|
|
135
|
+
_print(harbor_repositories(project=project, limit=limit, target=target), "repositories")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command("artifacts")
|
|
139
|
+
@cli_errors
|
|
140
|
+
def artifacts(project: str, repository: str, limit: Limit = 50, target: Target = None) -> None:
|
|
141
|
+
"""List artifacts in a repository."""
|
|
142
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_artifacts
|
|
143
|
+
_print(harbor_artifacts(project=project, repository=repository, limit=limit,
|
|
144
|
+
target=target), "artifacts")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("artifact")
|
|
148
|
+
@cli_errors
|
|
149
|
+
def artifact(project: str, repository: str, reference: str, target: Target = None) -> None:
|
|
150
|
+
"""Show one artifact by digest or tag."""
|
|
151
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_artifact
|
|
152
|
+
_print(harbor_artifact(project=project, repository=repository, reference=reference,
|
|
153
|
+
target=target))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command("tags")
|
|
157
|
+
@cli_errors
|
|
158
|
+
def tags(project: str, repository: str, reference: str, limit: Limit = 50,
|
|
159
|
+
target: Target = None) -> None:
|
|
160
|
+
"""List artifact tags."""
|
|
161
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_tags
|
|
162
|
+
_print(harbor_tags(project=project, repository=repository, reference=reference,
|
|
163
|
+
limit=limit, target=target), "tags")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("vulnerabilities")
|
|
167
|
+
@cli_errors
|
|
168
|
+
def vulnerabilities(project: str, repository: str, reference: str,
|
|
169
|
+
target: Target = None) -> None:
|
|
170
|
+
"""Show an artifact's stored vulnerability report."""
|
|
171
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_vulnerabilities
|
|
172
|
+
_print(harbor_vulnerabilities(project=project, repository=repository,
|
|
173
|
+
reference=reference, target=target))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command("replication-policies")
|
|
177
|
+
@cli_errors
|
|
178
|
+
def replication_policies(limit: Limit = 50, target: Target = None) -> None:
|
|
179
|
+
"""List replication policies."""
|
|
180
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_replication_policies
|
|
181
|
+
_print(harbor_replication_policies(limit=limit, target=target), "replication policies")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command("replication-policy")
|
|
185
|
+
@cli_errors
|
|
186
|
+
def replication_policy(policy_id: int, target: Target = None) -> None:
|
|
187
|
+
"""Show a replication policy."""
|
|
188
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_replication_policy
|
|
189
|
+
_print(harbor_replication_policy(policy_id=policy_id, target=target))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command("replication-execution")
|
|
193
|
+
@cli_errors
|
|
194
|
+
def replication_execution(execution_id: int, target: Target = None) -> None:
|
|
195
|
+
"""Show one replication execution."""
|
|
196
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_replication_execution
|
|
197
|
+
_print(harbor_replication_execution(execution_id=execution_id, target=target))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("retention-tasks")
|
|
201
|
+
@cli_errors
|
|
202
|
+
def retention_tasks(policy_id: int, execution_id: int, limit: Limit = 50,
|
|
203
|
+
target: Target = None) -> None:
|
|
204
|
+
"""List tasks of a retention execution."""
|
|
205
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_retention_tasks
|
|
206
|
+
_print(harbor_retention_tasks(policy_id=policy_id, execution_id=execution_id,
|
|
207
|
+
limit=limit, target=target), "retention tasks")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command("quotas")
|
|
211
|
+
@cli_errors
|
|
212
|
+
def quotas(limit: Limit = 50, target: Target = None) -> None:
|
|
213
|
+
"""List Harbor quotas."""
|
|
214
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_quotas
|
|
215
|
+
_print(harbor_quotas(limit=limit, target=target), "quotas")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command("project-audit-logs")
|
|
219
|
+
@cli_errors
|
|
220
|
+
def project_audit_logs(project: str, limit: Limit = 50, target: Target = None) -> None:
|
|
221
|
+
"""List Harbor audit logs for a project."""
|
|
222
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_project_audit_logs
|
|
223
|
+
_print(harbor_project_audit_logs(project=project, limit=limit, target=target), "audit logs")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command("scan-artifact")
|
|
227
|
+
@cli_errors
|
|
228
|
+
def scan_artifact(project: str, repository: str, reference: str,
|
|
229
|
+
dry_run: bool = False, target: Target = None) -> None:
|
|
230
|
+
"""Request a Harbor artifact vulnerability scan."""
|
|
231
|
+
from harbor_aiops.mcp_server.tools.writes import harbor_scan_artifact
|
|
232
|
+
if not dry_run:
|
|
233
|
+
double_confirm("scan artifact", f"{project}/{repository}:{reference}")
|
|
234
|
+
_print(harbor_scan_artifact(project=project, repository=repository,
|
|
235
|
+
reference=reference, dry_run=dry_run, target=target))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.command("create-tag")
|
|
239
|
+
@cli_errors
|
|
240
|
+
def create_tag(project: str, repository: str, reference: str, tag: str,
|
|
241
|
+
dry_run: bool = False, target: Target = None) -> None:
|
|
242
|
+
"""Create and verify a Harbor artifact tag."""
|
|
243
|
+
from harbor_aiops.mcp_server.tools.writes import harbor_create_tag
|
|
244
|
+
if not dry_run:
|
|
245
|
+
double_confirm("create tag", f"{project}/{repository}:{tag}")
|
|
246
|
+
_print(harbor_create_tag(project=project, repository=repository, reference=reference,
|
|
247
|
+
tag=tag, dry_run=dry_run, target=target))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command("delete-tag")
|
|
251
|
+
@cli_errors
|
|
252
|
+
def delete_tag(project: str, repository: str, reference: str, tag: str,
|
|
253
|
+
dry_run: bool = False, target: Target = None) -> None:
|
|
254
|
+
"""Delete one mutable Harbor tag after exact target checks."""
|
|
255
|
+
from harbor_aiops.mcp_server.tools.writes import harbor_delete_tag
|
|
256
|
+
if not dry_run:
|
|
257
|
+
double_confirm("delete tag", f"{project}/{repository}:{tag}")
|
|
258
|
+
_print(harbor_delete_tag(project=project, repository=repository, reference=reference,
|
|
259
|
+
tag=tag, dry_run=dry_run, target=target))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@app.command("gc-execution")
|
|
263
|
+
@cli_errors
|
|
264
|
+
def gc_execution(gc_id: int, target: Target = None) -> None:
|
|
265
|
+
"""Show one GC execution."""
|
|
266
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_gc_execution
|
|
267
|
+
_print(harbor_gc_execution(gc_id=gc_id, target=target))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command("gc-log")
|
|
271
|
+
@cli_errors
|
|
272
|
+
def gc_log(gc_id: int, target: Target = None) -> None:
|
|
273
|
+
"""Show a bounded GC execution log."""
|
|
274
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_gc_log
|
|
275
|
+
_print(harbor_gc_log(gc_id=gc_id, target=target))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command("replication-task-log")
|
|
279
|
+
@cli_errors
|
|
280
|
+
def replication_task_log(execution_id: int, task_id: int, target: Target = None) -> None:
|
|
281
|
+
"""Show a bounded replication task log."""
|
|
282
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_replication_task_log
|
|
283
|
+
_print(harbor_replication_task_log(execution_id=execution_id, task_id=task_id,
|
|
284
|
+
target=target))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.command("retention-task-log")
|
|
288
|
+
@cli_errors
|
|
289
|
+
def retention_task_log(policy_id: int, execution_id: int, task_id: int,
|
|
290
|
+
target: Target = None) -> None:
|
|
291
|
+
"""Show a bounded retention task log."""
|
|
292
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_retention_task_log
|
|
293
|
+
_print(harbor_retention_task_log(policy_id=policy_id, execution_id=execution_id,
|
|
294
|
+
task_id=task_id, target=target))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command("init")
|
|
298
|
+
@cli_errors
|
|
299
|
+
def init() -> None:
|
|
300
|
+
"""Add a Harbor target and store its password encrypted."""
|
|
301
|
+
import getpass
|
|
302
|
+
from pathlib import Path
|
|
303
|
+
|
|
304
|
+
import yaml
|
|
305
|
+
|
|
306
|
+
from harbor_aiops.config import TargetConfig
|
|
307
|
+
from harbor_aiops.secretstore import SecretStore, resolve_master_password
|
|
308
|
+
|
|
309
|
+
name = typer.prompt("Target name", default="harbor")
|
|
310
|
+
host = typer.prompt("Harbor hostname")
|
|
311
|
+
username = typer.prompt("Harbor username or robot account")
|
|
312
|
+
ca_file = typer.prompt("Custom CA PEM path (optional)", default="")
|
|
313
|
+
chosen = TargetConfig(name=name, host=host, username=username, ca_file=ca_file or None)
|
|
314
|
+
path = Path(ops_home()) / "config.yaml"
|
|
315
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
if path.exists():
|
|
317
|
+
raise ValueError(f"Config already exists at {path}; edit it to add a target.")
|
|
318
|
+
master = resolve_master_password(confirm_if_new=True)
|
|
319
|
+
password = getpass.getpass("Harbor password (hidden): ")
|
|
320
|
+
SecretStore.unlock(master).set(name, password)
|
|
321
|
+
path.write_text(
|
|
322
|
+
yaml.safe_dump(
|
|
323
|
+
{
|
|
324
|
+
"targets": [
|
|
325
|
+
{
|
|
326
|
+
"name": chosen.name,
|
|
327
|
+
"host": chosen.host,
|
|
328
|
+
"username": chosen.username,
|
|
329
|
+
"scheme": chosen.scheme,
|
|
330
|
+
"port": chosen.port,
|
|
331
|
+
"verify_ssl": chosen.verify_ssl,
|
|
332
|
+
"ca_file": chosen.ca_file,
|
|
333
|
+
"timeout": chosen.timeout,
|
|
334
|
+
}
|
|
335
|
+
]
|
|
336
|
+
}
|
|
337
|
+
),
|
|
338
|
+
encoding="utf-8",
|
|
339
|
+
)
|
|
340
|
+
path.chmod(0o600)
|
|
341
|
+
console.print(f"[green]Configured '{name}' in {path}.[/]")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command("health")
|
|
345
|
+
@cli_errors
|
|
346
|
+
def health(target: Target = None) -> None:
|
|
347
|
+
"""Show Harbor component health."""
|
|
348
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_health
|
|
349
|
+
|
|
350
|
+
_print(harbor_health(target=target))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@app.command("volumes")
|
|
354
|
+
@cli_errors
|
|
355
|
+
def volumes(target: Target = None) -> None:
|
|
356
|
+
"""Show Harbor storage volume usage."""
|
|
357
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_volumes
|
|
358
|
+
|
|
359
|
+
_print(harbor_volumes(target=target))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.command("gc")
|
|
363
|
+
@cli_errors
|
|
364
|
+
def gc(limit: Limit = 50, target: Target = None) -> None:
|
|
365
|
+
"""List garbage-collection executions."""
|
|
366
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_gc_history
|
|
367
|
+
|
|
368
|
+
_print(harbor_gc_history(limit=limit, target=target), "GC executions")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@app.command("replication")
|
|
372
|
+
@cli_errors
|
|
373
|
+
def replication(limit: Limit = 50, status: str | None = None, target: Target = None) -> None:
|
|
374
|
+
"""List replication executions."""
|
|
375
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_replication_executions
|
|
376
|
+
|
|
377
|
+
_print(
|
|
378
|
+
harbor_replication_executions(limit=limit, status=status, target=target),
|
|
379
|
+
"replication executions",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.command("replication-tasks")
|
|
384
|
+
@cli_errors
|
|
385
|
+
def replication_tasks(execution_id: int, limit: Limit = 50, target: Target = None) -> None:
|
|
386
|
+
"""List tasks in one replication execution."""
|
|
387
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_replication_tasks
|
|
388
|
+
|
|
389
|
+
_print(
|
|
390
|
+
harbor_replication_tasks(execution_id=execution_id, limit=limit, target=target),
|
|
391
|
+
"replication tasks",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.command("retention")
|
|
396
|
+
@cli_errors
|
|
397
|
+
def retention(policy_id: int, target: Target = None) -> None:
|
|
398
|
+
"""Show a retention policy by ID."""
|
|
399
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_retention_policy
|
|
400
|
+
|
|
401
|
+
_print(harbor_retention_policy(policy_id=policy_id, target=target))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.command("retention-executions")
|
|
405
|
+
@cli_errors
|
|
406
|
+
def retention_executions(policy_id: int, limit: Limit = 50, target: Target = None) -> None:
|
|
407
|
+
"""List executions of a retention policy."""
|
|
408
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_retention_executions
|
|
409
|
+
|
|
410
|
+
_print(
|
|
411
|
+
harbor_retention_executions(policy_id=policy_id, limit=limit, target=target),
|
|
412
|
+
"retention executions",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.command("diagnose")
|
|
417
|
+
@cli_errors
|
|
418
|
+
def diagnose(limit: Limit = 20, target: Target = None) -> None:
|
|
419
|
+
"""Correlate health, volume, GC and replication observations."""
|
|
420
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_diagnose
|
|
421
|
+
|
|
422
|
+
_print(harbor_diagnose(limit=limit, target=target))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@app.command("doctor")
|
|
426
|
+
@cli_errors
|
|
427
|
+
def doctor(target: Target = None) -> None:
|
|
428
|
+
"""Test target configuration and Harbor health endpoint."""
|
|
429
|
+
from harbor_aiops.mcp_server.tools.registry import harbor_health
|
|
430
|
+
|
|
431
|
+
_print(harbor_health(target=target))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@app.command("mcp")
|
|
435
|
+
@cli_errors
|
|
436
|
+
def mcp_cmd() -> None:
|
|
437
|
+
"""Start the MCP server over stdio."""
|
|
438
|
+
if sys.version_info < (3, 11):
|
|
439
|
+
raise typer.Exit(2)
|
|
440
|
+
from harbor_aiops.mcp_server.server import main
|
|
441
|
+
|
|
442
|
+
main()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
if __name__ == "__main__":
|
|
446
|
+
app()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""``harbor-aiops secret`` — manage the encrypted credential store.
|
|
2
|
+
|
|
3
|
+
Secrets (Harbor account passwords) are stored in ``~/.harbor-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 harbor_aiops.cli._common import cli_errors, console
|
|
16
|
+
from harbor_aiops.config import SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX
|
|
17
|
+
from harbor_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 password 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="Password (omit to be prompted, hidden)"),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Store (or replace) the password 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"Password for '{name}' (hidden): ")
|
|
47
|
+
else:
|
|
48
|
+
console.print("[yellow]! --value leaves the password in shell history and the "
|
|
49
|
+
"process list; omit it to be prompted instead.[/]")
|
|
50
|
+
store = SecretStore.unlock(password)
|
|
51
|
+
store.set(name, value)
|
|
52
|
+
console.print(f"[green]✓ Stored encrypted password for '{name}' in {SECRETS_FILE}[/]")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@secret_app.command("list")
|
|
56
|
+
@cli_errors
|
|
57
|
+
def secret_list() -> None:
|
|
58
|
+
"""List target names that have a stored password (values never shown)."""
|
|
59
|
+
store = SecretStore.unlock()
|
|
60
|
+
names = store.names()
|
|
61
|
+
if not names:
|
|
62
|
+
console.print("[yellow]No secrets stored yet. Add one: harbor-aiops secret set <name>[/]")
|
|
63
|
+
return
|
|
64
|
+
console.print("[bold]Stored secrets:[/]")
|
|
65
|
+
for n in names:
|
|
66
|
+
console.print(f" • {n}")
|
|
67
|
+
warning = check_permissions()
|
|
68
|
+
if warning:
|
|
69
|
+
console.print(f"[yellow]! {warning}[/]")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@secret_app.command("rm")
|
|
73
|
+
@cli_errors
|
|
74
|
+
def secret_rm(name: NameArg) -> None:
|
|
75
|
+
"""Delete a stored password."""
|
|
76
|
+
store = SecretStore.unlock()
|
|
77
|
+
store.delete(name)
|
|
78
|
+
console.print(f"[green]✓ Deleted password for '{name}'[/]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@secret_app.command("migrate")
|
|
82
|
+
@cli_errors
|
|
83
|
+
def secret_migrate() -> None:
|
|
84
|
+
"""Import passwords from a legacy plaintext .env into the encrypted store."""
|
|
85
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
86
|
+
imported = migrate_legacy_env(SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX, password)
|
|
87
|
+
if not imported:
|
|
88
|
+
console.print("[yellow]Nothing to migrate (no legacy .env secrets found).[/]")
|
|
89
|
+
return
|
|
90
|
+
console.print(f"[green]✓ Imported {len(imported)} secret(s): {', '.join(imported)}[/]")
|
|
91
|
+
console.print("[dim]The old .env was renamed to .env.migrated — delete it once verified.[/]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@secret_app.command("rotate-password")
|
|
95
|
+
@cli_errors
|
|
96
|
+
def secret_rotate_password() -> None:
|
|
97
|
+
"""Re-encrypt the whole store under a new master password."""
|
|
98
|
+
console.print("[bold]Unlock with the current master password:[/]")
|
|
99
|
+
store = SecretStore.unlock()
|
|
100
|
+
new_pw = getpass.getpass("New master password: ")
|
|
101
|
+
confirm = getpass.getpass("Confirm new master password: ")
|
|
102
|
+
if new_pw != confirm:
|
|
103
|
+
console.print("[red]Passwords did not match. Aborted.[/]")
|
|
104
|
+
raise typer.Exit(1)
|
|
105
|
+
store.with_password(new_pw)
|
|
106
|
+
console.print("[green]✓ Master password rotated. Update HARBOR_AIOPS_MASTER_PASSWORD.[/]")
|