feat(P01): SLICE-05 operator bootstrap CLI + secrets scope
- TASK-05-01 scripts/create-operator.py: CLI that reads PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS + PRAXIS_PG_DSN from env, creates the pool, applies migrations, hashes the password with argon2id, and INSERTs with ON CONFLICT DO NOTHING (idempotent — D-052). --update flag forces rehash + ON CONFLICT DO UPDATE. Missing env → exit 1 (R-BOOT-02). Connection failure → 3x retry with 5s backoff (R-BOOT-01). - TASK-05-02 .ciagent/config.json: added "operator" secrets scope (PRAXIS_PG_PASSWORD, PRAXIS_COOKIE_SECRET, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY). .ciagent/.env.secrets.example: template (committed, no real secrets). .gitignore: added negations so .env.secrets.example is tracked while .env.secrets stays ignored. - TASK-05-03 tests/test_create_operator.py: 7 tests (mocked PgStore) — create, already-exists (no update), --update rehashes, missing env → exit 1, password is argon2id (not plaintext). ---ci--- project: praxis phase: 1 milestone: v0.4 status: execute persona: devops-engineer task: 05-01,05-02,05-03 requirements: covered: [REQ-AUTH-01] ---/ci---
This commit is contained in:
Executable
+106
@@ -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())
|
||||
Reference in New Issue
Block a user