feat(milestone): merge phase/01 operator-foundation → milestone/v0.4-operator-tier

Phase 1 complete — Operator Foundation:
- Postgres 16 in Docker-in-LXC (asyncpg pool, 5-table schema, PgStore, migrations)
- Operator auth (argon2id, signed stateless cookies, slowapi 5/min rate limit)
- VC issuer key migration SQLite→Postgres (archive-before-active, R-VC-MIG-01)
- Operator bootstrap CLI (create-operator.py, idempotent)
- Backup cron script + G-008 restore drill
- Graceful degradation (server starts without Postgres)
- 272 tests pass, 33 skip (Postgres-requiring), 0 fail

---ci---
project: praxis
phase: 1
milestone: v0.4
status: complete
requirements:
  covered: [REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 01:41:06 +00:00
parent acbe8692ae
commit 00e39a3f85
33 changed files with 3255 additions and 94 deletions
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Praxis v0.4 — Nightly Postgres backup (D-055, G-008).
#
# Host-side cron script (decoupled from praxis service uptime —
# RESEARCH-v0.4 §1.5). Runs pg_dump inside the postgres container and
# writes a compressed custom-format dump to the pgbackups volume.
#
# The %u date format = day-of-week 1..7 (Monday=1, Sunday=7) → rolling
# 7-file retention with zero cleanup logic (D-055). Re-running overwrites
# the same day-of-week file.
#
# Cron entry (host, 03:30 CT nightly):
# 30 3 * * * /opt/praxis/scripts/backup-pg.sh
#
# Restore drill (G-008 — run at least once in staging to prove the backup
# is valid; NEVER restore into a live DB without stopping praxis first):
# docker compose stop praxis
# docker compose exec postgres pg_restore -U praxis -d praxis \
# --clean --if-exists /backups/praxis-3.dump
# # verify: \d operators; SELECT count(*) FROM operators; (etc. for all 5 tables)
# docker compose start praxis
#
# POSIX-sh compatible (no bashisms). Exit 0 on success, 1 on failure.
# Args: none. Env: COMPOSE_PROJECT_DIR (default: current dir).
set -eu
PROJECT_DIR="${COMPOSE_PROJECT_DIR:-$(pwd)}"
cd "$PROJECT_DIR"
DOW="$(date +%u)"
DUMP_FILE="/backups/praxis-${DOW}.dump"
echo "backup-pg: dumping praxis DB → ${DUMP_FILE} (day-of-week ${DOW})"
# -Fc = custom compressed format (works with pg_restore --clean --if-exists).
# -T stops the container from streaming while dumping? No — pg_dump is
# consistent within a transaction; the praxis service can stay up.
docker compose exec -T postgres pg_dump -U praxis -Fc praxis -f "$DUMP_FILE"
# Verify the dump is non-empty (sanity — a 0-byte dump means failure).
SIZE=$(docker compose exec -T postgres stat -c '%s' "$DUMP_FILE" 2>/dev/null || echo 0)
if [ "$SIZE" -le 0 ]; then
echo "backup-pg: ERROR — dump file is empty (${DUMP_FILE})" >&2
exit 1
fi
echo "backup-pg: OK — ${DUMP_FILE} is ${SIZE} bytes"
echo "backup-pg: restore drill (G-008): docker compose exec postgres pg_restore -U praxis -d praxis --clean --if-exists ${DUMP_FILE}"
exit 0
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Praxis v0.4 — Operator bootstrap CLI (TASK-05-01, D-052).
Creates the initial operator from env-provided credentials. Idempotent
(ON CONFLICT DO NOTHING). The --update flag forces a rehash + update.
Env:
PRAXIS_BOOTSTRAP_OPERATOR_USER — operator username (required)
PRAXIS_BOOTSTRAP_OPERATOR_PASS — operator password (required)
PRAXIS_PG_DSN — Postgres DSN (required)
Exit: 0 on success (created or already-exists), 1 on missing env / DB error.
Retries on connection failure (3 attempts, 5s backoff — R-BOOT-01).
Run:
PRAXIS_BOOTSTRAP_OPERATOR_USER=admin PRAXIS_BOOTSTRAP_OPERATOR_PASS=... \
PRAXIS_PG_DSN=postgresql://praxis:...@postgres:5432/praxis \
python3 scripts/create-operator.py
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from argon2 import PasswordHasher
_ph = PasswordHasher()
_RETRY_ATTEMPTS = 3
_RETRY_BACKOFF_S = 5.0
async def create_operator(update: bool = False) -> int:
user = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_USER", "").strip()
pw = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_PASS", "")
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
if not user or not pw:
print(
"create-operator: ERROR — PRAXIS_BOOTSTRAP_OPERATOR_USER and "
"PRAXIS_BOOTSTRAP_OPERATOR_PASS must be set (R-BOOT-02).",
file=sys.stderr,
)
return 1
if not dsn:
print(
"create-operator: ERROR — PRAXIS_PG_DSN must be set.",
file=sys.stderr,
)
return 1
import asyncpg
from db.pg_migrate import apply_pg_migrations
from db.pg_store import PgStore
last_exc: Exception | None = None
for attempt in range(1, _RETRY_ATTEMPTS + 1):
try:
pool = await asyncpg.create_pool(
dsn=dsn, min_size=1, max_size=3, command_timeout=10
)
try:
await apply_pg_migrations(pool)
store = PgStore(pool)
pw_hash = _ph.hash(pw)
display = user
oid = await store.insert_operator(
user, pw_hash, display, on_conflict_update=update
)
if update:
print(f"create-operator: updated operator {user!r} (id={oid})")
elif oid is not None:
print(f"create-operator: created operator {user!r} (id={oid})")
else:
print(f"create-operator: operator {user!r} already exists (no change)")
return 0
finally:
await pool.close()
except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc:
last_exc = exc
if attempt < _RETRY_ATTEMPTS:
print(
f"create-operator: connection attempt {attempt} failed "
f"({exc}); retrying in {_RETRY_BACKOFF_S}s (R-BOOT-01)...",
file=sys.stderr,
)
await asyncio.sleep(_RETRY_BACKOFF_S)
continue
print(f"create-operator: ERROR — could not connect after {_RETRY_ATTEMPTS} "
f"attempts: {last_exc}", file=sys.stderr)
return 1
def main() -> int:
parser = argparse.ArgumentParser(description="Create the initial Praxis operator.")
parser.add_argument(
"--update", action="store_true",
help="Force rehash + update if the operator already exists.",
)
args = parser.parse_args()
return asyncio.run(create_operator(update=args.update))
if __name__ == "__main__":
raise SystemExit(main())
+4 -1
View File
@@ -40,7 +40,10 @@ upid=$(pve_curl POST "$create_path" \
"hostname=${hostname}" \
"storage=${storage}" \
"rootfs=${storage}:16" \
"memory=${PROXMOX_MEMORY_MB:-4096}" \
# v0.4: 6144MB default (was 4096 in v0.2). Postgres ~400MB + praxis
# ~500MB + Docker daemon ~200MB + build headroom ~1GB + margin
# (REQ-NFR-MT-01). Override with PROXMOX_MEMORY_MB if needed.
"memory=${PROXMOX_MEMORY_MB:-6144}" \
"net0=name=eth0,bridge=vmbr0,ip=dhcp" \
"arch=amd64" \
"features=nesting=1")