admina-framework 0.9.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.
- admina/__init__.py +34 -0
- admina/cli/__init__.py +14 -0
- admina/cli/commands/__init__.py +14 -0
- admina/cli/main.py +1522 -0
- admina/cli/templates/admina.yaml.j2 +77 -0
- admina/cli/templates/docker-compose.yml.j2 +254 -0
- admina/cli/templates/env.j2 +10 -0
- admina/cli/templates/main.py.j2 +95 -0
- admina/cli/templates/plugin.py.j2 +145 -0
- admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
- admina/cli/templates/plugin_readme.md.j2 +27 -0
- admina/cli/templates/plugin_test.py.j2 +48 -0
- admina/core/__init__.py +14 -0
- admina/core/config.py +497 -0
- admina/core/event_bus.py +112 -0
- admina/core/secrets.py +257 -0
- admina/core/types.py +146 -0
- admina/dashboard/__init__.py +8 -0
- admina/dashboard/static/heimdall.png +0 -0
- admina/dashboard/static/index.html +1045 -0
- admina/dashboard/static/vendor/alpinejs.min.js +5 -0
- admina/domains/__init__.py +14 -0
- admina/domains/agent_security/__init__.py +41 -0
- admina/domains/agent_security/firewall.py +634 -0
- admina/domains/agent_security/loop_breaker.py +176 -0
- admina/domains/ai_infra/__init__.py +79 -0
- admina/domains/ai_infra/llm_engine.py +477 -0
- admina/domains/ai_infra/rag.py +817 -0
- admina/domains/ai_infra/webui.py +292 -0
- admina/domains/compliance/__init__.py +109 -0
- admina/domains/compliance/cross_regulation.py +314 -0
- admina/domains/compliance/eu_ai_act.py +367 -0
- admina/domains/compliance/forensic.py +380 -0
- admina/domains/compliance/gdpr.py +331 -0
- admina/domains/compliance/nis2.py +258 -0
- admina/domains/compliance/oisg.py +658 -0
- admina/domains/compliance/otel.py +101 -0
- admina/domains/data_sovereignty/__init__.py +42 -0
- admina/domains/data_sovereignty/classification.py +102 -0
- admina/domains/data_sovereignty/pii.py +260 -0
- admina/domains/data_sovereignty/residency.py +121 -0
- admina/integrations/__init__.py +14 -0
- admina/integrations/_engines.py +63 -0
- admina/integrations/cheshirecat/__init__.py +13 -0
- admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
- admina/integrations/crewai/__init__.py +13 -0
- admina/integrations/crewai/callbacks.py +347 -0
- admina/integrations/langchain/__init__.py +13 -0
- admina/integrations/langchain/callbacks.py +341 -0
- admina/integrations/n8n/__init__.py +14 -0
- admina/integrations/openclaw/__init__.py +14 -0
- admina/plugins/__init__.py +49 -0
- admina/plugins/base.py +633 -0
- admina/plugins/builtin/__init__.py +14 -0
- admina/plugins/builtin/adapters/__init__.py +14 -0
- admina/plugins/builtin/adapters/ollama.py +120 -0
- admina/plugins/builtin/adapters/openai.py +138 -0
- admina/plugins/builtin/alerts/__init__.py +14 -0
- admina/plugins/builtin/alerts/log.py +66 -0
- admina/plugins/builtin/alerts/webhook.py +102 -0
- admina/plugins/builtin/auth/__init__.py +14 -0
- admina/plugins/builtin/auth/apikey.py +138 -0
- admina/plugins/builtin/compliance/__init__.py +14 -0
- admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
- admina/plugins/builtin/connectors/__init__.py +14 -0
- admina/plugins/builtin/connectors/chromadb.py +137 -0
- admina/plugins/builtin/connectors/filesystem.py +111 -0
- admina/plugins/builtin/forensic/__init__.py +14 -0
- admina/plugins/builtin/forensic/filesystem.py +163 -0
- admina/plugins/builtin/forensic/minio.py +180 -0
- admina/plugins/builtin/guards/__init__.py +0 -0
- admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
- admina/plugins/builtin/pii/__init__.py +14 -0
- admina/plugins/builtin/pii/spacy_regex.py +160 -0
- admina/plugins/builtin/transports/__init__.py +14 -0
- admina/plugins/builtin/transports/http_rest.py +97 -0
- admina/plugins/builtin/transports/mcp.py +173 -0
- admina/plugins/registry.py +356 -0
- admina/proxy/__init__.py +15 -0
- admina/proxy/api/__init__.py +17 -0
- admina/proxy/api/dashboard.py +925 -0
- admina/proxy/api/integration.py +153 -0
- admina/proxy/config.py +214 -0
- admina/proxy/engine_bridge.py +306 -0
- admina/proxy/governance.py +232 -0
- admina/proxy/main.py +1484 -0
- admina/proxy/multi_upstream.py +156 -0
- admina/proxy/state.py +97 -0
- admina/py.typed +0 -0
- admina/sdk/__init__.py +34 -0
- admina/sdk/_compat.py +43 -0
- admina/sdk/compliance_kit.py +359 -0
- admina/sdk/governed_agent.py +391 -0
- admina/sdk/governed_data.py +434 -0
- admina/sdk/governed_model.py +241 -0
- admina_framework-0.9.0.dist-info/METADATA +575 -0
- admina_framework-0.9.0.dist-info/RECORD +102 -0
- admina_framework-0.9.0.dist-info/WHEEL +5 -0
- admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
- admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
- admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
- admina_framework-0.9.0.dist-info/top_level.txt +1 -0
admina/cli/main.py
ADDED
|
@@ -0,0 +1,1522 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Admina CLI — project scaffolding and management commands.
|
|
16
|
+
|
|
17
|
+
Entry point: ``admina = "cli.main:app"`` in pyproject.toml.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import webbrowser
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from urllib.error import URLError
|
|
32
|
+
from urllib.request import urlopen
|
|
33
|
+
|
|
34
|
+
import click
|
|
35
|
+
import yaml
|
|
36
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
37
|
+
|
|
38
|
+
from admina import __version__
|
|
39
|
+
from admina.core.secrets import SecretVault, validate_password
|
|
40
|
+
|
|
41
|
+
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
42
|
+
|
|
43
|
+
# Domains the user can toggle on/off during ``admina init``.
|
|
44
|
+
AVAILABLE_DOMAINS: dict[str, str] = {
|
|
45
|
+
"data_sovereignty": "PII redaction, data residency, classification",
|
|
46
|
+
"ai_infra": "LLM engine, RAG pipeline, Web UI",
|
|
47
|
+
"agent_security": "MCP proxy, firewall, loop breaker",
|
|
48
|
+
"compliance": "Forensic black-box, EU AI Act, OTEL",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Modules map to domains for the --modules shorthand.
|
|
52
|
+
MODULE_TO_DOMAIN: dict[str, str] = {
|
|
53
|
+
"model": "ai_infra",
|
|
54
|
+
"data": "data_sovereignty",
|
|
55
|
+
"compliance": "compliance",
|
|
56
|
+
"security": "agent_security",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bootstrap_secrets(project_dir: Path, *, force: bool = False) -> dict[str, str] | None:
|
|
61
|
+
"""Auto-generate secrets on first launch. Returns secrets if generated."""
|
|
62
|
+
vault = SecretVault(project_dir)
|
|
63
|
+
if vault.is_initialized and not force:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
generated = vault.bootstrap()
|
|
67
|
+
|
|
68
|
+
# Write .env with vault secrets so docker compose can read them
|
|
69
|
+
vault.write_dotenv(project_dir / ".env")
|
|
70
|
+
|
|
71
|
+
click.echo()
|
|
72
|
+
click.echo(" " + "=" * 54)
|
|
73
|
+
click.echo(" First-boot credentials generated!")
|
|
74
|
+
click.echo()
|
|
75
|
+
click.echo(f" API Key: {generated['ADMINA_API_KEY']}")
|
|
76
|
+
click.echo(f" Password: {generated['ADMINA_DASHBOARD_PASSWORD']}")
|
|
77
|
+
click.echo(" (dashboard, Grafana, MinIO, ClickHouse)")
|
|
78
|
+
click.echo()
|
|
79
|
+
click.echo(" Save these now — they will NOT be shown again.")
|
|
80
|
+
click.echo(" View: admina password show")
|
|
81
|
+
click.echo(" Reset: admina password reset")
|
|
82
|
+
click.echo(" " + "=" * 54)
|
|
83
|
+
click.echo()
|
|
84
|
+
return generated
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _jinja_env() -> Environment:
|
|
88
|
+
"""Create a Jinja2 environment pointing at the CLI templates dir.
|
|
89
|
+
|
|
90
|
+
Templates here are YAML / Python / .env files, not HTML, so XSS
|
|
91
|
+
isn't a risk. We still enable selective autoescape (html/xml) as
|
|
92
|
+
a defensive default in case a future template ships HTML.
|
|
93
|
+
"""
|
|
94
|
+
return Environment(
|
|
95
|
+
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
|
96
|
+
autoescape=select_autoescape(["html", "xml"]),
|
|
97
|
+
keep_trailing_newline=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _prompt_domains() -> list[str]:
|
|
102
|
+
"""Interactively ask the user which domains to enable."""
|
|
103
|
+
click.echo("\nAvailable domains:")
|
|
104
|
+
keys = list(AVAILABLE_DOMAINS.keys())
|
|
105
|
+
for i, (name, desc) in enumerate(AVAILABLE_DOMAINS.items(), 1):
|
|
106
|
+
click.echo(f" {i}. {name} — {desc}")
|
|
107
|
+
click.echo()
|
|
108
|
+
selection = click.prompt(
|
|
109
|
+
"Select domains (comma-separated numbers, or 'all')",
|
|
110
|
+
default="all",
|
|
111
|
+
)
|
|
112
|
+
if selection.strip().lower() == "all":
|
|
113
|
+
return keys
|
|
114
|
+
chosen: list[str] = []
|
|
115
|
+
for part in selection.split(","):
|
|
116
|
+
part = part.strip()
|
|
117
|
+
if part.isdigit():
|
|
118
|
+
idx = int(part) - 1
|
|
119
|
+
if 0 <= idx < len(keys):
|
|
120
|
+
chosen.append(keys[idx])
|
|
121
|
+
return chosen or keys
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_domains(
|
|
125
|
+
full_stack: bool,
|
|
126
|
+
modules: str | None,
|
|
127
|
+
interactive: bool = True,
|
|
128
|
+
) -> list[str]:
|
|
129
|
+
"""Determine which domains to enable based on CLI flags."""
|
|
130
|
+
if full_stack:
|
|
131
|
+
return list(AVAILABLE_DOMAINS.keys())
|
|
132
|
+
if modules:
|
|
133
|
+
domains: list[str] = []
|
|
134
|
+
for m in modules.split(","):
|
|
135
|
+
m = m.strip().lower()
|
|
136
|
+
domain = MODULE_TO_DOMAIN.get(m)
|
|
137
|
+
if domain and domain not in domains:
|
|
138
|
+
domains.append(domain)
|
|
139
|
+
return domains or list(AVAILABLE_DOMAINS.keys())
|
|
140
|
+
if interactive and sys.stdin.isatty():
|
|
141
|
+
return _prompt_domains()
|
|
142
|
+
return list(AVAILABLE_DOMAINS.keys())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _generate_file(
|
|
146
|
+
env: Environment,
|
|
147
|
+
template_name: str,
|
|
148
|
+
output_path: Path,
|
|
149
|
+
context: dict[str, object],
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Render a Jinja2 template and write it to *output_path*."""
|
|
152
|
+
tmpl = env.get_template(template_name)
|
|
153
|
+
content = tmpl.render(**context)
|
|
154
|
+
output_path.write_text(content)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _scaffold_project(
|
|
158
|
+
project_dir: Path,
|
|
159
|
+
domains: list[str],
|
|
160
|
+
project_name: str,
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
"""Generate the full project skeleton and return list of created files."""
|
|
163
|
+
env = _jinja_env()
|
|
164
|
+
created: list[str] = []
|
|
165
|
+
|
|
166
|
+
context: dict[str, object] = {
|
|
167
|
+
"project_name": project_name,
|
|
168
|
+
"domains": {d: d in domains for d in AVAILABLE_DOMAINS},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# admina.yaml
|
|
172
|
+
_generate_file(env, "admina.yaml.j2", project_dir / "admina.yaml", context)
|
|
173
|
+
created.append("admina.yaml")
|
|
174
|
+
|
|
175
|
+
# docker-compose.yml
|
|
176
|
+
_generate_file(
|
|
177
|
+
env,
|
|
178
|
+
"docker-compose.yml.j2",
|
|
179
|
+
project_dir / "docker-compose.yml",
|
|
180
|
+
context,
|
|
181
|
+
)
|
|
182
|
+
created.append("docker-compose.yml")
|
|
183
|
+
|
|
184
|
+
# .env with placeholder secrets
|
|
185
|
+
env_file = project_dir / ".env"
|
|
186
|
+
if not env_file.exists():
|
|
187
|
+
_generate_file(env, "env.j2", env_file, context)
|
|
188
|
+
created.append(".env")
|
|
189
|
+
|
|
190
|
+
# Example main.py
|
|
191
|
+
_generate_file(env, "main.py.j2", project_dir / "main.py", context)
|
|
192
|
+
created.append("main.py")
|
|
193
|
+
|
|
194
|
+
return created
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@click.group()
|
|
198
|
+
@click.version_option(version=__version__, prog_name="admina")
|
|
199
|
+
def app() -> None:
|
|
200
|
+
"""Admina — governed AI development framework."""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
@click.argument("project_name", default="my-admina-project")
|
|
205
|
+
@click.option(
|
|
206
|
+
"--full-stack",
|
|
207
|
+
is_flag=True,
|
|
208
|
+
default=False,
|
|
209
|
+
help="Enable all domains.",
|
|
210
|
+
)
|
|
211
|
+
@click.option(
|
|
212
|
+
"--modules",
|
|
213
|
+
default=None,
|
|
214
|
+
help="Comma-separated modules: model, data, compliance, security.",
|
|
215
|
+
)
|
|
216
|
+
@click.option(
|
|
217
|
+
"--no-pull",
|
|
218
|
+
is_flag=True,
|
|
219
|
+
default=False,
|
|
220
|
+
help="Skip docker compose pull.",
|
|
221
|
+
)
|
|
222
|
+
def init(
|
|
223
|
+
project_name: str,
|
|
224
|
+
full_stack: bool,
|
|
225
|
+
modules: str | None,
|
|
226
|
+
no_pull: bool,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Scaffold a new Admina project.
|
|
229
|
+
|
|
230
|
+
Creates admina.yaml, docker-compose.yml, .env, and an example main.py
|
|
231
|
+
inside PROJECT_NAME directory.
|
|
232
|
+
"""
|
|
233
|
+
# 1. Resolve domains
|
|
234
|
+
domains = _resolve_domains(full_stack, modules)
|
|
235
|
+
|
|
236
|
+
click.echo(f"\n Creating project: {project_name}")
|
|
237
|
+
click.echo(f" Domains: {', '.join(domains)}\n")
|
|
238
|
+
|
|
239
|
+
# 2. Create project directory
|
|
240
|
+
project_dir = Path.cwd() / project_name
|
|
241
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
|
|
243
|
+
# 3. Generate files
|
|
244
|
+
created = _scaffold_project(project_dir, domains, project_name)
|
|
245
|
+
for f in created:
|
|
246
|
+
click.echo(f" ✓ {f}")
|
|
247
|
+
|
|
248
|
+
# 4. Bootstrap secrets (first-time setup)
|
|
249
|
+
_bootstrap_secrets(project_dir)
|
|
250
|
+
|
|
251
|
+
# 5. Docker compose pull (optional)
|
|
252
|
+
if not no_pull and shutil.which("docker"):
|
|
253
|
+
click.echo("\n Pulling Docker images...")
|
|
254
|
+
result = subprocess.run(
|
|
255
|
+
["docker", "compose", "pull"],
|
|
256
|
+
cwd=str(project_dir),
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
)
|
|
260
|
+
if result.returncode == 0:
|
|
261
|
+
click.echo(" ✓ Docker images pulled")
|
|
262
|
+
else:
|
|
263
|
+
click.echo(" ⚠ docker compose pull failed (you can run it later)")
|
|
264
|
+
|
|
265
|
+
# 5. Print next steps
|
|
266
|
+
click.echo(f"""
|
|
267
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
268
|
+
Project ready!
|
|
269
|
+
|
|
270
|
+
Next steps:
|
|
271
|
+
cd {project_name}
|
|
272
|
+
admina dev # local mode (no Docker): proxy + dashboard on :3000
|
|
273
|
+
admina dev --stack # full Docker stack (proxy, redis, clickhouse, minio, grafana)
|
|
274
|
+
python main.py # run example (works without admina dev)
|
|
275
|
+
|
|
276
|
+
Docs: https://admina.org/docs
|
|
277
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
278
|
+
""")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── admina dev helpers ─────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Services and their health-check URLs, keyed by compose service name.
|
|
285
|
+
SERVICE_ENDPOINTS: dict[str, dict[str, str]] = {
|
|
286
|
+
"proxy": {
|
|
287
|
+
"label": "Proxy API",
|
|
288
|
+
"url": "http://localhost:8080",
|
|
289
|
+
"health": "http://localhost:8080/health",
|
|
290
|
+
},
|
|
291
|
+
"dashboard": {
|
|
292
|
+
"label": "Dashboard",
|
|
293
|
+
"url": "http://localhost:3000",
|
|
294
|
+
"health": "http://localhost:3000/",
|
|
295
|
+
},
|
|
296
|
+
"grafana": {
|
|
297
|
+
"label": "Grafana",
|
|
298
|
+
"url": "http://localhost:3001",
|
|
299
|
+
"health": "http://localhost:3001/api/health",
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _load_admina_yaml(project_dir: Path) -> dict[str, object]:
|
|
305
|
+
"""Load and return the parsed admina.yaml from *project_dir*.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
SystemExit: If the file does not exist.
|
|
309
|
+
"""
|
|
310
|
+
yaml_path = project_dir / "admina.yaml"
|
|
311
|
+
if not yaml_path.is_file():
|
|
312
|
+
click.echo(
|
|
313
|
+
"ERROR: admina.yaml not found in current directory. Run 'admina init' first.",
|
|
314
|
+
err=True,
|
|
315
|
+
)
|
|
316
|
+
raise SystemExit(1)
|
|
317
|
+
with open(yaml_path) as fh:
|
|
318
|
+
data = yaml.safe_load(fh) or {}
|
|
319
|
+
return data
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _yaml_hash(project_dir: Path) -> str:
|
|
323
|
+
"""Return a SHA-256 hex digest of admina.yaml for change detection."""
|
|
324
|
+
content = (project_dir / "admina.yaml").read_bytes()
|
|
325
|
+
return hashlib.sha256(content).hexdigest()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _domains_from_yaml(data: dict[str, object]) -> list[str]:
|
|
329
|
+
"""Extract enabled domain names from a parsed admina.yaml dict."""
|
|
330
|
+
domains_raw = data.get("domains", {})
|
|
331
|
+
if not isinstance(domains_raw, dict):
|
|
332
|
+
return list(AVAILABLE_DOMAINS.keys())
|
|
333
|
+
enabled: list[str] = []
|
|
334
|
+
for name in AVAILABLE_DOMAINS:
|
|
335
|
+
section = domains_raw.get(name, {})
|
|
336
|
+
if isinstance(section, dict) and section.get("enabled", False):
|
|
337
|
+
enabled.append(name)
|
|
338
|
+
return enabled
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _maybe_regenerate_compose(project_dir: Path, data: dict[str, object]) -> bool:
|
|
342
|
+
"""Re-generate docker-compose.yml if admina.yaml has changed.
|
|
343
|
+
|
|
344
|
+
Writes a ``.admina_compose_hash`` marker to track the last YAML hash.
|
|
345
|
+
Returns True if the compose file was regenerated.
|
|
346
|
+
"""
|
|
347
|
+
current_hash = _yaml_hash(project_dir)
|
|
348
|
+
hash_file = project_dir / ".admina_compose_hash"
|
|
349
|
+
|
|
350
|
+
if hash_file.is_file() and hash_file.read_text().strip() == current_hash:
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
domains = _domains_from_yaml(data)
|
|
354
|
+
project_name = project_dir.name
|
|
355
|
+
env = _jinja_env()
|
|
356
|
+
context: dict[str, object] = {
|
|
357
|
+
"project_name": project_name,
|
|
358
|
+
"domains": {d: d in domains for d in AVAILABLE_DOMAINS},
|
|
359
|
+
"with_llm": True, # init scaffolds the full template; admina dev gates at runtime
|
|
360
|
+
}
|
|
361
|
+
_generate_file(env, "docker-compose.yml.j2", project_dir / "docker-compose.yml", context)
|
|
362
|
+
hash_file.write_text(current_hash)
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _check_docker() -> bool:
|
|
367
|
+
"""Return True if ``docker`` is available on PATH."""
|
|
368
|
+
return shutil.which("docker") is not None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _health_check(
|
|
372
|
+
url: str,
|
|
373
|
+
*,
|
|
374
|
+
timeout: float = 30.0,
|
|
375
|
+
interval: float = 2.0,
|
|
376
|
+
) -> bool:
|
|
377
|
+
"""Poll *url* until it returns HTTP 2xx or *timeout* expires.
|
|
378
|
+
|
|
379
|
+
Returns True if the service became healthy, False on timeout.
|
|
380
|
+
"""
|
|
381
|
+
deadline = time.monotonic() + timeout
|
|
382
|
+
while time.monotonic() < deadline:
|
|
383
|
+
try:
|
|
384
|
+
resp = urlopen(url, timeout=3) # noqa: S310 — trusted localhost URL
|
|
385
|
+
if 200 <= resp.status < 400:
|
|
386
|
+
return True
|
|
387
|
+
except (URLError, OSError, TimeoutError):
|
|
388
|
+
pass
|
|
389
|
+
time.sleep(interval)
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _wait_for_services(
|
|
394
|
+
services: list[dict[str, str]],
|
|
395
|
+
timeout: float = 30.0,
|
|
396
|
+
interval: float = 2.0,
|
|
397
|
+
) -> list[dict[str, object]]:
|
|
398
|
+
"""Poll health endpoints for each service.
|
|
399
|
+
|
|
400
|
+
Returns a list of dicts with keys ``label``, ``url``, ``healthy``.
|
|
401
|
+
"""
|
|
402
|
+
results: list[dict[str, object]] = []
|
|
403
|
+
for svc in services:
|
|
404
|
+
label = svc["label"]
|
|
405
|
+
click.echo(f" Waiting for {label}...", nl=False)
|
|
406
|
+
healthy = _health_check(svc["health"], timeout=timeout, interval=interval)
|
|
407
|
+
if healthy:
|
|
408
|
+
click.echo(" ready")
|
|
409
|
+
else:
|
|
410
|
+
click.echo(" timeout")
|
|
411
|
+
results.append({"label": label, "url": svc["url"], "healthy": healthy})
|
|
412
|
+
return results
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _print_dev_summary(results: list[dict[str, object]]) -> None:
|
|
416
|
+
"""Print a summary table of running services and next steps."""
|
|
417
|
+
click.echo("\n Admina development stack is running\n")
|
|
418
|
+
click.echo(" Services:")
|
|
419
|
+
for r in results:
|
|
420
|
+
status = "ready" if r["healthy"] else "unhealthy"
|
|
421
|
+
click.echo(f" {r['label']:<20s} {r['url']} ({status})")
|
|
422
|
+
click.echo("""
|
|
423
|
+
Logs:
|
|
424
|
+
docker compose logs -f
|
|
425
|
+
|
|
426
|
+
Stop:
|
|
427
|
+
docker compose down
|
|
428
|
+
|
|
429
|
+
Next steps:
|
|
430
|
+
Dashboard: http://localhost:3000
|
|
431
|
+
API docs: http://localhost:8080/docs
|
|
432
|
+
Health: curl http://localhost:8080/health
|
|
433
|
+
""")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _find_free_port(preferred: int, host: str, search_window: int = 10) -> int:
|
|
437
|
+
"""Return *preferred* if it can be bound on *host*, else the next free
|
|
438
|
+
port in ``[preferred+1, preferred+search_window-1]``.
|
|
439
|
+
|
|
440
|
+
Raises:
|
|
441
|
+
RuntimeError: If no port in the window is free.
|
|
442
|
+
"""
|
|
443
|
+
import socket as _socket
|
|
444
|
+
|
|
445
|
+
bind_host = "127.0.0.1" if host in ("0.0.0.0", "::", "::0") else host
|
|
446
|
+
for port in range(preferred, preferred + search_window):
|
|
447
|
+
try:
|
|
448
|
+
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
|
|
449
|
+
# Don't set SO_REUSEADDR — we want a strict "is this port
|
|
450
|
+
# already serving something" check, not a "could be shared" one.
|
|
451
|
+
s.bind((bind_host, port))
|
|
452
|
+
return port
|
|
453
|
+
except OSError:
|
|
454
|
+
continue
|
|
455
|
+
raise RuntimeError(
|
|
456
|
+
f"No free port in [{preferred}, {preferred + search_window - 1}] on {bind_host}"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _list_local_ipv4() -> list[str]:
|
|
461
|
+
"""Return all IPv4 addresses reachable on this host (best-effort, stdlib only).
|
|
462
|
+
|
|
463
|
+
Always includes 127.0.0.1. Additionally probes the default-route address
|
|
464
|
+
(via UDP-connect trick) and `socket.gethostbyname_ex`, which together
|
|
465
|
+
cover the common cases: LAN IP, hostname-mapped IPs, multi-NIC setups.
|
|
466
|
+
"""
|
|
467
|
+
import socket as _socket
|
|
468
|
+
|
|
469
|
+
ips: set[str] = {"127.0.0.1"}
|
|
470
|
+
try:
|
|
471
|
+
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as s:
|
|
472
|
+
s.settimeout(0.5)
|
|
473
|
+
s.connect(("8.8.8.8", 80))
|
|
474
|
+
ips.add(s.getsockname()[0])
|
|
475
|
+
except OSError:
|
|
476
|
+
pass
|
|
477
|
+
try:
|
|
478
|
+
ips.update(_socket.gethostbyname_ex(_socket.gethostname())[2])
|
|
479
|
+
except (_socket.herror, _socket.gaierror, OSError):
|
|
480
|
+
pass
|
|
481
|
+
# Sort: loopback first, then lexicographic
|
|
482
|
+
return sorted(ips, key=lambda ip: (ip != "127.0.0.1", ip))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _run_local(
|
|
486
|
+
project_dir: Path,
|
|
487
|
+
vault: SecretVault,
|
|
488
|
+
*,
|
|
489
|
+
no_browser: bool,
|
|
490
|
+
port: int,
|
|
491
|
+
host: str,
|
|
492
|
+
) -> None:
|
|
493
|
+
"""Run proxy + dashboard as a single uvicorn process (no Docker)."""
|
|
494
|
+
# Auto-detect free port: if preferred is taken (Docker Desktop, Grafana,
|
|
495
|
+
# another node dev server on :3000…), fall back to the next free port.
|
|
496
|
+
try:
|
|
497
|
+
actual_port = _find_free_port(port, host)
|
|
498
|
+
except RuntimeError as exc:
|
|
499
|
+
click.echo(f"ERROR: {exc}", err=True)
|
|
500
|
+
raise SystemExit(1) from exc
|
|
501
|
+
if actual_port != port:
|
|
502
|
+
click.echo(f" ⚠ Port {port} is already in use — falling back to {actual_port}.")
|
|
503
|
+
port = actual_port
|
|
504
|
+
|
|
505
|
+
env = os.environ.copy()
|
|
506
|
+
env.update(vault.export_env())
|
|
507
|
+
# Local dev defaults — sane for single-user localhost.
|
|
508
|
+
env.setdefault("FORENSIC_BACKEND", "memory")
|
|
509
|
+
env.setdefault("OTEL_ENDPOINT", "")
|
|
510
|
+
env.setdefault("REDIS_URL", "")
|
|
511
|
+
env.setdefault("CLICKHOUSE_HOST", "")
|
|
512
|
+
env.setdefault("MINIO_ENDPOINT", "")
|
|
513
|
+
env.setdefault("UPSTREAM_MCP_URL", "")
|
|
514
|
+
env.setdefault("LOG_LEVEL", "INFO")
|
|
515
|
+
|
|
516
|
+
cmd = [
|
|
517
|
+
sys.executable,
|
|
518
|
+
"-m",
|
|
519
|
+
"uvicorn",
|
|
520
|
+
"admina.proxy.main:app",
|
|
521
|
+
"--host",
|
|
522
|
+
host,
|
|
523
|
+
"--port",
|
|
524
|
+
str(port),
|
|
525
|
+
"--log-level",
|
|
526
|
+
"info",
|
|
527
|
+
]
|
|
528
|
+
|
|
529
|
+
# Display URL: if listening on all interfaces, prefer localhost for the
|
|
530
|
+
# banner but warn that the proxy is exposed to the LAN.
|
|
531
|
+
is_public = host in ("0.0.0.0", "::", "::0")
|
|
532
|
+
display_host = "localhost" if host in ("127.0.0.1", "0.0.0.0", "::", "::0") else host
|
|
533
|
+
|
|
534
|
+
click.echo(f"\n Starting Admina proxy + dashboard on http://{display_host}:{port}")
|
|
535
|
+
click.echo(" Mode: local (no Docker)")
|
|
536
|
+
click.echo(" Forensic backend: in-memory (events live for the process lifetime)")
|
|
537
|
+
if is_public:
|
|
538
|
+
click.echo(
|
|
539
|
+
f" ⚠ Listening on {host}:{port} — accessible from the LAN. "
|
|
540
|
+
"Auth: API key required for /api/*."
|
|
541
|
+
)
|
|
542
|
+
click.echo(" Stop with Ctrl+C\n")
|
|
543
|
+
|
|
544
|
+
proc = subprocess.Popen(cmd, cwd=str(project_dir), env=env)
|
|
545
|
+
|
|
546
|
+
# Wait for /health to become reachable, then open the browser
|
|
547
|
+
healthcheck_host = "127.0.0.1" if host in ("0.0.0.0", "127.0.0.1") else host
|
|
548
|
+
healthy = _health_check(f"http://{healthcheck_host}:{port}/health", timeout=15.0, interval=0.5)
|
|
549
|
+
if healthy:
|
|
550
|
+
# When bound to all interfaces, enumerate each reachable address.
|
|
551
|
+
# Otherwise show the single configured host.
|
|
552
|
+
if is_public:
|
|
553
|
+
click.echo(" Ready. Reachable URLs:")
|
|
554
|
+
for ip in _list_local_ipv4():
|
|
555
|
+
label = "localhost" if ip == "127.0.0.1" else "LAN"
|
|
556
|
+
click.echo(f" http://{ip}:{port} ({label})")
|
|
557
|
+
else:
|
|
558
|
+
click.echo(f" Ready → http://{display_host}:{port}")
|
|
559
|
+
if not no_browser:
|
|
560
|
+
webbrowser.open(f"http://{display_host}:{port}")
|
|
561
|
+
else:
|
|
562
|
+
click.echo(" WARNING: proxy did not become healthy within 15s — continuing")
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
proc.wait()
|
|
566
|
+
except KeyboardInterrupt:
|
|
567
|
+
click.echo("\n Stopping...")
|
|
568
|
+
proc.terminate()
|
|
569
|
+
try:
|
|
570
|
+
proc.wait(timeout=5)
|
|
571
|
+
except subprocess.TimeoutExpired:
|
|
572
|
+
proc.kill()
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _run_compose(
|
|
576
|
+
project_dir: Path,
|
|
577
|
+
data: dict[str, object],
|
|
578
|
+
vault: SecretVault,
|
|
579
|
+
*,
|
|
580
|
+
no_browser: bool,
|
|
581
|
+
no_build: bool,
|
|
582
|
+
detach: bool,
|
|
583
|
+
with_llm: bool,
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Run the Docker compose stack."""
|
|
586
|
+
if not _check_docker():
|
|
587
|
+
click.echo(
|
|
588
|
+
"ERROR: --stack requires Docker. Install Docker Desktop, "
|
|
589
|
+
"or run `admina dev` without --stack for the local mode.",
|
|
590
|
+
err=True,
|
|
591
|
+
)
|
|
592
|
+
raise SystemExit(1)
|
|
593
|
+
|
|
594
|
+
# Regenerate compose if admina.yaml hash changed; force regenerate if
|
|
595
|
+
# the with-llm tier toggles (track it in the hash so cache invalidates).
|
|
596
|
+
current_hash = _yaml_hash(project_dir) + (":with_llm" if with_llm else ":stack")
|
|
597
|
+
hash_file = project_dir / ".admina_compose_hash"
|
|
598
|
+
regen_needed = not hash_file.is_file() or hash_file.read_text().strip() != current_hash
|
|
599
|
+
if regen_needed:
|
|
600
|
+
domains = _domains_from_yaml(data)
|
|
601
|
+
project_name = project_dir.name
|
|
602
|
+
env_j = _jinja_env()
|
|
603
|
+
context: dict[str, object] = {
|
|
604
|
+
"project_name": project_name,
|
|
605
|
+
"domains": {d: d in domains for d in AVAILABLE_DOMAINS},
|
|
606
|
+
"with_llm": with_llm,
|
|
607
|
+
}
|
|
608
|
+
_generate_file(env_j, "docker-compose.yml.j2", project_dir / "docker-compose.yml", context)
|
|
609
|
+
hash_file.write_text(current_hash)
|
|
610
|
+
click.echo(" docker-compose.yml regenerated")
|
|
611
|
+
else:
|
|
612
|
+
click.echo(" docker-compose.yml is up to date")
|
|
613
|
+
domains = _domains_from_yaml(data)
|
|
614
|
+
|
|
615
|
+
compose_cmd: list[str] = ["docker", "compose", "up"]
|
|
616
|
+
if not no_build:
|
|
617
|
+
compose_cmd.append("--build")
|
|
618
|
+
if detach:
|
|
619
|
+
compose_cmd.append("-d")
|
|
620
|
+
|
|
621
|
+
compose_env = os.environ.copy()
|
|
622
|
+
compose_env.update(vault.export_env())
|
|
623
|
+
|
|
624
|
+
click.echo(f" Running: {' '.join(compose_cmd)}\n")
|
|
625
|
+
|
|
626
|
+
# Always start in detached mode internally so we can health-check.
|
|
627
|
+
detach_cmd = compose_cmd if detach else compose_cmd + ["-d"]
|
|
628
|
+
result = subprocess.run(
|
|
629
|
+
detach_cmd,
|
|
630
|
+
cwd=str(project_dir),
|
|
631
|
+
env=compose_env,
|
|
632
|
+
capture_output=True,
|
|
633
|
+
text=True,
|
|
634
|
+
)
|
|
635
|
+
if result.returncode != 0:
|
|
636
|
+
click.echo(
|
|
637
|
+
f"ERROR: docker compose up failed.\n{result.stderr}\nTry: docker compose logs",
|
|
638
|
+
err=True,
|
|
639
|
+
)
|
|
640
|
+
raise SystemExit(1)
|
|
641
|
+
|
|
642
|
+
active_services: list[dict[str, str]] = []
|
|
643
|
+
for svc_name, svc_info in SERVICE_ENDPOINTS.items():
|
|
644
|
+
if svc_name == "proxy" and "agent_security" not in domains:
|
|
645
|
+
continue
|
|
646
|
+
if svc_name == "grafana" and "compliance" not in domains:
|
|
647
|
+
continue
|
|
648
|
+
active_services.append(svc_info)
|
|
649
|
+
|
|
650
|
+
click.echo()
|
|
651
|
+
results = _wait_for_services(active_services)
|
|
652
|
+
|
|
653
|
+
if not no_browser:
|
|
654
|
+
webbrowser.open("http://localhost:3000")
|
|
655
|
+
|
|
656
|
+
_print_dev_summary(results)
|
|
657
|
+
|
|
658
|
+
if not detach:
|
|
659
|
+
click.echo(" Attaching to logs (Ctrl+C to stop)...\n")
|
|
660
|
+
try:
|
|
661
|
+
subprocess.run(["docker", "compose", "logs", "-f"], cwd=str(project_dir))
|
|
662
|
+
except KeyboardInterrupt:
|
|
663
|
+
click.echo("\n Stopping...")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@app.command()
|
|
667
|
+
@click.option(
|
|
668
|
+
"--stack",
|
|
669
|
+
is_flag=True,
|
|
670
|
+
default=False,
|
|
671
|
+
help="Run the full Docker stack (proxy + dashboard + redis + clickhouse + minio + otel + grafana).",
|
|
672
|
+
)
|
|
673
|
+
@click.option(
|
|
674
|
+
"--with-llm",
|
|
675
|
+
is_flag=True,
|
|
676
|
+
default=False,
|
|
677
|
+
help="Add local LLM services (ollama + chromadb + open-webui) to --stack. Implies --stack.",
|
|
678
|
+
)
|
|
679
|
+
@click.option("--no-browser", is_flag=True, default=False, help="Skip opening browser.")
|
|
680
|
+
@click.option("--no-build", is_flag=True, default=False, help="Use existing images (--stack only).")
|
|
681
|
+
@click.option("--detach", is_flag=True, default=False, help="Run in background (--stack only).")
|
|
682
|
+
@click.option(
|
|
683
|
+
"--port",
|
|
684
|
+
type=int,
|
|
685
|
+
default=3000,
|
|
686
|
+
help="Port for local mode (default 3000 — same as Docker stack dashboard).",
|
|
687
|
+
)
|
|
688
|
+
@click.option(
|
|
689
|
+
"--host",
|
|
690
|
+
type=str,
|
|
691
|
+
default="127.0.0.1",
|
|
692
|
+
help=(
|
|
693
|
+
"Bind address for local mode (default 127.0.0.1). "
|
|
694
|
+
"Use 0.0.0.0 to listen on all interfaces (LAN access)."
|
|
695
|
+
),
|
|
696
|
+
)
|
|
697
|
+
@click.option(
|
|
698
|
+
"--public",
|
|
699
|
+
is_flag=True,
|
|
700
|
+
default=False,
|
|
701
|
+
help="Shortcut for --host 0.0.0.0 (listen on all interfaces).",
|
|
702
|
+
)
|
|
703
|
+
def dev(
|
|
704
|
+
stack: bool,
|
|
705
|
+
with_llm: bool,
|
|
706
|
+
no_browser: bool,
|
|
707
|
+
no_build: bool,
|
|
708
|
+
detach: bool,
|
|
709
|
+
port: int,
|
|
710
|
+
host: str,
|
|
711
|
+
public: bool,
|
|
712
|
+
) -> None:
|
|
713
|
+
"""Start Admina locally.
|
|
714
|
+
|
|
715
|
+
Three modes:
|
|
716
|
+
|
|
717
|
+
\b
|
|
718
|
+
admina dev Local mode (default): one uvicorn process,
|
|
719
|
+
dashboard served on the same port. No Docker.
|
|
720
|
+
admina dev --stack Docker compose: proxy + dashboard + redis +
|
|
721
|
+
clickhouse + minio + otel + grafana.
|
|
722
|
+
admina dev --with-llm --stack plus local LLM services
|
|
723
|
+
(ollama + chromadb + open-webui).
|
|
724
|
+
|
|
725
|
+
Local-mode network binding:
|
|
726
|
+
|
|
727
|
+
\b
|
|
728
|
+
admina dev → 127.0.0.1:3000 (localhost only)
|
|
729
|
+
admina dev --host 0.0.0.0 → all interfaces (LAN access)
|
|
730
|
+
admina dev --public → shortcut for --host 0.0.0.0
|
|
731
|
+
admina dev --port 9000 → custom port
|
|
732
|
+
"""
|
|
733
|
+
project_dir = Path.cwd()
|
|
734
|
+
|
|
735
|
+
# Common bootstrap: admina.yaml + secrets vault
|
|
736
|
+
data = _load_admina_yaml(project_dir)
|
|
737
|
+
click.echo(" admina.yaml loaded")
|
|
738
|
+
|
|
739
|
+
vault = SecretVault(project_dir)
|
|
740
|
+
if not vault.is_initialized:
|
|
741
|
+
_bootstrap_secrets(project_dir)
|
|
742
|
+
else:
|
|
743
|
+
click.echo(" Secrets vault loaded")
|
|
744
|
+
vault.write_dotenv(project_dir / ".env")
|
|
745
|
+
|
|
746
|
+
if stack or with_llm:
|
|
747
|
+
_run_compose(
|
|
748
|
+
project_dir,
|
|
749
|
+
data,
|
|
750
|
+
vault,
|
|
751
|
+
no_browser=no_browser,
|
|
752
|
+
no_build=no_build,
|
|
753
|
+
detach=detach,
|
|
754
|
+
with_llm=with_llm,
|
|
755
|
+
)
|
|
756
|
+
else:
|
|
757
|
+
bind_host = "0.0.0.0" if public else host
|
|
758
|
+
_run_local(
|
|
759
|
+
project_dir,
|
|
760
|
+
vault,
|
|
761
|
+
no_browser=no_browser,
|
|
762
|
+
port=port,
|
|
763
|
+
host=bind_host,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# ── admina plugin helpers ──────────────────────────────────
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
# Maps --type flag values to the registry type keys.
|
|
771
|
+
PLUGIN_TYPE_CHOICES: list[str] = [
|
|
772
|
+
"model_adapter",
|
|
773
|
+
"data_connector",
|
|
774
|
+
"governance_guard",
|
|
775
|
+
"compliance_template",
|
|
776
|
+
"transport_adapter",
|
|
777
|
+
"forensic_store",
|
|
778
|
+
"auth_provider",
|
|
779
|
+
"pii_engine",
|
|
780
|
+
"alert_channel",
|
|
781
|
+
]
|
|
782
|
+
|
|
783
|
+
# Maps type key → (base class name, name property, category dir)
|
|
784
|
+
_SCAFFOLD_META: dict[str, tuple[str, str, str]] = {
|
|
785
|
+
"model_adapter": ("BaseModelAdapter", "name", "adapters"),
|
|
786
|
+
"data_connector": ("BaseDataConnector", "name", "connectors"),
|
|
787
|
+
"governance_guard": ("BaseGovernanceGuard", "name", "guards"),
|
|
788
|
+
"compliance_template": ("BaseComplianceTemplate", "framework_name", "compliance"),
|
|
789
|
+
"transport_adapter": ("BaseTransportAdapter", "protocol_name", "transports"),
|
|
790
|
+
"forensic_store": ("BaseForensicStore", "store_name", "forensic"),
|
|
791
|
+
"auth_provider": ("BaseAuthProvider", "provider_name", "auth"),
|
|
792
|
+
"pii_engine": ("BasePIIEngine", "supported_languages", "pii"),
|
|
793
|
+
"alert_channel": ("BaseAlertChannel", "channel_name", "alerts"),
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _pip_install(package: str) -> subprocess.CompletedProcess[str]:
|
|
798
|
+
"""Run ``pip install <package>`` and return the result."""
|
|
799
|
+
return subprocess.run(
|
|
800
|
+
[sys.executable, "-m", "pip", "install", package],
|
|
801
|
+
capture_output=True,
|
|
802
|
+
text=True,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _discover_and_list_plugins() -> dict[str, dict[str, type]]:
|
|
807
|
+
"""Run plugin discovery and return all registered plugins by type."""
|
|
808
|
+
from admina.plugins.registry import PluginRegistry
|
|
809
|
+
|
|
810
|
+
registry = PluginRegistry()
|
|
811
|
+
registry.discover()
|
|
812
|
+
return registry.list_all()
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _scaffold_plugin(
|
|
816
|
+
plugin_name: str,
|
|
817
|
+
plugin_type: str,
|
|
818
|
+
output_dir: Path,
|
|
819
|
+
) -> list[str]:
|
|
820
|
+
"""Generate boilerplate files for a new plugin.
|
|
821
|
+
|
|
822
|
+
Returns list of created file paths (relative to *output_dir*).
|
|
823
|
+
"""
|
|
824
|
+
base_class, name_prop, _category = _SCAFFOLD_META[plugin_type]
|
|
825
|
+
class_name = "".join(w.capitalize() for w in plugin_name.replace("-", "_").split("_"))
|
|
826
|
+
|
|
827
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
828
|
+
|
|
829
|
+
env = _jinja_env()
|
|
830
|
+
context: dict[str, object] = {
|
|
831
|
+
"plugin_name": plugin_name,
|
|
832
|
+
"plugin_type": plugin_type,
|
|
833
|
+
"base_class": base_class,
|
|
834
|
+
"class_name": class_name,
|
|
835
|
+
"name_property": name_prop,
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
created: list[str] = []
|
|
839
|
+
|
|
840
|
+
# Main plugin module
|
|
841
|
+
plugin_file = output_dir / f"{plugin_name.replace('-', '_')}.py"
|
|
842
|
+
_generate_file(env, "plugin.py.j2", plugin_file, context)
|
|
843
|
+
created.append(plugin_file.name)
|
|
844
|
+
|
|
845
|
+
# Test file
|
|
846
|
+
tests_dir = output_dir / "tests"
|
|
847
|
+
tests_dir.mkdir(exist_ok=True)
|
|
848
|
+
test_file = tests_dir / f"test_{plugin_name.replace('-', '_')}.py"
|
|
849
|
+
_generate_file(env, "plugin_test.py.j2", test_file, context)
|
|
850
|
+
created.append(f"tests/{test_file.name}")
|
|
851
|
+
|
|
852
|
+
# pyproject.toml
|
|
853
|
+
pyproject_file = output_dir / "pyproject.toml"
|
|
854
|
+
_generate_file(env, "plugin_pyproject.toml.j2", pyproject_file, context)
|
|
855
|
+
created.append("pyproject.toml")
|
|
856
|
+
|
|
857
|
+
# README
|
|
858
|
+
readme_file = output_dir / "README.md"
|
|
859
|
+
_generate_file(env, "plugin_readme.md.j2", readme_file, context)
|
|
860
|
+
created.append("README.md")
|
|
861
|
+
|
|
862
|
+
return created
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
@app.group()
|
|
866
|
+
def plugin() -> None:
|
|
867
|
+
"""Manage Admina plugins."""
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
@plugin.command("install")
|
|
871
|
+
@click.argument("package_name")
|
|
872
|
+
def plugin_install(package_name: str) -> None:
|
|
873
|
+
"""Install an Admina plugin via pip and register it.
|
|
874
|
+
|
|
875
|
+
PACKAGE_NAME is a pip-installable package (e.g. admina-adapter-bedrock).
|
|
876
|
+
"""
|
|
877
|
+
click.echo(f" Installing {package_name}...")
|
|
878
|
+
result = _pip_install(package_name)
|
|
879
|
+
|
|
880
|
+
if result.returncode != 0:
|
|
881
|
+
click.echo(f" ERROR: pip install failed.\n{result.stderr}", err=True)
|
|
882
|
+
raise SystemExit(1)
|
|
883
|
+
|
|
884
|
+
click.echo(f" Installed {package_name}")
|
|
885
|
+
|
|
886
|
+
# Add to admina.yaml plugins list if present
|
|
887
|
+
yaml_path = Path.cwd() / "admina.yaml"
|
|
888
|
+
if yaml_path.is_file():
|
|
889
|
+
data = yaml.safe_load(yaml_path.read_text()) or {}
|
|
890
|
+
plugins = data.get("plugins", [])
|
|
891
|
+
if not isinstance(plugins, list):
|
|
892
|
+
plugins = []
|
|
893
|
+
module_name = package_name.replace("-", "_")
|
|
894
|
+
if module_name not in plugins:
|
|
895
|
+
plugins.append(module_name)
|
|
896
|
+
data["plugins"] = plugins
|
|
897
|
+
yaml_path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
|
898
|
+
click.echo(f" Added {module_name} to admina.yaml plugins list")
|
|
899
|
+
|
|
900
|
+
click.echo(f"\n Plugin {package_name} is ready to use.")
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _plugin_install_path(cls: type) -> str:
|
|
904
|
+
"""Return the source file path of a plugin class.
|
|
905
|
+
|
|
906
|
+
Falls back to ``cls.__module__`` if the file cannot be resolved
|
|
907
|
+
(e.g., dynamic classes or zip-imports).
|
|
908
|
+
"""
|
|
909
|
+
import inspect
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
path = Path(inspect.getfile(cls)).resolve()
|
|
913
|
+
except (TypeError, OSError):
|
|
914
|
+
return f"<module {cls.__module__}>"
|
|
915
|
+
# Make paths under the installed admina package readable as
|
|
916
|
+
# "admina/plugins/.../foo.py" rather than absolute site-packages.
|
|
917
|
+
try:
|
|
918
|
+
admina_pkg_root = Path(__file__).resolve().parents[1]
|
|
919
|
+
return str(path.relative_to(admina_pkg_root.parent))
|
|
920
|
+
except ValueError:
|
|
921
|
+
return str(path)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@plugin.command("list")
|
|
925
|
+
def plugin_list() -> None:
|
|
926
|
+
"""List all installed Admina plugins by type, with their source path."""
|
|
927
|
+
all_plugins = _discover_and_list_plugins()
|
|
928
|
+
total = 0
|
|
929
|
+
|
|
930
|
+
click.echo("\n Installed plugins:\n")
|
|
931
|
+
for type_key in PLUGIN_TYPE_CHOICES:
|
|
932
|
+
plugins = all_plugins.get(type_key, {})
|
|
933
|
+
if plugins:
|
|
934
|
+
label = type_key.replace("_", " ").title()
|
|
935
|
+
click.echo(f" {label}:")
|
|
936
|
+
for name, cls in sorted(plugins.items()):
|
|
937
|
+
path = _plugin_install_path(cls)
|
|
938
|
+
click.echo(f" - {name}")
|
|
939
|
+
click.echo(f" module: {cls.__module__}")
|
|
940
|
+
click.echo(f" path: {path}")
|
|
941
|
+
total += 1
|
|
942
|
+
click.echo()
|
|
943
|
+
|
|
944
|
+
if total == 0:
|
|
945
|
+
click.echo(" No plugins found.")
|
|
946
|
+
else:
|
|
947
|
+
click.echo(f" Total: {total} plugin(s)")
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
@plugin.command("create")
|
|
951
|
+
@click.argument("plugin_name")
|
|
952
|
+
@click.option(
|
|
953
|
+
"--type",
|
|
954
|
+
"plugin_type",
|
|
955
|
+
type=click.Choice(PLUGIN_TYPE_CHOICES),
|
|
956
|
+
default="model_adapter",
|
|
957
|
+
help="Plugin type to scaffold.",
|
|
958
|
+
)
|
|
959
|
+
def plugin_create(plugin_name: str, plugin_type: str) -> None:
|
|
960
|
+
"""Scaffold boilerplate for a new Admina plugin.
|
|
961
|
+
|
|
962
|
+
Creates a directory with the plugin module, tests, pyproject.toml,
|
|
963
|
+
and README.
|
|
964
|
+
"""
|
|
965
|
+
output_dir = Path.cwd() / plugin_name
|
|
966
|
+
|
|
967
|
+
if output_dir.exists() and any(output_dir.iterdir()):
|
|
968
|
+
click.echo(f" ERROR: Directory {plugin_name}/ already exists and is not empty.", err=True)
|
|
969
|
+
raise SystemExit(1)
|
|
970
|
+
|
|
971
|
+
click.echo(f"\n Scaffolding plugin: {plugin_name}")
|
|
972
|
+
click.echo(f" Type: {plugin_type}\n")
|
|
973
|
+
|
|
974
|
+
created = _scaffold_plugin(plugin_name, plugin_type, output_dir)
|
|
975
|
+
for f in created:
|
|
976
|
+
click.echo(f" {f}")
|
|
977
|
+
|
|
978
|
+
click.echo(f"""
|
|
979
|
+
Plugin scaffolded in {plugin_name}/
|
|
980
|
+
|
|
981
|
+
Next steps:
|
|
982
|
+
cd {plugin_name}
|
|
983
|
+
# Edit {created[0]} to implement your plugin
|
|
984
|
+
pip install -e .
|
|
985
|
+
admina plugin list
|
|
986
|
+
""")
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
@app.command()
|
|
990
|
+
def doctor() -> None:
|
|
991
|
+
"""Check Admina installation health.
|
|
992
|
+
|
|
993
|
+
Verifies Python version, core dependencies, optional extras,
|
|
994
|
+
governance engines, and infrastructure connectivity.
|
|
995
|
+
"""
|
|
996
|
+
import platform
|
|
997
|
+
|
|
998
|
+
ok_mark = "[OK]"
|
|
999
|
+
warn_mark = "[WARN]"
|
|
1000
|
+
fail_mark = "[FAIL]"
|
|
1001
|
+
issues: list[str] = []
|
|
1002
|
+
|
|
1003
|
+
click.echo("")
|
|
1004
|
+
click.echo("=" * 55)
|
|
1005
|
+
click.echo(" Admina Doctor — Installation Health Check")
|
|
1006
|
+
click.echo("=" * 55)
|
|
1007
|
+
|
|
1008
|
+
# ── Python version ───────────────────────────────────────
|
|
1009
|
+
py_ver = platform.python_version()
|
|
1010
|
+
py_ok = tuple(int(x) for x in py_ver.split(".")[:2]) >= (3, 10)
|
|
1011
|
+
click.echo(f"\n Python: {py_ver} {ok_mark if py_ok else fail_mark}")
|
|
1012
|
+
if not py_ok:
|
|
1013
|
+
issues.append("Python >= 3.10 required")
|
|
1014
|
+
|
|
1015
|
+
# ── Admina version ───────────────────────────────────────
|
|
1016
|
+
try:
|
|
1017
|
+
from admina import __version__
|
|
1018
|
+
|
|
1019
|
+
click.echo(f" Admina: {__version__} {ok_mark}")
|
|
1020
|
+
except ImportError:
|
|
1021
|
+
click.echo(f" Admina: not installed {fail_mark}")
|
|
1022
|
+
issues.append("Run: pip install -e .")
|
|
1023
|
+
|
|
1024
|
+
# ── Core deps ────────────────────────────────────────────
|
|
1025
|
+
click.echo("\n Core dependencies:")
|
|
1026
|
+
for mod_name, pkg_name in [("yaml", "pyyaml"), ("click", "click"), ("jinja2", "jinja2")]:
|
|
1027
|
+
try:
|
|
1028
|
+
__import__(mod_name)
|
|
1029
|
+
click.echo(f" {pkg_name:20s} {ok_mark}")
|
|
1030
|
+
except ImportError:
|
|
1031
|
+
click.echo(f" {pkg_name:20s} {fail_mark}")
|
|
1032
|
+
issues.append(f"Missing: pip install {pkg_name}")
|
|
1033
|
+
|
|
1034
|
+
# ── Optional extras ──────────────────────────────────────
|
|
1035
|
+
click.echo("\n Optional extras:")
|
|
1036
|
+
extras = {
|
|
1037
|
+
"proxy": [
|
|
1038
|
+
("fastapi", "fastapi"),
|
|
1039
|
+
("uvicorn", "uvicorn"),
|
|
1040
|
+
("httpx", "httpx"),
|
|
1041
|
+
("pydantic", "pydantic"),
|
|
1042
|
+
("redis", "redis"),
|
|
1043
|
+
("minio", "minio"),
|
|
1044
|
+
("clickhouse_connect", "clickhouse-connect"),
|
|
1045
|
+
],
|
|
1046
|
+
"nlp": [
|
|
1047
|
+
("spacy", "spacy"),
|
|
1048
|
+
("sklearn", "scikit-learn"),
|
|
1049
|
+
("numpy", "numpy"),
|
|
1050
|
+
],
|
|
1051
|
+
"telemetry": [
|
|
1052
|
+
("opentelemetry", "opentelemetry-api"),
|
|
1053
|
+
],
|
|
1054
|
+
}
|
|
1055
|
+
for group, mods in extras.items():
|
|
1056
|
+
present = 0
|
|
1057
|
+
for mod_name, _ in mods:
|
|
1058
|
+
try:
|
|
1059
|
+
__import__(mod_name)
|
|
1060
|
+
present += 1
|
|
1061
|
+
except ImportError:
|
|
1062
|
+
pass
|
|
1063
|
+
if present == len(mods):
|
|
1064
|
+
click.echo(f" [{group}]{' ' * (16 - len(group))} {ok_mark} ({present}/{len(mods)})")
|
|
1065
|
+
elif present > 0:
|
|
1066
|
+
click.echo(
|
|
1067
|
+
f" [{group}]{' ' * (16 - len(group))} {warn_mark} ({present}/{len(mods)})"
|
|
1068
|
+
)
|
|
1069
|
+
else:
|
|
1070
|
+
click.echo(f" [{group}]{' ' * (16 - len(group))} -- not installed")
|
|
1071
|
+
|
|
1072
|
+
# ── spaCy NER model ──────────────────────────────────────
|
|
1073
|
+
click.echo("\n NLP engine:")
|
|
1074
|
+
try:
|
|
1075
|
+
import spacy # type: ignore[import-untyped]
|
|
1076
|
+
|
|
1077
|
+
try:
|
|
1078
|
+
spacy.load("en_core_web_sm")
|
|
1079
|
+
click.echo(f" en_core_web_sm {ok_mark}")
|
|
1080
|
+
except OSError:
|
|
1081
|
+
click.echo(
|
|
1082
|
+
f" en_core_web_sm {warn_mark} (run: python -m spacy download en_core_web_sm)"
|
|
1083
|
+
)
|
|
1084
|
+
issues.append("spaCy model not installed — PII uses regex-only mode")
|
|
1085
|
+
except ImportError:
|
|
1086
|
+
click.echo(" spacy -- not installed (PII disabled)")
|
|
1087
|
+
|
|
1088
|
+
# ── Rust engine ──────────────────────────────────────────
|
|
1089
|
+
click.echo("\n Governance engine:")
|
|
1090
|
+
try:
|
|
1091
|
+
import admina_core # type: ignore[import-untyped]
|
|
1092
|
+
|
|
1093
|
+
click.echo(f" Rust engine {ok_mark} v{admina_core.version()}")
|
|
1094
|
+
except ImportError:
|
|
1095
|
+
click.echo(" Rust engine -- not installed (using Python fallback)")
|
|
1096
|
+
click.echo(f" Python fallback {ok_mark}")
|
|
1097
|
+
|
|
1098
|
+
# ── Plugin registry ──────────────────────────────────────
|
|
1099
|
+
click.echo("\n Plugin registry:")
|
|
1100
|
+
try:
|
|
1101
|
+
from admina.plugins.registry import PluginRegistry
|
|
1102
|
+
|
|
1103
|
+
reg = PluginRegistry()
|
|
1104
|
+
count = reg.discover()
|
|
1105
|
+
click.echo(f" Discovered {ok_mark} {count} plugins")
|
|
1106
|
+
except Exception as exc:
|
|
1107
|
+
import traceback
|
|
1108
|
+
|
|
1109
|
+
logging.getLogger("admina.cli").debug(
|
|
1110
|
+
"Plugin discovery traceback:\n%s", traceback.format_exc()
|
|
1111
|
+
)
|
|
1112
|
+
click.echo(f" Discovered {fail_mark} {exc}")
|
|
1113
|
+
issues.append(f"Plugin discovery failed: {exc}")
|
|
1114
|
+
|
|
1115
|
+
# ── Environment variables ────────────────────────────────
|
|
1116
|
+
click.echo("\n Environment:")
|
|
1117
|
+
# Layer 1: process env. Layer 2: a .env file in cwd (the project's
|
|
1118
|
+
# vault-generated file). Process env wins so a deliberate override
|
|
1119
|
+
# is honoured.
|
|
1120
|
+
env = dict(os.environ)
|
|
1121
|
+
dotenv_loaded = False
|
|
1122
|
+
dotenv_path = Path.cwd() / ".env"
|
|
1123
|
+
if dotenv_path.is_file():
|
|
1124
|
+
try:
|
|
1125
|
+
for line in dotenv_path.read_text(encoding="utf-8").splitlines():
|
|
1126
|
+
line = line.strip()
|
|
1127
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
1128
|
+
continue
|
|
1129
|
+
k, _, v = line.partition("=")
|
|
1130
|
+
k = k.strip()
|
|
1131
|
+
v = v.strip().strip('"').strip("'")
|
|
1132
|
+
env.setdefault(k, v)
|
|
1133
|
+
dotenv_loaded = True
|
|
1134
|
+
except OSError:
|
|
1135
|
+
pass
|
|
1136
|
+
if dotenv_loaded:
|
|
1137
|
+
click.echo(" (loaded ./.env)")
|
|
1138
|
+
|
|
1139
|
+
env_checks = [
|
|
1140
|
+
("ADMINA_API_KEY", True, "auth disabled — set a strong key in production"),
|
|
1141
|
+
("ADMINA_GOVERNANCE_MODE", False, "default 'enforce'"),
|
|
1142
|
+
("ADMINA_DASHBOARD_PASSWORD", False, "dashboard basic-auth disabled"),
|
|
1143
|
+
]
|
|
1144
|
+
for var, required, hint in env_checks:
|
|
1145
|
+
val = env.get(var, "")
|
|
1146
|
+
if val:
|
|
1147
|
+
shown = val if len(val) <= 8 else f"{val[:4]}…({len(val)} chars)"
|
|
1148
|
+
click.echo(f" {var:30s} {ok_mark} {shown}")
|
|
1149
|
+
else:
|
|
1150
|
+
mark = warn_mark if not required else fail_mark
|
|
1151
|
+
click.echo(f" {var:30s} {mark} {hint}")
|
|
1152
|
+
if required:
|
|
1153
|
+
issues.append(f"Set {var} (run `admina dev` or `./scripts/bootstrap-secrets.sh`)")
|
|
1154
|
+
|
|
1155
|
+
# ── Docker ───────────────────────────────────────────────
|
|
1156
|
+
click.echo("\n Infrastructure:")
|
|
1157
|
+
docker_ok = _check_docker()
|
|
1158
|
+
click.echo(
|
|
1159
|
+
f" Docker {ok_mark if docker_ok else warn_mark + ' not found (needed for full stack)'}"
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
# ── docker compose stack ─────────────────────────────────
|
|
1163
|
+
if docker_ok and Path("docker-compose.yml").exists():
|
|
1164
|
+
try:
|
|
1165
|
+
ps = subprocess.run(
|
|
1166
|
+
["docker", "compose", "ps", "--format", "{{.Name}}\t{{.Status}}"],
|
|
1167
|
+
capture_output=True,
|
|
1168
|
+
text=True,
|
|
1169
|
+
timeout=10,
|
|
1170
|
+
)
|
|
1171
|
+
if ps.returncode == 0 and ps.stdout.strip():
|
|
1172
|
+
lines = [ln for ln in ps.stdout.strip().split("\n") if ln.strip()]
|
|
1173
|
+
healthy = sum(1 for ln in lines if "healthy" in ln.lower())
|
|
1174
|
+
unhealthy = [ln for ln in lines if "unhealthy" in ln.lower()]
|
|
1175
|
+
running = len(lines)
|
|
1176
|
+
click.echo(
|
|
1177
|
+
f" docker compose {ok_mark} {running} services running, {healthy} healthy"
|
|
1178
|
+
)
|
|
1179
|
+
for ln in unhealthy:
|
|
1180
|
+
name = ln.split("\t", 1)[0]
|
|
1181
|
+
click.echo(f" {warn_mark} {name} is unhealthy")
|
|
1182
|
+
issues.append(
|
|
1183
|
+
f"Container {name} unhealthy — check `docker compose logs {name}`"
|
|
1184
|
+
)
|
|
1185
|
+
else:
|
|
1186
|
+
click.echo(" docker compose -- no stack running (run: admina dev)")
|
|
1187
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
1188
|
+
pass
|
|
1189
|
+
|
|
1190
|
+
# ── Dashboard reachability ───────────────────────────────
|
|
1191
|
+
if docker_ok:
|
|
1192
|
+
try:
|
|
1193
|
+
import urllib.request as _req
|
|
1194
|
+
|
|
1195
|
+
with _req.urlopen("http://localhost:3000/health", timeout=2) as r:
|
|
1196
|
+
if r.status == 200:
|
|
1197
|
+
click.echo(f" dashboard /health {ok_mark} http://localhost:3000")
|
|
1198
|
+
except (OSError, ValueError):
|
|
1199
|
+
click.echo(
|
|
1200
|
+
" dashboard /health -- not reachable (stack down or different port)"
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
# ── Proxy reachability ───────────────────────────────────
|
|
1204
|
+
if docker_ok:
|
|
1205
|
+
try:
|
|
1206
|
+
import urllib.request as _req
|
|
1207
|
+
|
|
1208
|
+
with _req.urlopen("http://localhost:8080/health", timeout=2) as r:
|
|
1209
|
+
if r.status == 200:
|
|
1210
|
+
click.echo(f" proxy /health {ok_mark} http://localhost:8080")
|
|
1211
|
+
except (OSError, ValueError):
|
|
1212
|
+
click.echo(
|
|
1213
|
+
" proxy /health -- not reachable (stack down or different port)"
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
# ── Summary ──────────────────────────────────────────────
|
|
1217
|
+
click.echo("\n" + "=" * 55)
|
|
1218
|
+
if not issues:
|
|
1219
|
+
click.echo(" All checks passed. Admina is ready.")
|
|
1220
|
+
else:
|
|
1221
|
+
click.echo(f" {len(issues)} issue(s) found:")
|
|
1222
|
+
for issue in issues:
|
|
1223
|
+
click.echo(f" - {issue}")
|
|
1224
|
+
click.echo("=" * 55)
|
|
1225
|
+
click.echo("")
|
|
1226
|
+
|
|
1227
|
+
if issues:
|
|
1228
|
+
sys.exit(1)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
# ── admina password commands ──────────────────────────────
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
@app.group()
|
|
1235
|
+
def password() -> None:
|
|
1236
|
+
"""Manage Admina platform credentials."""
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
@password.command("show")
|
|
1240
|
+
def password_show() -> None:
|
|
1241
|
+
"""Display current API key and password from the vault."""
|
|
1242
|
+
vault = SecretVault(Path.cwd())
|
|
1243
|
+
if not vault.is_initialized:
|
|
1244
|
+
click.echo(" No vault found. Run 'admina init' or 'admina dev' first.", err=True)
|
|
1245
|
+
raise SystemExit(1)
|
|
1246
|
+
|
|
1247
|
+
if sys.stdin.isatty():
|
|
1248
|
+
click.confirm(" This will display secrets in your terminal. Continue?", abort=True)
|
|
1249
|
+
|
|
1250
|
+
data = vault.export_env()
|
|
1251
|
+
click.echo()
|
|
1252
|
+
click.echo(f" API Key: {data.get('ADMINA_API_KEY', '(not set)')}")
|
|
1253
|
+
click.echo(f" Password: {data.get('ADMINA_DASHBOARD_PASSWORD', '(not set)')}")
|
|
1254
|
+
click.echo(" (shared across dashboard, Grafana, MinIO, ClickHouse)")
|
|
1255
|
+
click.echo()
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
@password.command("reset")
|
|
1259
|
+
def password_reset() -> None:
|
|
1260
|
+
"""Generate a new random password and API key."""
|
|
1261
|
+
vault = SecretVault(Path.cwd())
|
|
1262
|
+
if not vault.is_initialized:
|
|
1263
|
+
click.echo(" No vault found. Run 'admina init' or 'admina dev' first.", err=True)
|
|
1264
|
+
raise SystemExit(1)
|
|
1265
|
+
|
|
1266
|
+
generated = vault.bootstrap()
|
|
1267
|
+
vault.write_dotenv(Path.cwd() / ".env")
|
|
1268
|
+
|
|
1269
|
+
click.echo()
|
|
1270
|
+
click.echo(" Credentials regenerated:")
|
|
1271
|
+
click.echo(f" API Key: {generated['ADMINA_API_KEY']}")
|
|
1272
|
+
click.echo(f" Password: {generated['ADMINA_DASHBOARD_PASSWORD']}")
|
|
1273
|
+
click.echo()
|
|
1274
|
+
click.echo(" Restart services to apply: docker compose up --build -d")
|
|
1275
|
+
click.echo()
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
@password.command("set")
|
|
1279
|
+
@click.option(
|
|
1280
|
+
"--password",
|
|
1281
|
+
"new_password",
|
|
1282
|
+
prompt=True,
|
|
1283
|
+
hide_input=True,
|
|
1284
|
+
confirmation_prompt=True,
|
|
1285
|
+
help="New password.",
|
|
1286
|
+
)
|
|
1287
|
+
def password_set(new_password: str) -> None:
|
|
1288
|
+
"""Set a custom password for all web UIs."""
|
|
1289
|
+
ok, issues = validate_password(new_password)
|
|
1290
|
+
if not ok:
|
|
1291
|
+
click.echo("\n Password does not meet requirements:", err=True)
|
|
1292
|
+
for issue in issues:
|
|
1293
|
+
click.echo(f" - {issue}", err=True)
|
|
1294
|
+
click.echo()
|
|
1295
|
+
raise SystemExit(1)
|
|
1296
|
+
|
|
1297
|
+
vault = SecretVault(Path.cwd())
|
|
1298
|
+
if not vault.is_initialized:
|
|
1299
|
+
click.echo(" No vault found. Run 'admina init' or 'admina dev' first.", err=True)
|
|
1300
|
+
raise SystemExit(1)
|
|
1301
|
+
|
|
1302
|
+
vault.update_password(new_password)
|
|
1303
|
+
vault.write_dotenv(Path.cwd() / ".env")
|
|
1304
|
+
|
|
1305
|
+
click.echo("\n Password updated across all services.")
|
|
1306
|
+
click.echo(" Restart services to apply: docker compose up --build -d\n")
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
@app.command()
|
|
1310
|
+
@click.option(
|
|
1311
|
+
"--output",
|
|
1312
|
+
"-o",
|
|
1313
|
+
type=click.Path(path_type=Path),
|
|
1314
|
+
default=Path("admina.yaml"),
|
|
1315
|
+
help="Where to write the resulting YAML (default: ./admina.yaml).",
|
|
1316
|
+
)
|
|
1317
|
+
@click.option(
|
|
1318
|
+
"--non-interactive",
|
|
1319
|
+
is_flag=True,
|
|
1320
|
+
help="Skip prompts and write a default-restrictive admina.yaml.",
|
|
1321
|
+
)
|
|
1322
|
+
def configure(output: Path, non_interactive: bool) -> None:
|
|
1323
|
+
"""Interactive wizard to produce an admina.yaml.
|
|
1324
|
+
|
|
1325
|
+
Walks through the small set of choices that affect day-1 behaviour:
|
|
1326
|
+
governance mode (enforce / observe / dry-run), firewall categories
|
|
1327
|
+
to disable, loop-breaker thresholds, PII categories.
|
|
1328
|
+
|
|
1329
|
+
Defaults are restrictive: enforce mode, all firewall categories on,
|
|
1330
|
+
EU-aware PII set on. The wizard never recommends a "Pro" upgrade
|
|
1331
|
+
or a paid feature — the OSS edition is fully usable as-is.
|
|
1332
|
+
"""
|
|
1333
|
+
|
|
1334
|
+
if output.exists():
|
|
1335
|
+
if not click.confirm(f" {output} already exists. Overwrite?", default=False):
|
|
1336
|
+
click.echo(" Aborted. No file written.", err=True)
|
|
1337
|
+
raise SystemExit(1)
|
|
1338
|
+
|
|
1339
|
+
click.echo("")
|
|
1340
|
+
click.echo("=" * 55)
|
|
1341
|
+
click.echo(" Admina Configuration Wizard")
|
|
1342
|
+
click.echo("=" * 55)
|
|
1343
|
+
click.echo("")
|
|
1344
|
+
click.echo(" Defaults are restrictive (enforce mode, all categories on).")
|
|
1345
|
+
click.echo(" Press Enter at any prompt to keep the default.")
|
|
1346
|
+
click.echo("")
|
|
1347
|
+
|
|
1348
|
+
# ── Governance mode ───────────────────────────────────────
|
|
1349
|
+
if non_interactive:
|
|
1350
|
+
mode = "enforce"
|
|
1351
|
+
else:
|
|
1352
|
+
click.echo(" Governance mode:")
|
|
1353
|
+
click.echo(" enforce → block flagged requests (production)")
|
|
1354
|
+
click.echo(" observe → never block, log 'would have blocked'")
|
|
1355
|
+
click.echo(" dry-run → like observe + tag responses")
|
|
1356
|
+
mode = click.prompt(
|
|
1357
|
+
" Mode",
|
|
1358
|
+
default="enforce",
|
|
1359
|
+
type=click.Choice(["enforce", "observe", "dry-run"]),
|
|
1360
|
+
show_choices=False,
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
# ── Firewall categories ───────────────────────────────────
|
|
1364
|
+
builtin_cats = [
|
|
1365
|
+
"instruction_override",
|
|
1366
|
+
"role_hijack",
|
|
1367
|
+
"prompt_extraction",
|
|
1368
|
+
"jailbreak",
|
|
1369
|
+
"delimiter_injection",
|
|
1370
|
+
"data_exfiltration",
|
|
1371
|
+
"tool_abuse",
|
|
1372
|
+
"obfuscation",
|
|
1373
|
+
"multilang_evasion",
|
|
1374
|
+
]
|
|
1375
|
+
disabled: list[str] = []
|
|
1376
|
+
if not non_interactive:
|
|
1377
|
+
click.echo("")
|
|
1378
|
+
click.echo(" Firewall categories (all enabled by default):")
|
|
1379
|
+
for cat in builtin_cats:
|
|
1380
|
+
click.echo(f" - {cat}")
|
|
1381
|
+
raw = click.prompt(
|
|
1382
|
+
" Categories to DISABLE (comma-separated, empty for none)",
|
|
1383
|
+
default="",
|
|
1384
|
+
show_default=False,
|
|
1385
|
+
)
|
|
1386
|
+
disabled = [c.strip() for c in raw.split(",") if c.strip() in builtin_cats]
|
|
1387
|
+
unknown = [c.strip() for c in raw.split(",") if c.strip() and c.strip() not in builtin_cats]
|
|
1388
|
+
if unknown:
|
|
1389
|
+
click.echo(f" (ignored unknown categories: {', '.join(unknown)})", err=True)
|
|
1390
|
+
|
|
1391
|
+
# ── Loop breaker thresholds ───────────────────────────────
|
|
1392
|
+
if non_interactive:
|
|
1393
|
+
loop_window, loop_thresh, loop_max = 10, 0.85, 3
|
|
1394
|
+
else:
|
|
1395
|
+
click.echo("")
|
|
1396
|
+
click.echo(" Loop breaker (anti-runaway agent):")
|
|
1397
|
+
loop_window = click.prompt(" Sliding window size", default=10, type=int)
|
|
1398
|
+
loop_thresh = click.prompt(" TF-IDF similarity threshold", default=0.85, type=float)
|
|
1399
|
+
loop_max = click.prompt(" Max consecutive similar messages", default=3, type=int)
|
|
1400
|
+
|
|
1401
|
+
# ── PII categories ────────────────────────────────────────
|
|
1402
|
+
pii_default = [
|
|
1403
|
+
"email",
|
|
1404
|
+
"phone",
|
|
1405
|
+
"credit_card",
|
|
1406
|
+
"ssn",
|
|
1407
|
+
"iban",
|
|
1408
|
+
"ip",
|
|
1409
|
+
"person",
|
|
1410
|
+
"org",
|
|
1411
|
+
"it_codice_fiscale",
|
|
1412
|
+
"es_dni",
|
|
1413
|
+
]
|
|
1414
|
+
if non_interactive:
|
|
1415
|
+
pii_categories = pii_default
|
|
1416
|
+
else:
|
|
1417
|
+
click.echo("")
|
|
1418
|
+
click.echo(" PII categories to redact:")
|
|
1419
|
+
for cat in pii_default:
|
|
1420
|
+
click.echo(f" - {cat}")
|
|
1421
|
+
raw = click.prompt(
|
|
1422
|
+
" Categories to ADD or REMOVE (e.g. '+de_personalausweis,-org')",
|
|
1423
|
+
default="",
|
|
1424
|
+
show_default=False,
|
|
1425
|
+
)
|
|
1426
|
+
pii_categories = list(pii_default)
|
|
1427
|
+
for token in raw.split(","):
|
|
1428
|
+
token = token.strip()
|
|
1429
|
+
if not token:
|
|
1430
|
+
continue
|
|
1431
|
+
if token.startswith("-") and token[1:] in pii_categories:
|
|
1432
|
+
pii_categories.remove(token[1:])
|
|
1433
|
+
elif token.startswith("+") and token[1:] not in pii_categories:
|
|
1434
|
+
pii_categories.append(token[1:])
|
|
1435
|
+
|
|
1436
|
+
# ── Write YAML ────────────────────────────────────────────
|
|
1437
|
+
yaml_text = _render_admina_yaml(
|
|
1438
|
+
mode=mode,
|
|
1439
|
+
firewall_disabled=disabled,
|
|
1440
|
+
loop_window=loop_window,
|
|
1441
|
+
loop_thresh=loop_thresh,
|
|
1442
|
+
loop_max=loop_max,
|
|
1443
|
+
pii_categories=pii_categories,
|
|
1444
|
+
)
|
|
1445
|
+
output.write_text(yaml_text, encoding="utf-8")
|
|
1446
|
+
|
|
1447
|
+
click.echo("")
|
|
1448
|
+
click.echo(f" Wrote {output}")
|
|
1449
|
+
click.echo("")
|
|
1450
|
+
click.echo(" Next steps:")
|
|
1451
|
+
click.echo(" - Review the generated file")
|
|
1452
|
+
click.echo(" - Set ADMINA_API_KEY in .env (run 'admina dev' to bootstrap)")
|
|
1453
|
+
click.echo(" - Start the stack: admina dev")
|
|
1454
|
+
click.echo("")
|
|
1455
|
+
if mode != "enforce":
|
|
1456
|
+
click.echo(
|
|
1457
|
+
f" ⚠ Governance mode is '{mode}' — flagged requests will NOT "
|
|
1458
|
+
"be blocked. Switch to 'enforce' once you have tuned the policies."
|
|
1459
|
+
)
|
|
1460
|
+
click.echo("")
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _render_admina_yaml(
|
|
1464
|
+
*,
|
|
1465
|
+
mode: str,
|
|
1466
|
+
firewall_disabled: list[str],
|
|
1467
|
+
loop_window: int,
|
|
1468
|
+
loop_thresh: float,
|
|
1469
|
+
loop_max: int,
|
|
1470
|
+
pii_categories: list[str],
|
|
1471
|
+
) -> str:
|
|
1472
|
+
"""Render an admina.yaml from wizard inputs. Pure function for testing."""
|
|
1473
|
+
disabled_list = (
|
|
1474
|
+
"\n ".join(f"- {c}" for c in firewall_disabled) if firewall_disabled else "[]"
|
|
1475
|
+
)
|
|
1476
|
+
pii_list = ", ".join(pii_categories)
|
|
1477
|
+
disabled_block = (
|
|
1478
|
+
f" disabled_categories:\n {disabled_list}"
|
|
1479
|
+
if firewall_disabled
|
|
1480
|
+
else " disabled_categories: []"
|
|
1481
|
+
)
|
|
1482
|
+
return f"""# Generated by `admina configure`.
|
|
1483
|
+
# Edit freely; this is the source of truth for the proxy at startup.
|
|
1484
|
+
|
|
1485
|
+
schema_version: 1
|
|
1486
|
+
|
|
1487
|
+
domains:
|
|
1488
|
+
data_sovereignty:
|
|
1489
|
+
enabled: true
|
|
1490
|
+
pii:
|
|
1491
|
+
enabled: true
|
|
1492
|
+
categories: [{pii_list}]
|
|
1493
|
+
ner_model: en_core_web_sm
|
|
1494
|
+
|
|
1495
|
+
agent_security:
|
|
1496
|
+
enabled: true
|
|
1497
|
+
firewall:
|
|
1498
|
+
enabled: true
|
|
1499
|
+
mode: {mode}
|
|
1500
|
+
{disabled_block}
|
|
1501
|
+
custom_patterns: []
|
|
1502
|
+
loop_breaker:
|
|
1503
|
+
enabled: true
|
|
1504
|
+
window_size: {loop_window}
|
|
1505
|
+
similarity_threshold: {loop_thresh}
|
|
1506
|
+
max_consecutive: {loop_max}
|
|
1507
|
+
|
|
1508
|
+
compliance:
|
|
1509
|
+
enabled: true
|
|
1510
|
+
eu_ai_act:
|
|
1511
|
+
enabled: true
|
|
1512
|
+
|
|
1513
|
+
dashboard:
|
|
1514
|
+
enabled: true
|
|
1515
|
+
port: 3000
|
|
1516
|
+
|
|
1517
|
+
auth_provider: apikey
|
|
1518
|
+
"""
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
if __name__ == "__main__":
|
|
1522
|
+
app()
|