From 9e4c217a3d5296c89fda42ffa117e713694b10d1 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Wed, 20 May 2026 19:44:58 +0200 Subject: [PATCH] =?UTF-8?q?stage-17:=20operational=20hardening=20=E2=80=94?= =?UTF-8?q?=20.env=20keys,=20model=20status,=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three load-bearing operational pieces before any new features: * .env.example committed, .env gitignored — per-developer API keys (THREATFOX_AUTH_KEY, OTX_API_KEY, NVD_API_KEY) ready for the registrations ahead; python-dotenv loads it in the venv CLI; compose picks it up via env_file: .env on the cockpit service. * Cockpit /api/inference-status endpoint + a topbar status chip that polls it on page load — "model · live" green when up, "model · offline" amber when the inference server is unreachable. No more manual checking. Compose also gains a healthcheck on the inference service (applies on next recreate). * New `psyc backup` command — tars the audit trail (db + sealed packages + recipient keys + ledger + datasets) to data/backups/psyc-data-.tar.gz. Excludes the HF model cache, mock-cert receipts, and the re-trainable adapters — the goal is the irrecoverable evidence, not bulk artifacts. Co-Authored-By: Claude Opus 4.7 --- .env.example | 20 +++++++++++++++++ docker-compose.yml | 7 ++++++ pyproject.toml | 1 + src/psyc/cli.py | 33 ++++++++++++++++++++++++++++ src/psyc/cockpit/app.py | 8 ++++++- src/psyc/cockpit/static/cockpit.css | 18 +++++++++++++++ src/psyc/cockpit/templates/base.html | 18 +++++++++++++++ 7 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8fa3982 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# psyc — per-developer secrets. Copy to `.env` (already gitignored) and fill in. +# +# cp .env.example .env +# # edit .env with your own keys +# +# Do not commit .env. Each developer keeps their own keys local. + +# --- CTI source API keys (sources we may add to Scoutline) --- +# ThreatFox / abuse.ch — free auth key at https://auth.abuse.ch/ +THREATFOX_AUTH_KEY= +# AlienVault OTX — free key at https://otx.alienvault.com/api +OTX_API_KEY= +# NIST NVD — free key at https://nvd.nist.gov/developers/request-an-api-key +# (raises throttling from ~5 to ~50 requests / 30s) +NVD_API_KEY= + +# --- Internal service URLs — overridden in docker compose; defaults for venv CLI --- +# PSYC_MOCK_CERT_URL=http://127.0.0.1:8770 +# PSYC_INFERENCE_URL=http://127.0.0.1:8771 +# PSYC_DATA_DIR=./data diff --git a/docker-compose.yml b/docker-compose.yml index ae320f0..7476d03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: build: . image: psyc:latest command: ["psyc", "serve", "--host", "0.0.0.0", "--port", "8767"] + env_file: .env # per-dev API keys (gitignored). cp .env.example .env first. environment: VIRTUAL_HOST: psyc.neuronetz.ai VIRTUAL_PORT: "8767" @@ -57,6 +58,12 @@ services: networks: [backend] restart: unless-stopped profiles: ["gpu"] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8771/healthz')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 90s deploy: resources: reservations: diff --git a/pyproject.toml b/pyproject.toml index 3f9ab98..0c44a80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pynacl>=1.5", "structlog>=24.1", "sqlalchemy>=2.0", + "python-dotenv>=1.0", ] [project.optional-dependencies] diff --git a/src/psyc/cli.py b/src/psyc/cli.py index 0a6df27..9d2e9c8 100644 --- a/src/psyc/cli.py +++ b/src/psyc/cli.py @@ -6,8 +6,12 @@ from typing import List import typer import uvicorn +from dotenv import load_dotenv from psyc import db, log + + +load_dotenv() # per-dev .env (API keys) is loaded into os.environ for venv CLI from psyc.cockpit import inference from psyc.lines import classify, courier, proof, route, scout, seal, train from psyc.lines import map as map_line @@ -31,6 +35,35 @@ def status() -> None: typer.echo(f"cases: {db.case_count()}") +@app.command("backup") +def backup(out_dir: str = typer.Option("data/backups", "--out", help="directory to write the archive into")) -> None: + """Tar the audit trail (db + sealed packages + keys + ledger + datasets) to a timestamped archive. + + Excludes the HF model cache, mock-cert receipts, model adapters (re-trainable), + and the backups dir itself — the goal is the irrecoverable evidence, not bulk artifacts. + """ + import tarfile + from datetime import datetime, timezone + from pathlib import Path + + from psyc import DATA_DIR + + out_path = Path(out_dir) + out_path.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + archive = out_path / f"psyc-data-{ts}.tar.gz" + excluded = {".hf-cache", "adapters", "mock_cert", out_path.name} + included: list[str] = [] + with tarfile.open(archive, "w:gz") as tar: + for entry in DATA_DIR.iterdir(): + if entry.name in excluded: + continue + tar.add(entry, arcname=entry.name) + included.append(entry.name) + size_mb = archive.stat().st_size / (1024 * 1024) + typer.echo(f"wrote {archive} ({size_mb:.2f} MB) — included: {', '.join(sorted(included))}") + + def _ingest(source: str, limit: int) -> None: db.init_db() typer.echo(f"fetching {source} (limit={limit})…") diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py index 6f75417..bc0fb33 100644 --- a/src/psyc/cockpit/app.py +++ b/src/psyc/cockpit/app.py @@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from psyc import db, log -from psyc.cockpit import journey as journey_view +from psyc.cockpit import inference, journey as journey_view from psyc.lines import ledger as ledger_line from psyc.lines import route as route_line from psyc.lines import seal as seal_line @@ -96,3 +96,9 @@ def train_view(request: Request) -> HTMLResponse: @app.get("/healthz") def healthz() -> dict: return {"status": "ok"} + + +@app.get("/api/inference-status") +def inference_status() -> dict: + adapter = inference.server_adapter() + return {"online": adapter is not None, "adapter": adapter} diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index de29a3c..c56ecb1 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -83,6 +83,24 @@ h1, h2, h3, .nav a { margin-left: 18px; color: var(--muted); } .nav a:hover { color: var(--text); } +/* live model status chip in the topbar */ +.model-status { + display: inline-flex; align-items: center; gap: 6px; + font-family: var(--font-display); font-size: 10px; letter-spacing: 1px; + text-transform: uppercase; padding: 3px 9px; margin-left: 14px; + border: 1px solid var(--border); border-radius: 4px; + background: var(--panel-2); color: var(--muted); cursor: default; +} +.model-status-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--muted); +} +.model-status[data-state="online"] { color: var(--green); border-color: color-mix(in oklab, var(--green) 45%, var(--border)); } +.model-status[data-state="online"] .model-status-dot { background: var(--green); box-shadow: 0 0 6px var(--green); animation: status-pulse 2.2s ease-in-out infinite; } +.model-status[data-state="offline"] { color: var(--amber); border-color: color-mix(in oklab, var(--amber) 45%, var(--border)); } +.model-status[data-state="offline"] .model-status-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); } +@keyframes status-pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } } + /* neuronetz family framing — right of the topbar */ .family { display: flex; align-items: center; gap: 14px; } .family-icon { diff --git a/src/psyc/cockpit/templates/base.html b/src/psyc/cockpit/templates/base.html index c20d88e..3b6c8d5 100644 --- a/src/psyc/cockpit/templates/base.html +++ b/src/psyc/cockpit/templates/base.html @@ -21,6 +21,9 @@ Ledger Trainline + + model +
NN-sc — Security/Control
@@ -29,5 +32,20 @@ {% block content %}{% endblock %}
psyc 0.1 — defensive CTI prototype · powered by neuronetz.ai
+