stage-17: operational hardening — .env keys, model status, backup
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-<ts>.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 <noreply@anthropic.com>
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"pynacl>=1.5",
|
||||
"structlog>=24.1",
|
||||
"sqlalchemy>=2.0",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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})…")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<a href="/ledger">Ledger</a>
|
||||
<a href="/train">Trainline</a>
|
||||
</nav>
|
||||
<span class="model-status" id="model-status" data-state="checking" title="checking…">
|
||||
<span class="model-status-dot"></span><span class="model-status-text">model</span>
|
||||
</span>
|
||||
<div class="family">
|
||||
<img class="family-icon" src="/static/nn-sc-icon.png" alt="NN-sc — Security/Control" title="NN-sc · Security — psyc, the security app of the neuronetz platform">
|
||||
</div>
|
||||
@@ -29,5 +32,20 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">psyc 0.1 — defensive CTI prototype · powered by <a href="https://neuronetz.ai" target="_blank" rel="noopener">neuronetz.ai</a></footer>
|
||||
<script>
|
||||
fetch('/api/inference-status').then(r => r.json()).then(s => {
|
||||
const el = document.getElementById('model-status');
|
||||
if (!el) return;
|
||||
if (s.online) {
|
||||
el.dataset.state = 'online';
|
||||
el.title = 'model up · ' + s.adapter;
|
||||
el.querySelector('.model-status-text').textContent = 'model · live';
|
||||
} else {
|
||||
el.dataset.state = 'offline';
|
||||
el.title = 'inference server unreachable — Classifier bot falls back to rules';
|
||||
el.querySelector('.model-status-text').textContent = 'model · offline';
|
||||
}
|
||||
}).catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user