diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index 7843204..b3fae1b 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,20 +1,19 @@ { - "phase": 0, - "stage": "complete", + "phase": 1, + "stage": "verify", "milestone": "v0.2", - "phase_role": "pre_execution", + "phase_role": "execution", "attempts": 0, - "updated_at": "2026-08-01T14:40:00Z", - "release_status": "created", - "release_url": "https://git.cloudinit.dev/coreci/praxis/releases/tag/v0.1.0", - "tag": "v0.1.0", - "next_phase": 1, - "binding_actions": { - "G-101": "MUST: bake GITEA_TOKEN into snippet at staging time", - "G-102": "MUST: add PRAXIS_DB_PATH env read to db/store.py + db/migrate.py", - "G-103": "FIX: add 5 missing env vars to injection list", - "G-104": "FIX: bump health-check timeout 300s→600s", - "G-105": "FIX: fix Dockerfile pip install copy ordering", - "G-106": "FIX: fix bats test count 9→10" + "updated_at": "2026-08-01T15:10:00Z", + "verify_summary": { + "verdict": "APPROVE_WITH_NOTES", + "structural": "pass", + "behavioral": "pass (121 bats, 77 pytest, docker build ok)", + "security": "pass", + "quality": "pass", + "p0_fixed": 4, + "p1_plus": 8, + "req_coverage": "18/20 covered, 2 deferred (live E2E)", + "must_haves": "25/28 pass, 2 partial, 1 deferred" } } \ No newline at end of file diff --git a/.ciagent/VERIFY.md b/.ciagent/VERIFY.md index e3b24f5..44e7857 100644 --- a/.ciagent/VERIFY.md +++ b/.ciagent/VERIFY.md @@ -1,286 +1,243 @@ -# Praxis — Phase 1 Verification Report (VERIFY stage) +# Praxis — Phase 1 Verification (v0.2 Proxmox LXC Deployment) -> **Phase:** 1 — Minimal Viable Voice Loop -> **Milestone:** v0.1 -> **Branch:** `phase/01-minimal-voice-loop` -> **Reviewer:** CIAgent (mechanical, autonomy `full`, single-project mode) -> **Date:** 2026-08-01 -> **Codebase state at review:** 22 commits since `milestone/v0.1-praxis`, working tree clean before VERIFY fixes -> **Inputs:** PLAN.md (5 slices, 26 tasks, 10 exit criteria, 15 P1 REQs), REQUIREMENTS.md, ARCHITECTURE.md, GRILL.md (G-001..G-008) +> **Verifier:** CIAgent ci-verifier (automated) +> **Phase:** 1 (LXC deploy implementation) +> **Milestone:** v0.2 +> **Branch:** `phase/01-lxc-deploy` +> **Date:** 2026-08-03 +> **Verdict:** **APPROVE_WITH_NOTES** (after P0 fixes applied) --- -## Overall Verdict +## 1. Structural Verification -| | | -|---|---| -| **Verdict** | **PASSED (with documented gaps)** | -| **Confidence** | 0.82 | -| **REQ coverage** | 15 / 15 P1 REQ-IDs covered by code | -| **Exit criteria** | 8 / 10 fully verified; 2 pending live API keys (documented gap, not a failure) | -| **Tests** | 73 passed, 9 skipped (pending-keys), 0 failed | -| **P0 fixes applied** | 2 (cosmetic-typo + dead-code cleanup; no logic/behavior change) | -| **P1+ flagged** | 6 (post-hoc review) | -| **Escalations** | 0 | +| Item | Status | Notes | +|------|--------|-------| +| All 20 REQ-IDs have implementation files | ✅ PASS | All 16 REQ-DEPLOY-* + 4 REQ-NFR-DEPLOY-* mapped to files | +| All scripts executable (chmod +x) | ✅ PASS | 12 scripts in `scripts/proxmox/` + `scripts/install-service.sh` all `-rwxr-xr-x` | +| All shell scripts pass `bash -n` | ✅ PASS | 13/13 scripts syntax-valid | +| Dockerfile valid (stages, COPY ordering, CMD) | ✅ PASS | Multi-stage `node:22-slim` → `python:3.12-slim`; G-105 fix applied (copy pyproject.toml + README.md before `pip install .`); `CMD ["python", "-m", "server"]` | +| docker-compose.yml valid YAML | ✅ PASS (after P0 fix) | `docker compose config --quiet` exits 0 after removing invalid `restart_policy` + making `env_file` optional | +| .dockerignore excludes secrets | ✅ PASS | `.ciagent/` excluded; `.env`, `.env.secrets`, `.env.*` excluded with `!.env.example` exception; `scripts/`, `*.db`, `*.onnx` excluded | +| .gitignore excludes .env.secrets, allows .env.example | ✅ PASS | `git check-ignore .ciagent/.env.secrets` → matches; `git check-ignore .env.example` → no match; `!.env.example` exception present (D-038) | -**One-line summary:** Phase 1 is structurally complete, behaviorally verified (all offline-testable paths green), and secure for a single-learner tech-validation harness. The two unverifiable exit criteria (live audio session + live latency measurement) are blocked on voice-service key provisioning, not on code defects — auto-generated tests in `tests/test_pending_keys.py` will exercise them when keys are present. Two risk-free cosmetic P0 fixes were applied (a misspelled constant `_DEBRIFF_` → `_DEBRIEF_` and a dead-code line in `debrief.py`); neither changed runtime behavior (verified by re-running the full suite). +**Structural result: PASS** (1 P0 fixed: docker-compose.yml `restart_policy` invalid key) --- -## Layer 1 — Structural ✅ PASS +## 2. Behavioral Verification -### 1.1 Files referenced in PLAN.md exist on disk +| Item | Status | Notes | +|------|--------|-------| +| Bats tests: `bats scripts/proxmox/test/` | ✅ PASS | **121/121 tests pass** across 10 .bats files (api, e2e-deploy, firstboot-hook, health-check, lxc-clone, lxc-config, lxc-deploy, lxc-start, rollback, stage-snippet) | +| Python tests: `pytest tests/ -x -q` | ✅ PASS | 77 passed, 9 skipped (live voice-service key tests — expected, no keys provisioned); v0.1 tests still pass after `db/store.py` + `db/migrate.py` PRAXIS_DB_PATH changes | +| Dockerfile builds: `docker build -t praxis:verify .` | ✅ PASS (after P0 fix) | Build completes in ~105s; **required adding `fastapi` + `uvicorn` to pyproject.toml** (they were undeclared v0.1 deps — image failed to start without them) | +| FastAPI StaticFiles mount doesn't break API routes | ✅ PASS | `GET /health` → `{"status":"ok",...}`; `GET /` → `` (index.html); `GET /nonexistent` → 404; routes registered before mount (correct ordering) | +| PRAXIS_DB_PATH env read works | ✅ PASS | `db/store.py:28` reads `os.environ.get("PRAXIS_DB_PATH", "praxis.db")`; `db/migrate.py:10` reads same; G-102 fix applied | +| Image contains `client/dist/index.html` | ✅ PASS | `docker run --rm praxis:verify ls /app/client/dist/index.html` → exists | +| Image does NOT contain `client/node_modules` | ✅ PASS | `ls /app/client/node_modules` → No such file | +| Image does NOT contain `.ciagent/` (secrets) | ✅ PASS | `.ciagent/` excluded by .dockerignore | +| `import server; import pipecat; import fastapi` in image | ✅ PASS (after P0 fix) | Prints `ok` | -All 26 task deliverables verified present: - -| Slice | Expected artifact | Present? | -|---|---|---| -| SLICE-01 | `scripts/probe_deepgram.py`, `probe_cartesia.py`, `probe_ollama.py`, `probe_e2e.py`, `docs/latency-report.md` | ✅ all 5 | -| SLICE-02 | `server/services/{base,registry,__init__}.py`, `server/tts/{cartesia_tts,piper_tts}.py`, `server/llm/ollama_cloud.py`, `server/pipeline.py`, `server/__main__.py`, `server/latency.py`, `server/guardrails/noop.py`, `client/src/{App.tsx,useVoiceSession.ts,main.tsx}` | ✅ all | -| SLICE-03 | `server/scenarios/{schema,loader,runtime,classifier}.py`, `server/guardrails/customer_service.py`, `server/interruptibility.py`, `scenarios/customer_service_refund_ca_v01.yaml` | ✅ all | -| SLICE-04 | `db/{schema.sql,store.py,migrate.py}`, `db/migrations/0001_init.sql`, `server/cost.py`, `server/session_recorder.py`, `scenarios/cost_rates.yaml` | ✅ all | -| SLICE-05 | `server/debrief.py`, `db/migrations/0002_debrief.sql`, `docs/debrief/default.yaml`, `scripts/e2e_smoke.py`, `tests/test_e2e.py` | ✅ all | - -No referenced file is missing. `server/asr/__init__.py` exists but is empty (an organizational placeholder — ASR uses Pipecat's Deepgram service directly in `pipeline.py`; no adapter needed for v0.1 since Deepgram is the only ASR). Acceptable. - -### 1.2 Imports resolve (no dangling references) - -Ran `python3 -c "import ..."` for every server/db module + the public API: - -``` -ALL SERVER/DB IMPORTS OK -PUBLIC EXPORTS OK -PIPELINE+MAIN IMPORT OK -pipecat 1.6.0 DEPS OK (pydantic, yaml, aiosqlite, httpx, websockets, loguru, fastapi) -``` - -Public exports verified present in their declared `__all__`: -- `server.services` → `TTSProvider, LLMProvider, Guardrail, get_tts, get_llm, get_guardrail` ✅ -- `server.scenarios` → `Scenario, load, load_all, ...` ✅ -- `db` → `PraxisStore, apply_migrations, HARDCODED_LEARNER_ID, ...` ✅ - -### 1.3 No stub implementations or TODO placeholders left behind - -Grep for `TODO|FIXME|XXX|HACK|NotImplemented|NotImplementedError` → **0 matches** in `.py` files (no `NotImplementedError` stubs; no TODO/FIXME markers). - -`pass` statements found: 9 — all legitimate (bare `except: pass` / `except ImportError: pass` in probe graceful-degradation paths and one no-op branch in `session_recorder.py:70` which is an intentional placeholder for future real audio-minute metering, documented in a comment). No empty-function-body stubs. - -### 1.4 Declared exports exist - -Verified each `__all__` entry resolves to a real symbol in its module. No dangling exports. - -### 1.5 Client typecheck + build - -``` -npm run typecheck → tsc -b --noEmit → clean (exit 0, no output) -npm run build → vite build → ✓ built in 636ms (152 modules, dist/ produced) -``` - -**PASS.** (One vite chunk-size warning >500kB — a cosmetic bundling advisory, not an error; acceptable for a v0.1 single-page client.) - -### 1.6 Python syntax check - -`python3 -m py_compile` on all 20 key server/db/script modules → **PY_COMPILE OK** (no syntax errors). - -> **Note on Pipecat LSP static-type noise:** `pipeline.py` / `__main__.py` / `e2e_smoke.py` show Pyright/LSP errors (dataclass-settings API: `No parameter named "api_key"`/`"allow_interruptions"`; `LLMContextAggregator` "abstract"; `_FakeLLM` not assignable to `LLMProvider`). These are **static-type-only** — they stem from Pipecat's dataclass-`Settings` pattern (fields valid at runtime, not visible to the static analyzer) and test fakes that structurally satisfy the ABC but aren't registered as subclasses. **Runtime imports, the e2e smoke test, and all 73 tests pass despite the static warnings.** This matches the documented EXECUTE state. Flagged as P2 (maintainability) — see Quality findings. - -**Layer 1 verdict: PASS.** +**Behavioral result: PASS** (2 P0 fixed: pyproject.toml missing fastapi/uvicorn; docker-compose.yml invalid key) --- -## Layer 2 — Behavioral ✅ PASS (with 2 documented key-pending gaps) +## 3. Security Verification -### 2.1 Test suite +| Item | Status | Notes | +|------|--------|-------| +| No secrets in committed files | ✅ PASS | `grep` for hardcoded API keys/tokens in new files → none found; all use `${VAR}` expansion or empty defaults | +| .dockerignore excludes `.ciagent/.env*` | ✅ PASS | `.ciagent/` directory excluded; secrets never in build context | +| .gitignore excludes `.env.secrets` | ✅ PASS | `git check-ignore .ciagent/.env.secrets` → matches | +| stage-snippet.sh bakes GITEA_TOKEN at runtime (G-101) | ✅ PASS | `sed -i "s\|\${GITEA_TOKEN}\|${GITEA_TOKEN}\|g"` substitutes the placeholder; token is NOT committed to repo, only baked into the snippet at staging time (stored in Proxmox snippet storage, not git) | +| docker-compose.yml uses env_file (not hardcoded secrets) | ✅ PASS | `env_file: /etc/praxis/server.env` (written by install-service.sh from lxc.environment); no secret values in compose file | +| install-service.sh writes env file with mode 0640 | ✅ PASS | `chmod 0640 "$ENV_FILE"` + `chown root:praxis` (root:praxis only) | +| firstboot-hook.sh GITEA_TOKEN from baked snippet (not env) | ✅ PASS | Hook uses `${GITEA_TOKEN}` which is baked by stage-snippet.sh; comment documents the G-101 fix | -``` -python3 -m pytest → 73 passed, 9 skipped (pending-keys), 0 failed, 1 warning in 9.81s -``` - -The 1 warning is a benign `DeprecationWarning: 'audioop' is deprecated` from Pipecat's `audio/utils.py` (third-party, Python 3.13 advisory — not actionable in v0.1). - -Test file inventory (12 files, 73 offline tests + 9 pending-key tests): - -| File | Tests | Covers | -|---|---|---| -| `test_scenario_schema.py` | 5 | TASK-03-01/02 — Pydantic schema + YAML loader | -| `test_scenario_runtime.py` | 7 | TASK-03-03/07 — runtime, flows spec, branch set | -| `test_classifier.py` | 11 | TASK-03-05/06 — interruptibility + branch classifier (heuristic + LLM + parser) | -| `test_guardrail.py` | 9 | TASK-03-04 — Customer Service ruleset + debrief filter + NoOp swap | -| `test_llm_adapter.py` | 6 | TASK-02-03 — Ollama adapter (models, missing-key, mocked stream, chat_full) | -| `test_tts_adapters.py` | 7 | TASK-02-02 — Cartesia/Piper (env selection, missing-key, synthesize_all, ABC) | -| `test_store.py` | 6 | TASK-04-01/02 — migrations, hardcoded learner, CRUD, progress | -| `test_cost_and_recorder.py` | 7 | TASK-04-03/04 — cost derivation + SessionRecorder lifecycle | -| `test_debrief.py` | 5 | TASK-05-01/02/03 — debrief gen, no-think, guardrail filter, TTS voice | -| `test_debrief_persistence.py` | 2 | TASK-05-05 — migration 0002 + debrief_text persisted | -| `test_latency_observer.py` | 5 | TASK-02-06 — LatencyRecord math + observer state | -| `test_e2e.py` | 3 | TASK-05-06 — full-loop smoke (DB assertions) | -| `test_pending_keys.py` (NEW) | 9 (skipped) | Exit criteria #1/#2 — live-key verifications | - -### 2.2 E2E smoke test - -``` -python3 scripts/e2e_smoke.py -→ E2E SMOKE TEST — PASSED - session_id: sess-..., branch_id: accept_resolution, outcome: success, - turns_logged: 4, cost_cents: 1, debrief_chars: 194, - max_latency_ms: 510.0, within_budget: True, budget_ms: 600.0 -``` - -The full offline loop works: scenario load → session start → 4 turns logged → heuristic branch classification → debrief generation (stub LLM) → guardrail filter → cost derivation → session/turns/progress/debrief persisted to SQLite. **PASS.** - -### 2.3 Phase 1 Exit Criteria (10 items — PLAN.md §4) - -| # | Criterion | Status | Evidence | -|---|---|---|---| -| 1 | Full session end-to-end (client → disclaimer → speak → AI responds → branch → debrief → SQLite) | **GAP (pending keys)** | Code-complete: `__main__.py` accepts WebRTC, loads scenario, logs disclaimer; `pipeline.py` wires VAD→STT→LLM→TTS; `debrief.py` + `session_recorder.py` close the loop. Cannot exercise live without DEEPGRAM/CARTESIA/OLLAMA keys. Auto-test: `tests/test_pending_keys.py::test_ollama_gemma4_cloud_returns_first_token` + `test_cartesia_tts_streams_audio` + `test_deepgram_stt_service_constructs_with_live_key`. | -| 2 | Latency measured (R1-R4 real numbers) + TTS decision | **GAP (pending keys)** | `docs/latency-report.md` exists with budget, decision matrix, G-003 no-go actions, Piper pre-staging. Probes built and degrade gracefully (`KEY_MISSING` → exit 0). Live numbers pending keys. Auto-tests: `test_r1_deepgram_first_partial_latency`, `test_r2_...`, `test_r3_...`, `test_r4_...`, `test_live_latency_report_has_real_numbers`. | -| 3 | TTS behind interface, swappable via `PRAXIS_TTS` | ✅ **PASS** | `server/services/base.py:TTSProvider` (ABC); `cartesia_tts.py` + `piper_tts.py` adapters; `registry.get_tts()` selects via env. Tests: `test_cartesia_selectable_via_env`, `test_piper_selectable_via_env`, `test_both_adapters_are_ttsprovider`. | -| 4 | LLM behind interface, both models callable | ✅ **PASS** | `LLMProvider` ABC; `OllamaCloudLLM` with `roleplay_model`/`debrief_model` properties + `no_think` flag. Tests: `test_ollama_models_from_env_defaults`, `test_ollama_is_llmprovider`. Live call pending keys (auto-test: `test_ollama_deepseek_debrief_no_think_returns_text`). | -| 5 | Guardrail pluggable + CustomerService ruleset + disclaimer + unit-tested | ✅ **PASS** | `Guardrail` ABC + `CustomerServiceGuardrail` + `NoOpGuardrail`; disclaimer text defined; 9 unit tests covering legal/financial/medical/impersonation blocks + debrief filter + NoOp swap. | -| 6 | Scenario YAML → Pydantic → Flows, `failure_mode` present | ✅ **PASS** | `schema.py` (Pydantic) + `loader.py` (`yaml.safe_load`) + `runtime.py` (`as_flow_spec`); `customer_service_refund_ca_v01.yaml` has `failure_mode: escalates_unresolved`. Tests: 5 schema tests + 7 runtime tests. | -| 7 | Interruptibility (learner cuts AI TTS, AI yields) | ✅ **PASS (structural)** | `pipeline.py` sets `allow_interruptions=True` (D-008); `interruptibility.py::pipeline_allows_interruptions` verified by 3 tests. Live manual test documented as pending in latency-report; Pipecat's built-in interrupt handling provides the runtime behavior. | -| 8 | Learner state persists (session + turns + progress + cost; single learner, no auth) | ✅ **PASS** | `db/` schema + migrations + async store; hardcoded `learner-1` "Alex" row; `SessionRecorder` wires store into pipeline. Tests: `test_store_start_log_end_session`, `test_hardcoded_learner_row_exists`, `test_session_recorder_full_lifecycle`. | -| 9 | Cost logged per session (`cost_estimated_cents` non-null + breakdown) | ✅ **PASS** | `server/cost.py::derive_cost` + `cost_rates.yaml`; `sessions.cost_estimated_cents` + `cost_breakdown_json` populated. Tests: `test_derive_cost_basic`, `test_session_recorder_full_lifecycle` (asserts `cost_estimated_cents > 0`). | -| 10 | E2E smoke test passes (full loop + DB assertions) | ✅ **PASS** | `scripts/e2e_smoke.py` + `tests/test_e2e.py` (3 tests) — passes; asserts session/turns/cost/debrief/branch persisted. | - -**Exit criteria: 8/10 PASS, 2/10 GAP (pending keys, not code defects).** - -### 2.4 REQ Coverage Traceability (15 P1 REQ-IDs) - -| REQ-ID | Covered? | Files (trace) | Test status | -|---|---|---|---| -| REQ-VOICE-01 | ✅ | `server/pipeline.py:_build_stt` (Deepgram Nova-3) | structural test + pending live test | -| REQ-VOICE-02 | ✅ | `server/services/base.py:TTSProvider`, `server/tts/cartesia_tts.py`, `server/tts/piper_tts.py` | 7 tests + pending live test | -| REQ-VOICE-03 | ✅ | `server/latency.py`, `docs/latency-report.md` | 5 tests; live number pending keys | -| REQ-VOICE-04 | ✅ | `server/pipeline.py` (`allow_interruptions=True`), `server/interruptibility.py` | 3 tests | -| REQ-SCEN-01 | ✅ | `scenarios/customer_service_refund_ca_v01.yaml`, `server/scenarios/runtime.py` | 7 runtime + 5 schema tests | -| REQ-STATE-01 | ✅ | `db/schema.sql`, `db/store.py`, `db/migrations/0001_init.sql`, `server/session_recorder.py` | 6 store + 7 recorder tests | -| REQ-LLM-01 | ✅ | `server/llm/ollama_cloud.py` (gemma4:cloud) | 6 tests + pending live test | -| REQ-LLM-02 | ✅ | `server/llm/ollama_cloud.py` (`no_think`), `server/debrief.py`, `server/scenarios/classifier.py` | 5 debrief tests + pending live test | -| REQ-DEBRIEF-01 | ✅ | `server/debrief.py`, `docs/debrief/default.yaml`, `server/session_recorder.py` | 5 debrief + 2 persistence tests | -| REQ-ORCH-01 | ✅ | `server/pipeline.py` (Pipecat + Silero VAD + interrupt) | imports + e2e smoke | -| REQ-ORCH-02 | ✅ | `server/services/base.py:Guardrail`, `server/guardrails/customer_service.py`, `server/services/registry.py` | 9 guardrail tests | -| REQ-SCEN-FMT-01 | ✅ | `server/scenarios/schema.py`, `server/scenarios/loader.py`, `server/scenarios/runtime.py` | 5 schema + 7 runtime tests | -| REQ-NFR-LAT-01 | ✅ | `server/latency.py`, `docs/latency-report.md`, `scripts/probe_*.py` | 5 tests; live measurement pending keys | -| REQ-NFR-SAFE-01 | ✅ | `server/guardrails/customer_service.py` (disclaimer + 4 block categories + debrief filter) | 9 guardrail tests | -| REQ-NFR-COST-01 | ✅ | `server/cost.py`, `scenarios/cost_rates.yaml`, `server/session_recorder.py` | 7 cost/recorder tests | - -**Coverage: 15/15 P1 REQ-IDs covered by code.** All have at least one offline test except where the requirement is inherently live-key-dependent (REQ-VOICE-03 live number, REQ-LLM-01/02 live call) — those are covered by auto-generated pending-key tests that activate when keys are provisioned. - -### 2.5 Auto-generated tests for unverifiable items - -`tests/test_pending_keys.py` (NEW — 9 tests, all skip cleanly without keys): - -| Test | Verifies | Activates when | -|---|---|---| -| `test_r1_deepgram_first_partial_latency` | R1 probe runs live | DEEPGRAM_API_KEY | -| `test_r2_cartesia_first_audio_latency` | R2 probe runs live | CARTESIA_API_KEY | -| `test_r3_ollama_ttft_both_models` | R3 probe (R6 resolution) | OLLAMA_API_KEY | -| `test_r4_integrated_e2e_latency_within_or_documented` | R4 integrated e2e | OLLAMA + CARTESIA | -| `test_ollama_gemma4_cloud_returns_first_token` | REQ-LLM-01 live | OLLAMA_API_KEY | -| `test_ollama_deepseek_debrief_no_think_returns_text` | REQ-LLM-02 live no-think | OLLAMA_API_KEY | -| `test_cartesia_tts_streams_audio` | REQ-VOICE-02 live | CARTESIA_API_KEY | -| `test_deepgram_stt_service_constructs_with_live_key` | REQ-VOICE-01 live | DEEPGRAM_API_KEY | -| `test_live_latency_report_has_real_numbers` | Exit criterion #2 | OLLAMA + CARTESIA | - -All 9 skip with a clear reason when keys are absent; the default fast suite stays green (73 passed, 9 skipped). - -**Layer 2 verdict: PASS (8/10 exit criteria verified; 2/10 documented key-pending gaps with auto-tests ready).** +**Security result: PASS** (no issues) --- -## Layer 3 — Security (STRIDE) ✅ ACCEPT (all dispositions low/medium for v0.1 pilot) +## 4. Quality Verification -Threat model context: v0.1 is a **single-learner tech-validation harness** (G-008), local SQLite, no auth (D-007), no PII beyond a hardcoded display name, no network exposure beyond the pilot host. STRIDE findings are dispositioned per the auto-policy (low=accept, medium=mitigate, high=escalate). +| Item | Status | Notes | +|------|--------|-------| +| Shell scripts follow coreci patterns (set -eu, pve_env, SCRIPT_DIR) | ✅ PASS | All scripts: `set -eu`, `SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"`, `pve_env` validation, `. api.sh` sourcing | +| No remaining "coreci" references in praxis scripts (except origin comments) | ✅ PASS (after P0 fix) | timing.sh was using `coreci_deploy_timing_*` metric names — **fixed to `praxis_deploy_timing_*`**; remaining "coreci" refs are: origin comments ("Adapted from coreci"), Gitea org name (`GITEA_ORG="coreci"` — the repo owner), D-026 secret path (`~/coreci/.ciagent/.env.secrets`) — all correct | +| Bats tests cover all scripts (10 files, not 9 — G-106) | ⚠️ NOTE | 10 .bats files exist (121 tests), but **3 PLAN-specified test files are missing**: `timing.bats` (TASK-09-07), `idempotency.bats` (TASK-09-08), `docker-build.bats` (TASK-09-10). Idempotency IS covered in lxc-deploy.bats (16 tests), timing is exercised via lxc-deploy.bats, and docker-build is verified manually here. Coverage is adequate but doesn't match the PLAN's file list. | +| Health-check timeout is 600s (G-104, not 300s or 180s) | ✅ PASS | `health-check.sh:29` — `timeout_s="${PRAXIS_HEALTH_TIMEOUT:-600}"`; praxis.service `TimeoutStartSec=600`; .env.example documents `PRAXIS_HEALTH_TIMEOUT=600` | +| Dockerfile copies pyproject.toml before source (G-105) | ✅ PASS | `COPY pyproject.toml README.md ./` → `RUN pip install .` → `COPY server/ scenarios/ db/` (correct ordering) | -| Category | Finding | Severity | Disposition | Evidence | -|---|---|---|---|---| -| **Spoofing** | No auth in v0.1 (D-007 — single hardcoded learner "Alex"). Anyone who can reach the Pipecat server's `/pipecat/webrtc` endpoint could start a session. | Low (pilot) | **Accept** | D-007 explicitly defers auth. Single-learner harness; the server binds `0.0.0.0:8789` but is intended for a single pilot host. CORS is `allow_origins=["*"]` (dev) — acceptable for v0.1, **flag for tightening before any multi-learner milestone** (P1). | -| **Tampering** | SQLite local file (`praxis.db`) — no integrity protection. A local user can `sqlite3 praxis.db` and edit session/outcome/cost rows. | Low (pilot) | **Accept** | D-007: local pilot, single-learner. Trust model assumes the pilot host is trusted. No tamper-evidence needed for tech-validation. Documented in `db/schema.sql` header. | -| **Repudiation** | Sessions are logged with auto-generated ids (`sess-`) and timestamps; no signed audit trail. A learner could dispute "I never did that session." | N/A (pilot) | **Accept** | Single hardcoded learner, no auth → no multi-party repudiation surface. Sessions are for learner self-review, not compliance. | -| **Info Disclosure** | (a) `.ciagent/.env.secrets` is `0600` perms + gitignored — ✅ verified. (b) `.env`, `.env.secrets`, `.env.*` all in `.gitignore` — ✅ verified. (c) `git ls-files` confirms **no secret/key/db files tracked**. (d) Grep for hardcoded API keys (`sk-...`, `*_API_KEY="..."` assignments) → **0 matches** in non-example files. (e) `db/*.db` gitignored — no learner data leaked. | Low | **Accept** | Secrets handling is correct. The local `.ciagent/.env.secrets` contains a `DEEPGRAM_API_KEY` value (40 chars) but it is **not committed** (gitignored, 0600) — this is the intended dev-secret pattern. No info-disclosure vulnerability found. | -| **Denial of Service** | No rate limiting on the FastAPI/Pipecat server; no connection cap; a client can open many WebRTC sessions. `asyncio.create_task(runner.run(task))` fires-and-forgets per request. | Low-Medium (pilot) | **Accept (v0.1) / Flag (P1)** | D-007/D-012: single-learner pilot, no adversarial threat model. Acceptable for v0.1. **Flag for P1 post-hoc review**: before any multi-learner exposure, add connection limits + task lifecycle management (the current `create_task` without tracking could leak tasks on disconnect). | -| **Elevation of Privilege** | No auth → no privilege ladder → no escalation surface. | N/A | **Accept** | N/A for v0.1. | - -### Injection-vector review (security persona) - -| Vector | Status | Evidence | -|---|---|---| -| **YAML scenario loading** | ✅ Safe | `server/scenarios/loader.py` uses `yaml.safe_load` (not `yaml.load`) — no arbitrary Python object construction. Scenario files are repo-authored (D-007: no user-uploaded scenarios in v0.1). | -| **LLM prompt construction** | ✅ Contained | `classifier.py::_build_user_prompt` and `debrief.py::_render` interpolate learner text into the prompt via string replacement. A malicious learner ASR transcript could inject prompt text, but: (a) the LLM is role-playing a customer (no tool calls / no DB writes from LLM output), (b) the guardrail output filter runs on the response, (c) the branch classifier output is JSON-parsed leniently with fallback. Prompt injection impact is bounded to a misclassified branch or a weird debrief — not a security boundary for v0.1. **Accept.** | -| **SQL injection** | ✅ Safe | `db/store.py` uses parameterized queries exclusively (`?` placeholders) — no string-interpolated SQL. | -| **Path traversal (scenario id)** | Low | `loader.load(scenario_id)` builds `base / f"{scenario_id}.yaml"` — a `scenario_id` containing `../` could escape `scenarios/`. In v0.1 the id comes from the env var `PRAXIS_SCENARIO` (operator-controlled), not user input. **Accept for v0.1; flag for P1** if scenario ids ever become user-selectable. | - -**Layer 3 verdict: ACCEPT.** No high-severity STRIDE findings. 3 P1 flags for future hardening (CORS tightening, DoS/connection limits, path-traversal guard) — all appropriate for a post-pilot milestone, not v0.1 blockers. +**Quality result: PASS with notes** (1 P0 fixed: timing.sh metric names; 1 note: missing 3 bats files but coverage is adequate via other files) --- -## Layer 4 — Quality (multi-persona review) +## 5. Must-Have Verification (MH-01..MH-28) -### P0 fixes applied (2) +| MH-ID | Requirement | Status | Evidence | +|-------|-------------|--------|----------| +| MH-01 | `docker build -t praxis:test .` succeeds | ✅ PASS | Build completes (~105s) after fastapi/uvicorn added to pyproject.toml | +| MH-02 | `docker compose config` parses without error | ✅ PASS (fixed) | Was failing due to invalid `restart_policy` key; fixed → exits 0 | +| MH-03 | `docker run --rm praxis:test python -c "import server, pipecat"` | ✅ PASS (fixed) | Prints `ok` after fastapi added to pyproject.toml | +| MH-04 | Image contains `client/dist/index.html` | ✅ PASS | Verified via `docker run --rm praxis:verify ls /app/client/dist/index.html` | +| MH-05 | `.dockerignore` excludes node_modules, .git, client/dist, .ciagent/.env* | ✅ PASS | All patterns present in .dockerignore | +| MH-06 | SQLite persists across `docker compose restart` via named volume | ✅ PASS (design) | `praxis-data` volume mounted at `/app/data`; `PRAXIS_DB_PATH=/app/data/praxis.db` set in compose + env; `db/store.py` + `db/migrate.py` read PRAXIS_DB_PATH (G-102 fix). Live restart test not run (no Docker daemon persistence in verify env), but the wiring is correct. | +| MH-07 | `GET /health` returns JSON `{"status":"ok",...}` | ✅ PASS | Verified via `curl http://localhost:18789/health` → `{"status":"ok","version":"0.1.0","keys":{...},"tts":"cartesia"}` | +| MH-08 | `GET /` returns index.html when client/dist exists | ✅ PASS | `curl http://localhost:18789/` → `` | +| MH-09 | `GET /nonexistent` returns 404 | ✅ PASS | `curl -s -o /dev/null -w "%{http_code}"` → `404` | +| MH-10 | `pytest tests/` passes (no regression) | ✅ PASS | 77 passed, 9 skipped (live-key tests) | +| MH-11 | All scripts pass `sh -n` and `shellcheck` | ✅ PASS | 13/13 syntax-valid; shellcheck clean (only SC1090 non-constant-source warning on e2e-deploy.sh, expected) | +| MH-12 | api.sh, ct-exists.sh, lxc-start.sh byte-identical to coreci | ⚠️ PARTIAL | api.sh: byte-identical ✓; lxc-start.sh: differs only in header comment (line 2 "CoreCI"→"Praxis") — functionally identical; ct-exists.sh: differs in comments + path reference (coreci has it in `proxy/ct-exists.sh`, praxis at top level) — functionally identical. Header-comment-only diffs are acceptable adaptations. | +| MH-13 | lxc-clone.sh uses hostname=praxis, rootfs=:16, memory=4096 | ✅ PASS | `hostname=${PRAXIS_HOSTNAME:-praxis}`, `rootfs=${storage}:16`, `memory=${PROXMOX_MEMORY_MB:-4096}`, `features=nesting=1` | +| MH-14 | lxc-config.sh emits praxis-firstboot.sh hookscript + praxis env vars | ✅ PASS (fixed) | `hookscript_volid="${storage}:snippets/praxis-firstboot.sh"`; emits all praxis lxc.environment vars (PRAXIS_HOST, PRAXIS_PORT, PRAXIS_DB_PATH, PRAXIS_SCENARIOS_DIR, GITEA_TOKEN, DEEPGRAM/CARTESIA/OLLAMA keys + config). **Fixed**: added missing PRAXIS_HOST + PRAXIS_SCENARIOS_DIR; aligned defaults with .env.example + docker-compose.yml | +| MH-15 | health-check.sh polls /health:8789 with 600s timeout | ✅ PASS | `health_url="http://${ip}:${http_port}/health"`; `http_port=${PRAXIS_PORT:-8789}`; `timeout_s=${PRAXIS_HEALTH_TIMEOUT:-600}` (G-104 fix applied) | +| MH-16 | firstboot-hook.sh installs Docker + clones repo + runs install-service.sh | ✅ PASS (fixed) | Step 1: apt install docker.io docker-compose-v2 git curl; Step 2: git clone; Step 3: sh scripts/install-service.sh. **Fixed**: idempotency check was referencing non-existent `/usr/local/bin/praxis-deploy` (coreci artifact) → changed to `[ -d /opt/praxis/.git ] && systemctl is-active --quiet praxis` | +| MH-17 | lxc-deploy.sh orchestrates clone→config→start→health with rollback trap + idempotency | ✅ PASS | EXIT trap calls rollback.sh on failure; idempotency check (ct_exists + ct_running + health); --recreate/--reconfigure flags; timing wrappers | +| MH-18 | lxc-deploy.sh has NO proxy/PROXY_VMID/BACKEND_DOMAIN steps | ✅ PASS | 0 matches for PROXY_VMID/BACKEND_DOMAIN/backend-add/smoke-test | +| MH-19 | praxis.service: ExecStart=docker compose up + ExecStartPre=docker compose build + Restart=on-failure + TimeoutStartSec | ✅ PASS (fixed) | ExecStartPre=/usr/bin/docker compose build; ExecStart=/usr/bin/docker compose up; Restart=on-failure; TimeoutStartSec=600 (G-104). **Fixed**: User=root → User=praxis (MH-21 alignment). Unit is written inline via heredoc in install-service.sh (not a separate file, but functionally equivalent). | +| MH-20 | praxis.service has NO Docker-incompatible hardening | ✅ PASS | No ProtectSystem/PrivateDevices/RestrictNamespaces/NoNewPrivileges/MemoryDenyWriteExecute; comment documents the decision | +| MH-21 | install-service.sh creates praxis user in docker group + writes env file + installs unit | ✅ PASS (fixed) | useradd + usermod -aG docker; writes /etc/praxis/server.env (0640, root:praxis); installs systemd unit; **Fixed**: User=praxis in unit (was User=root) | +| MH-22 | config.json secrets.scopes has release/proxmox/voice with correct env vars | ✅ PASS (fixed) | All 3 scopes present; **Fixed**: removed PROXMOX_LXC_VMID from proxmox scope (D-037 — it's `auto`, not a secret) | +| MH-23 | lxc-deploy.sh sources ~/coreci/.ciagent/.env.secrets + praxis .ciagent/.env.secrets | ✅ PASS (fixed) | **Fixed**: added secret-sourcing block to lxc-deploy.sh (was only in e2e-deploy.sh wrapper). Sources both files with graceful warnings if absent; pve_env validates after. | +| MH-24 | .env.example documents all PROXMOX_* + deploy vars (no actual secrets) | ✅ PASS | Deployment section documents PROXMOX_API_URL/TOKEN/NODE/STORAGE/TEMPLATE_VOLID/LXC_VMID/TLS_SKIP_VERIFY/MEMORY_MB + PRAXIS_HEALTH_URL/PORT/TIMEOUT + PRAXIS_CLIENT_DIST; all commented out or empty; D-026 source-from-coreci documented | +| MH-25 | git check-ignore: .ciagent/.env.secrets matches; .env.example does not | ✅ PASS | Verified both | +| MH-26 | `make test-proxmox-scripts` passes — 10 bats files | ⚠️ PARTIAL | 121 bats tests pass via `bats scripts/proxmox/test/`, but **no Makefile exists** (TASK-09-11 not implemented). `make test-proxmox-scripts` target unavailable. Tests pass when run directly via bats. | +| MH-27 | e2e-deploy.bats passes against live Proxmox (or skips) | ✅ PASS | e2e-deploy.bats has `PRAXIS_E2E_LIVE=1` skip guard — skips by default (no live cluster in CI); 7 e2e tests present | +| MH-28 | E2E deploy completes in < 5 min | ⏭️ DEFERRED | Requires live Proxmox cluster + secrets; not runnable in verify env. Wiring (timing wrappers, 600s timeout) is correct. | -Both are risk-free cosmetic cleanups with no logic/behavior change. Verified by re-running the full suite (73 passed, 9 skipped, 0 failed) + e2e smoke after each fix. - -| # | File:line | Issue | Fix | Verification | -|---|---|---|---|---| -| P0-1 | `server/guardrails/customer_service.py:119,123` | Misspelled constant `_DEBRIFF_LEGAL_REDIRECT` (two F's; should be `_DEBRIEF_`). Worked at runtime only because the method references the constant by the same misspelled name and Python resolves globals at call time — but the typo is a latent trap: any future refactor that renames one occurrence would silently break the debrief filter, causing legal-action recommendations to pass unfiltered (a safety regression). | Renamed both occurrences to `_DEBRIEF_LEGAL_REDIRECT`. | `test_debrief_guardrail_blocks_legal_action` passes; manual end-to-end check confirms legal-action text still replaced by the redirect. | -| P0-2 | `server/debrief.py:31` | Dead code: `rel = template_id.replace("/", ".") ...` computed but never used (the actual path resolution uses `template_id.split('/')[-1]`). Confusing for maintainers and flagged by linters. | Removed the dead line. | `test_debrief_*` (5 tests) pass; template loading verified. | - -### P1+ findings flagged for post-hoc review (6) - -| # | Severity | Persona | File:line | Finding | Recommendation | -|---|---|---|---|---|---| -| Q-1 | P1 | Maintainability | `server/pipeline.py`, `server/__main__.py`, `scripts/e2e_smoke.py` | Pipecat LSP static-type noise (~12 Pyright errors: dataclass-`Settings` fields, `LLMContextAggregator` abstractness, `_FakeLLM` not subclassing `LLMProvider`). Runtime is fine; static analysis is noisy. | Add `# type: ignore[...]` annotations with reasons, or wrap Pipecat service construction in typed helper functions. Register test fakes via `LLMProvider.register` or duck-type with `Protocol`. Non-blocking. | -| Q-2 | P1 | Correctness | `server/latency.py:106-112` | `TextFrame` is treated as an LLM-first-token proxy, but `TextFrame` is generic — it can carry non-LLM text (e.g. the opening-line TTS input), which could misattribute the first-token timestamp. The `LLMFullResponseEndFrame` branch (L99) is a better proxy but also imperfect. | For v0.1 accept (latency is logged, not enforced); for Phase 2 use Pipecat's `LLMTokenUsageFrame` / metrics service for accurate TTFT. | -| Q-3 | P1 | Adversarial/Security | `server/scenarios/loader.py:34` | `load(scenario_id)` builds `base / f"{scenario_id}.yaml"` without sanitizing `../` — path traversal possible if `scenario_id` is ever user-controlled. Currently env-var-controlled (operator), so low risk. | Add a guard: reject `scenario_id` containing path separators or `..`, or resolve + verify the result stays within `base`. | -| Q-4 | P1 | Security/DoS | `server/__main__.py:96-98` | `asyncio.create_task(runner.run(task))` is fire-and-forget — no tracking of running tasks, no cap on concurrent sessions, no cancellation on client disconnect. Acceptable for single-learner pilot but would leak resources at scale. | Track tasks in a set; cancel on disconnect; cap concurrency. Defer to multi-learner milestone. | -| Q-5 | P1 | Security | `server/__main__.py:55` | CORS `allow_origins=["*"]` — dev setting. Acceptable for v0.1 single-origin pilot but must be tightened before any non-local exposure. | Make CORS origin env-configurable (`PRAXIS_CORS_ORIGINS`); default to the client dev origin. | -| Q-6 | P2 | Testing | `tests/test_e2e.py:16-37` | The 3 e2e test functions each call `asyncio.run(run_e2e(...))` independently — the full loop runs 3× per test session (wasteful, ~3× the DB writes). Also `test_e2e_debrief_non_empty` re-runs the whole loop just to assert `debrief_chars > 50`. | Refactor to a session-scoped fixture that runs `run_e2e` once and shares the result dict across the 3 assertions. Non-blocking. | - -### Per-persona summary - -**Correctness:** Logic is sound across the hot path. `classify_branch_sync_heuristic` correctly scores branches by signal-keyword overlap and tie-breaks to the first branch (deterministic). `derive_cost` arithmetic verified (`test_derive_cost_piper_zero_tts` confirms Piper $0 path). `LatencyRecord.e2e_asr_to_tts_ms` math correct (550ms in test). Branch classifier parser is lenient (handles code fences, malformed JSON, empty input) with safe fallbacks. **No correctness P0s.** - -**Testing:** 73 tests are meaningful — they cover schema validation, adapter graceful degradation, guardrail block categories, cost math, store CRUD, recorder lifecycle, debrief generation/filter, latency math, and the full e2e loop with DB assertions. Coverage is broad; gaps are the live-key paths (now covered by `test_pending_keys.py` skips) and client-side (no React component tests — v0.1 relies on e2e smoke per `package.json` "test" script). The `_FakeLLM`/`_StubDebriefLLM` fakes structurally satisfy the `LLMProvider` contract. **No testing P0s.** One P2 (test redundancy, Q-6). - -**Security:** See Layer 3. No hardcoded keys, safe YAML loading, parameterized SQL, bounded prompt-injection impact. 3 future-hardening P1s (Q-3/4/5). **No security P0s.** - -**Performance:** No O(n²) in the voice-loop hot path. `LatencyObserver.process_frame` is O(1) per frame (passes through + records a timestamp). `lru_cache` on registry getters avoids repeated adapter construction. `SessionRecorder.log_turn` is O(1) per turn. The classifier runs once at session end (D-P1-05 — offline from the latency path). **No performance P0s.** One observation: `LLMContextAggregator` + Pipecat's context object grow with conversation length (unbounded turn history) — acceptable for v0.1 short sessions; flag for Phase 2 if sessions exceed ~50 turns. - -**Maintainability:** Interfaces (`TTSProvider`/`LLMProvider`/`Guardrail`) are clean ABCs with typed dataclasses (`TTSResult`, `LLMStreamChunk`, `GuardrailVerdict`, `GuardrailContext`). The registry centralizes env-based selection. Adapters are thin and consistently degrade gracefully on missing keys/models. Naming is clear. The one maintainability defect was the `_DEBRIFF` typo (fixed as P0-1). Pipecat static-type noise (Q-1) is the remaining friction. **No maintainability P0s after fixes.** - -**Adversarial:** What if the LLM returns malicious content? → Guardrail output filter (`_DEBRIEF_LEGAL_ACTION_RE` + 4 category regexes) blocks legal/financial/medical/impersonation; the debrief path replaces blocked content with a coaching redirect. What if the YAML scenario is malformed? → Pydantic `ValidationError` raised at load (typed, tested). What if the classifier returns garbage? → `_parse_branch` falls back to scanning for a known branch id, then to the first branch — never crashes. What if a probe key is missing? → `KEY_MISSING` banner, exit 0. **No adversarial P0s.** The guardrail regexes are heuristic (not LLM-based) and could be evaded by paraphrase — acceptable for v0.1 Customer Service (low-risk domain per D-019); the pluggable interface allows a stronger ruleset for high-risk domains later. - -**Layer 4 verdict: PASS.** 2 P0 fixes applied (cosmetic, verified). 6 P1+ flags for post-hoc review (none blocking). +**Must-have result: 25/28 PASS, 2 PARTIAL (MH-12 comment-only diffs, MH-26 no Makefile), 1 DEFERRED (MH-28 live E2E)** --- -## GRILL binding decisions — status check +## 6. REQ-ID Coverage -| ID | Decision | Honored? | Evidence | -|---|---|---|---| -| G-001 | v0.1 = tech-validation, not thesis validation | ✅ | `README.md` L3: "tech-validation harness (per G-008)"; `docs/latency-report.md` frames numbers as pilot-config. | -| G-002 | Branch is post-hoc classification, not runtime fork | ✅ | `server/scenarios/runtime.py:as_flow_spec` → `transitions: []` with comment "v0.1: no in-flight transitions (G-002)"; classifier runs at session end. | -| G-003 | Go/no-go gate has explicit no-go actions | ✅ | `docs/latency-report.md` §"SLICE-01 go/no-go gate" lists actions (a)/(b)/(c). | -| G-004 | Per-slice estimates at EXECUTE | ⚠️ Partial | Commit messages carry slice/task ids; no explicit effort estimates in PLAN.md, but the wave structure + 26 tasks provide sizing. Acceptable for autonomous project. | -| G-005 | v0.1 logged costs not representative of at-scale | ✅ | `server/cost.py` header + `scenarios/cost_rates.yaml` header both cite G-005. | -| G-006 | No real-learner recruitment; tech harness | ✅ | Hardcoded `learner-1` "Alex"; no recruitment code/artifacts. | -| G-007 | Stop-trigger defined (ties to G-003) | ✅ | latency-report §go/no-go gate documents the stop trigger. | -| G-008 | "Pilot" = tech pilot, not learner pilot | ✅ | README + docs consistent. | +| REQ-ID | Requirement | Status | Evidence | +|--------|-------------|--------|----------| +| REQ-DEPLOY-01 | Multi-stage Dockerfile | ✅ COVERED | Dockerfile: node:22-slim → python:3.12-slim; client/dist built in Stage 1, served via StaticFiles in Stage 2 | +| REQ-DEPLOY-02 | docker-compose.yml + SQLite volume | ✅ COVERED | docker-compose.yml: port 8789, praxis-data volume, env_file, restart: unless-stopped | +| REQ-DEPLOY-03 | Port api.sh verbatim | ✅ COVERED | api.sh byte-identical to coreci (diff confirmed) | +| REQ-DEPLOY-04 | Adapt lxc-clone.sh | ✅ COVERED | hostname=praxis, rootfs=:16, memory=4096, features=nesting=1 | +| REQ-DEPLOY-05 | Adapt lxc-config.sh | ✅ COVERED | hookscript=praxis-firstboot.sh, all praxis lxc.environment vars (GITEA_TOKEN, voice keys, PRAXIS_*, OLLAMA_*, DEEPGRAM_*, CARTESIA_*) | +| REQ-DEPLOY-06 | Adapt firstboot-hook.sh | ✅ COVERED | Docker install + git clone + install-service.sh; idempotency check (fixed); G-101 baked token | +| REQ-DEPLOY-07 | Adapt health-check.sh | ✅ COVERED | /health:8789, 600s timeout (G-104), PRAXIS_HEALTH_URL override, bridge-IP resolution | +| REQ-DEPLOY-08 | Port lxc-start/rollback/stage-snippet/timing | ✅ COVERED | lxc-start.sh (comment-only diff), rollback.sh (proxy block removed), stage-snippet.sh (G-101 bake fix), timing.sh (metric names fixed to praxis_*) | +| REQ-DEPLOY-09 | lxc-deploy.sh orchestrator | ✅ COVERED | clone→config→start→health; rollback trap; idempotency (--recreate/--reconfigure); VMID=auto; secret sourcing (fixed) | +| REQ-DEPLOY-10 | install-service.sh | ✅ COVERED | Creates praxis user + docker group; writes /etc/praxis/server.env (0640); installs systemd unit; starts service | +| REQ-DEPLOY-11 | praxis.service systemd unit | ✅ COVERED | ExecStart=docker compose up, ExecStartPre=docker compose build, Restart=on-failure, TimeoutStartSec=600, Requires=docker.service, no Docker-incompatible hardening. Written inline in install-service.sh (not a separate file — functionally equivalent) | +| REQ-DEPLOY-12 | Secret wiring | ✅ COVERED | config.json scopes (release/proxmox/voice); lxc-deploy.sh sources ~/coreci/.ciagent/.env.secrets + praxis .ciagent/.env.secrets (fixed); PROXMOX_LXC_VMID removed from scope (D-037) | +| REQ-DEPLOY-13 | FastAPI StaticFiles mount | ✅ COVERED | server/__main__.py mounts client/dist at "/" after API routes; PRAXIS_CLIENT_DIST env override; graceful degradation if dist absent | +| REQ-DEPLOY-14 | .env.example with deployment vars | ✅ COVERED | Proxmox LXC deployment section with all PROXMOX_* + PRAXIS_HEALTH_* + PRAXIS_CLIENT_DIST; D-026 documented; no actual secrets | +| REQ-DEPLOY-15 | E2E deploy verification | ✅ COVERED | 10 bats files (121 tests) + e2e-deploy.sh + e2e-deploy.bats (with skip guard); missing timing.bats/idempotency.bats/docker-build.bats but coverage adequate | +| REQ-DEPLOY-16 | .dockerignore | ✅ COVERED | Excludes node_modules, .git, client/dist, .ciagent/, .env*, *.db, *.onnx, scripts/, etc. | +| REQ-NFR-DEPLOY-01 | Deploy idempotency | ✅ COVERED | lxc-deploy.sh: ct_exists + ct_running + health-check (30s) → skip; --reconfigure → re-PUT config + restart; --recreate → rollback + redeploy; no flag + unhealthy → error exit 1 | +| REQ-NFR-DEPLOY-02 | Deploy rollback on failure | ✅ COVERED | EXIT trap calls rollback.sh on any stage failure (clone/config/start/health); skip_rollback flag for --reconfigure + no-flag-unhealthy cases | +| REQ-NFR-DEPLOY-03 | First-boot < 5 min | ⏭️ DEFERRED | Wiring correct (600s timeout, timing wrappers); live measurement requires cluster access | +| REQ-NFR-DEPLOY-04 | Secrets never committed | ✅ COVERED | .gitignore covers .env.secrets + .env.*; .dockerignore excludes .ciagent/; secrets injected at runtime via lxc.environment + baked snippet; no secret values in any committed file | + +**Coverage: 18/20 COVERED, 2 DEFERRED (REQ-NFR-DEPLOY-03 live measurement, REQ-DEPLOY-15 partial test-file list)** --- -## Summary +## 7. P0 Issues (Critical — FIXED) -| Layer | Verdict | Detail | -|---|---|---| -| 1 — Structural | ✅ PASS | All files present; imports resolve; no stubs/TODOs; exports valid; client typecheck+build clean; py_compile clean. | -| 2 — Behavioral | ✅ PASS (2 documented gaps) | 73 tests pass; e2e smoke passes; 8/10 exit criteria verified; 15/15 REQs covered; 9 auto-tests ready for pending keys. | -| 3 — Security (STRIDE) | ✅ ACCEPT | No high-severity findings; secrets handled correctly (0600 + gitignored, no hardcoded keys, safe YAML, parameterized SQL); 3 P1 future-hardening flags. | -| 4 — Quality | ✅ PASS | 2 P0 cosmetic fixes applied + verified; 6 P1+ flagged; no logic/security/performance P0s. | +### P0-01: docker-compose.yml invalid `restart_policy` key (MH-02, REQ-DEPLOY-02) +- **Symptom:** `docker compose config` failed with `services.praxis additional properties 'restart_policy' not allowed` +- **Root cause:** `restart_policy` is only valid for `docker stack deploy` (Swarm), not `docker compose`. A duplicate `restart: on-failure` was already present on line 9. +- **Fix:** Removed the `restart_policy` block; changed `restart: on-failure` → `restart: unless-stopped` (per PLAN spec); changed `env_file` to `required: false` syntax so `docker compose config` validates without the file present (install-service.sh always creates it before `up` in production). +- **Status:** ✅ FIXED -**Overall: PASSED (with documented gaps).** The two key-pending exit criteria are environment gaps (no voice-service keys provisioned), not code defects — `tests/test_pending_keys.py` will verify them automatically when keys are present. The codebase is ready for SHIP subject to the orchestrator's decision on the key-pending items. +### P0-02: pyproject.toml missing `fastapi` + `uvicorn` dependencies (MH-01, MH-03, MH-07, REQ-DEPLOY-01, REQ-DEPLOY-13) +- **Symptom:** `docker run praxis:verify` failed with `ModuleNotFoundError: No module named 'fastapi'`; server couldn't start. +- **Root cause:** `server/__main__.py` imports `fastapi` and `uvicorn`, but neither was declared in `pyproject.toml` `[project.dependencies]`. They were installed in the dev environment (v0.1) but not declared — the Dockerfile exposed the gap because the image only installs `pip install .` deps. +- **Fix:** Added `"fastapi>=0.110"` and `"uvicorn>=0.30"` to `pyproject.toml` dependencies. Rebuilt image → server starts, `/health` and `/` both work. +- **Status:** ✅ FIXED + +### P0-03: timing.sh still used `coreci_deploy_timing_*` metric names (REQ-DEPLOY-08, TASK-03-07) +- **Symptom:** timing.sh emitted `{"event":"deploy_timing",...}` and Prometheus metric `coreci_deploy_timing_seconds` — not the praxis-prefixed names required by TASK-03-07. +- **Root cause:** timing.sh was copied verbatim from coreci with a note saying "rename in a follow-up if desired" — but TASK-03-07 requires the rename as part of the deliverable. +- **Fix:** Changed event → `praxis_deploy_timing`, metric → `praxis_deploy_timing_seconds`, textfile path → `praxis_deploy_timing_.prom`. Verified via sourcing + textfile collector test. +- **Status:** ✅ FIXED + +### P0-04: firstboot-hook.sh idempotency check references non-existent binary (REQ-DEPLOY-06, REQ-NFR-DEPLOY-01) +- **Symptom:** The idempotency check `[ -x /usr/local/bin/praxis-deploy ] && systemctl is-active --quiet praxis` would NEVER short-circuit in production because praxis never creates `/usr/local/bin/praxis-deploy` (that's a coreci Go binary path). Every CT restart that triggers the post-start hook would re-run the full install (apt install docker, git clone, install-service). +- **Root cause:** The check was copied from coreci's firstboot-hook (which installs a binary to `/usr/local/bin/`) without adapting for praxis's docker-compose-based deployment. +- **Fix:** Changed check to `[ -d /opt/praxis/.git ] && systemctl is-active --quiet praxis` — verifies the repo is cloned AND the service is active. +- **Note:** The bats test for this passed before the fix because the mock `pct` returns exit 0 regardless of the actual command body — the test validates the hook's behavior given a successful idempotency probe, not the probe's actual logic. This is a test-design limitation (mocking `pct exec` at the process level can't validate the `sh -c` body). +- **Status:** ✅ FIXED --- -*End of Phase 1 verification report. VERIFY only — SHIP is the orchestrator's next step.* \ No newline at end of file +## 8. P1+ Issues (Non-critical — flagged for post-hoc review) + +### P1-01: Missing `praxis.service` standalone file (REQ-DEPLOY-11) +- The PLAN specifies `scripts/proxmox/praxis.service` as a file, but the unit is written inline via heredoc in `install-service.sh` (line 74). Functionally equivalent (the unit content is identical), but doesn't match the PLAN's file structure. No fix applied — the inline approach works and avoids a path-resolution issue (install-service.sh would need to locate the service file relative to itself). +- **Recommendation:** Accept the inline approach; update PLAN if needed. + +### P1-02: Missing 3 bats test files (MH-26, TASK-09-07/08/10) +- `timing.bats`, `idempotency.bats`, `docker-build.bats` are not present. However: + - Idempotency IS tested in `lxc-deploy.bats` (16 tests cover --recreate/--reconfigure/healthy-skip/no-flag-error) + - Timing is exercised via `lxc-deploy.bats` (timing_start/timing_end wrappers called) + - Docker-build is verified manually in this verification (MH-01/03/04 pass) +- **Recommendation:** Add the 3 missing bats files for explicit coverage in a follow-up; current coverage is adequate for ship. + +### P1-03: Missing `Makefile` (MH-26, TASK-09-11) +- No `Makefile` with `test-proxmox-scripts` target. Tests run via `bats scripts/proxmox/test/` directly. +- **Recommendation:** Add a minimal Makefile in a follow-up. + +### P1-04: Missing `e2e-smoke.sh` (TASK-10-02) +- The standalone smoke script isn't present, but `e2e-deploy.sh` covers the same checks (/health JSON, / HTML, keys field). +- **Recommendation:** Accept e2e-deploy.sh as the smoke verification; add e2e-smoke.sh if a manual post-deploy smoke tool is wanted. + +### P1-05: lxc-config.sh defaults were inconsistent with .env.example + docker-compose.yml (FIXED) +- OLLAMA_BASE_URL defaulted to `http://ollama.cloudinit.dev:11434` (vs `https://ollama.com/v1`); DEEPGRAM_LANGUAGE `en-US` (vs `en`); DEEPGRAM_REGION `us-east-1` (vs `na`); PRAXIS_TTS `deepgram` (vs `cartesia`); CARTESIA_VOICE_ID empty (vs the shared voice ID). +- **Status:** ✅ FIXED — aligned all defaults with .env.example + docker-compose.yml + install-service.sh. + +### P1-06: e2e-deploy.sh always passes `--insecure` to curl (line 80) +- `curl -sS --insecure ${PROXMOX_TLS_SKIP_VERIFY:+--insecure}` — the first `--insecure` is unconditional, so TLS verification is always skipped regardless of `PROXMOX_TLS_SKIP_VERIFY`. +- **Recommendation:** Remove the unconditional `--insecure`, keep only the conditional one. + +### P1-07: MH-12 — lxc-start.sh and ct-exists.sh have comment-only diffs from coreci +- lxc-start.sh differs in header comment line 2 ("CoreCI"→"Praxis"); ct-exists.sh differs in comments + path reference (proxy/ → top-level). Functionally identical. The PLAN said "verbatim" but header-comment adaptation is reasonable. +- **Recommendation:** Accept as verbatim-equivalent. + +### P1-08: install-service.sh `RestartSec=5` (vs PLAN's `RestartSec=10`) +- Minor deviation from PLAN spec (5s vs 10s restart delay). Not functionally significant. +- **Recommendation:** Accept. + +--- + +## 9. Summary + +| Layer | Result | +|-------|--------| +| Structural | ✅ PASS (1 P0 fixed: docker-compose.yml) | +| Behavioral | ✅ PASS (1 P0 fixed: pyproject.toml fastapi/uvicorn) | +| Security | ✅ PASS (no issues) | +| Quality | ✅ PASS (2 P0 fixed: timing.sh metrics, firstboot-hook idempotency; 1 P1 fixed: lxc-config defaults) | +| Must-haves | 25/28 PASS, 2 PARTIAL, 1 DEFERRED | +| REQ coverage | 18/20 COVERED, 2 DEFERRED (live E2E) | + +### P0 issues fixed: 4 +1. docker-compose.yml invalid `restart_policy` key → removed +2. pyproject.toml missing `fastapi` + `uvicorn` → added +3. timing.sh `coreci_*` metric names → renamed to `praxis_*` +4. firstboot-hook.sh idempotency check referencing non-existent binary → fixed to check `/opt/praxis/.git` + service active + +### P1+ issues: 8 (1 fixed, 7 noted) +- P1-05 (lxc-config defaults) fixed; P1-01/02/03/04/06/07/08 noted for follow-up. + +### Verdict: **APPROVE_WITH_NOTES** + +Phase 1 is structurally complete and behaviorally sound after the 4 P0 fixes. All 121 bats tests pass, all 77 non-live pytest tests pass, the Docker image builds and serves both the API and client, secrets are properly excluded from git/image, and the G-101/G-102/G-103/G-104/G-105/G-106 grill fixes are all applied. The remaining P1 items are non-blocking (missing Makefile, missing 3 bats files with adequate alternative coverage, comment-only coreci diffs). The 2 deferred REQ-NFR-DEPLOY-03 (live first-boot timing) and MH-28 require a live Proxmox cluster and cannot be verified in this environment — the wiring is correct and ready for live E2E. + +**Files modified by verifier (P0/P1 fixes):** +- `docker-compose.yml` — removed invalid `restart_policy`, fixed `env_file` optional syntax, `restart: unless-stopped` +- `pyproject.toml` — added `fastapi>=0.110` + `uvicorn>=0.30` +- `scripts/proxmox/timing.sh` — renamed `coreci_deploy_timing_*` → `praxis_deploy_timing_*` +- `scripts/proxmox/firstboot-hook.sh` — fixed idempotency check (`/usr/local/bin/praxis-deploy` → `/opt/praxis/.git`) +- `scripts/proxmox/lxc-deploy.sh` — added secret sourcing from ~/coreci/ + praxis .env.secrets (MH-23) +- `scripts/proxmox/lxc-config.sh` — added PRAXIS_HOST + PRAXIS_SCENARIOS_DIR; aligned defaults with .env.example +- `scripts/install-service.sh` — `User=root` → `User=praxis` (MH-21) +- `.ciagent/config.json` — removed PROXMOX_LXC_VMID from proxmox scope (D-037) +- `scripts/proxmox/test/firstboot-hook.bats` — updated comment to match fixed idempotency check \ No newline at end of file diff --git a/.ciagent/config.json b/.ciagent/config.json index bc32585..20f6e39 100644 --- a/.ciagent/config.json +++ b/.ciagent/config.json @@ -94,7 +94,7 @@ }, { "name": "proxmox", - "env_vars": ["PROXMOX_API_URL", "PROXMOX_API_TOKEN", "PROXMOX_NODE", "PROXMOX_STORAGE", "PROXMOX_TEMPLATE_VOLID", "PROXMOX_LXC_VMID", "PROXMOX_TLS_SKIP_VERIFY"] + "env_vars": ["PROXMOX_API_URL", "PROXMOX_API_TOKEN", "PROXMOX_NODE", "PROXMOX_STORAGE", "PROXMOX_TEMPLATE_VOLID", "PROXMOX_TLS_SKIP_VERIFY"] }, { "name": "voice", diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..db7ddcb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# Praxis — Docker build context exclusions +# Keep context small (no node_modules, no .git, no pre-built dist). + +# Node / client +client/node_modules/ +client/dist/ +client/.vite/ + +# Python +__pycache__/ +*.py[cod] +.eggs/ +*.egg-info/ +build/ +dist/ +.venv/ +venv/ + +# Git +.git/ +.gitignore + +# CI / planning (not needed inside the container image) +.ciagent/ + +# Secrets — NEVER in the image +.env +.env.secrets +.env.* +!.env.example + +# SQLite DBs (mounted as a volume, not baked in) +*.db +*.db-journal +*.db-wal +*.db-shm + +# Test / coverage artifacts +.pytest_cache/ +.coverage +htmlcov/ +coverage.out + +# Deploy scripts (the CT clones the repo separately for scripts; +# the image only needs server + client + db + scenarios) +scripts/ + +# Piper voice models (pre-staged locally, not in image) +*.onnx +*.pt +*.bin +piper_models/ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.env.example b/.env.example index 01c17bd..7b15122 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ -# Praxis v0.1 — Environment Configuration -# Copy to `.env` and fill in real values. See docs/latency-report.md for key-provisioning status. -# At v0.1 EXECUTE time, only GITEA_TOKEN is provisioned; voice-service keys are pending. +# Praxis — Environment Configuration (v0.2) +# Copy to `.env` and fill in real values. +# Voice-service keys are in .ciagent/.env.secrets (not this file). +# Proxmox deployment vars are sourced from ~/coreci/.ciagent/.env.secrets (D-026). # ─── Voice services ────────────────────────────────────────────────────────── # Deepgram Nova-3 ASR (D-013). Get from https://console.deepgram.com/ @@ -28,8 +29,11 @@ OLLAMA_DEBRIEF_MODEL=deepseek-v4-flash:cloud # ─── Server ─────────────────────────────────────────────────────────────────── PRAXIS_HOST=0.0.0.0 PRAXIS_PORT=8789 +# In Docker: /app/data/praxis.db (volume-mounted). Local dev: ./praxis.db PRAXIS_DB_PATH=./praxis.db PRAXIS_SCENARIOS_DIR=./scenarios +# Client dist directory (for FastAPI StaticFiles serving, D-023) +PRAXIS_CLIENT_DIST=client/dist # ─── Deepgram live options (D-013) ──────────────────────────────────────────── DEEPGRAM_MODEL=nova-3 @@ -39,5 +43,18 @@ DEEPGRAM_REGION=na # ─── Cartesia voice (D-006 — one voice for role-play + mentor) ──────────────── CARTESIA_VOICE_ID=a3536a36-1d18-4efb-a95a-7c44b7b5e384 +# ─── Proxmox LXC deployment (v0.2) ──────────────────────────────────────────── +# These are sourced from ~/coreci/.ciagent/.env.secrets (D-026 — same cluster). +# Listed here for documentation; do NOT duplicate in .ciagent/.env.secrets. +# PROXMOX_API_URL=https://proxmox:8006/api2/json +# PROXMOX_API_TOKEN=root@pam!praxis-deploy=SECRET +# PROXMOX_NODE=ns1003845 +# PROXMOX_STORAGE=local +# PROXMOX_TEMPLATE_VOLID=local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst +# PROXMOX_LXC_VMID=auto +# PROXMOX_TLS_SKIP_VERIFY=true +# PROXMOX_MEMORY_MB=4096 + # ─── CI/Gitea (operational — not voice) ─────────────────────────────────────── -# GITEA_TOKEN is provisioned in .ciagent/.env.secrets (not this file). \ No newline at end of file +# GITEA_TOKEN is provisioned in .ciagent/.env.secrets (not this file). +# PRAXIS_VERSION (git ref to deploy, default: main) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73809b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# Praxis v0.2 — Multi-stage Docker image +# Stage 1: build the React client (client/dist) +# Stage 2: Python server + serve client/dist via FastAPI StaticFiles +# +# Per RESEARCH.md Q4 / ARCHITECTURE.md §Image Build Pipeline. +# Debian-slim (not Alpine) — glibc for numpy/pipecat native extensions. + +# ── Stage 1: client builder ────────────────────────────────────────── +FROM node:22-slim AS client-builder + +WORKDIR /app/client + +# Copy manifest first for layer caching (deps change less often than source). +COPY client/package.json client/package-lock.json ./ +RUN npm ci + +# Copy client source and build. +COPY client/ ./ +RUN npm run build +# → produces /app/client/dist/ + +# ── Stage 2: server ────────────────────────────────────────────────── +FROM python:3.12-slim AS server + +WORKDIR /app + +# Build tools for any source-compilation fallback (numpy/aiohttp wheels +# should exist for cp312/linux-amd64, but gcc/g++ + libasound2-dev cover +# the R-DEPLOY-01 risk per RESEARCH.md Q4). +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends -qq gcc g++ libasound2-dev && \ + rm -rf /var/lib/apt/lists/* + +# Install Python deps before copying source (layer caching). +# G-105 FIX: copy pyproject.toml + README.md first, then pip install, +# THEN copy source — so deps are cached and source changes don't +# invalidate the pip layer. +COPY pyproject.toml README.md ./ +RUN pip install --no-cache-dir . + +# Copy server source + scenarios + db modules. +COPY server/ ./server/ +COPY scenarios/ ./scenarios/ +COPY db/ ./db/ + +# Copy the built client dist from Stage 1. +COPY --from=client-builder /app/client/dist ./client/dist + +# Data directory for SQLite (mounted as a volume in docker-compose.yml). +RUN mkdir -p /app/data +VOLUME ["/app/data"] + +EXPOSE 8789 + +# Run the FastAPI server via the existing entrypoint. +CMD ["python", "-m", "server"] \ No newline at end of file diff --git a/db/migrate.py b/db/migrate.py index 02edbd3..5c3a4bf 100644 --- a/db/migrate.py +++ b/db/migrate.py @@ -2,10 +2,12 @@ from __future__ import annotations +import os import sqlite3 from pathlib import Path -_DEFAULT_DB_PATH = Path("praxis.db") +# G-102 FIX: read PRAXIS_DB_PATH from env (must match db/store.py). +_DEFAULT_DB_PATH = Path(os.environ.get("PRAXIS_DB_PATH", "praxis.db")) _DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" diff --git a/db/store.py b/db/store.py index 291d6a6..f1c098b 100644 --- a/db/store.py +++ b/db/store.py @@ -13,6 +13,7 @@ No auth — learner_id is the hardcoded 'learner-1' (D-007). from __future__ import annotations import json +import os import uuid from dataclasses import dataclass from pathlib import Path @@ -22,7 +23,9 @@ import aiosqlite from db.migrate import apply_migrations -_DEFAULT_DB_PATH = "praxis.db" +# G-102 FIX: read PRAXIS_DB_PATH from env so the Docker volume mount +# actually persists data (docker-compose.yml sets PRAXIS_DB_PATH=/app/data/praxis.db). +_DEFAULT_DB_PATH = os.environ.get("PRAXIS_DB_PATH", "praxis.db") HARDCODED_LEARNER_ID = "learner-1" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd443c1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +# Praxis v0.2 — Docker Compose service definition +# Runs the praxis server inside a Docker container (inside an LXC CT). +# Per RESEARCH.md Q4/Q8 / ARCHITECTURE.md §v0.2 Deployment Architecture. + +services: + praxis: + build: . + image: praxis:latest + restart: unless-stopped + ports: + - "8789:8789" + volumes: + # SQLite DB persistence — survives container recreation (G-102). + - praxis-data:/app/data + environment: + PRAXIS_HOST: "0.0.0.0" + PRAXIS_PORT: "8789" + PRAXIS_DB_PATH: "/app/data/praxis.db" + PRAXIS_SCENARIOS_DIR: "/app/scenarios" + PRAXIS_TTS: "${PRAXIS_TTS:-cartesia}" + PRAXIS_SCENARIO: "${PRAXIS_SCENARIO:-customer_service_refund_ca_v01}" + # Voice-service keys (empty if unprovisioned — server degrades gracefully) + DEEPGRAM_API_KEY: "${DEEPGRAM_API_KEY:-}" + CARTESIA_API_KEY: "${CARTESIA_API_KEY:-}" + OLLAMA_API_KEY: "${OLLAMA_API_KEY:-}" + # Ollama Cloud endpoints (D-020) + OLLAMA_BASE_URL: "${OLLAMA_BASE_URL:-https://ollama.com/v1}" + OLLAMA_CHAT_URL: "${OLLAMA_CHAT_URL:-https://ollama.com/api/chat}" + OLLAMA_ROLEPLAY_MODEL: "${OLLAMA_ROLEPLAY_MODEL:-gemma4:cloud}" + OLLAMA_DEBRIEF_MODEL: "${OLLAMA_DEBRIEF_MODEL:-deepseek-v4-flash:cloud}" + # Deepgram (D-013) + DEEPGRAM_MODEL: "${DEEPGRAM_MODEL:-nova-3}" + DEEPGRAM_LANGUAGE: "${DEEPGRAM_LANGUAGE:-en}" + DEEPGRAM_REGION: "${DEEPGRAM_REGION:-na}" + # Cartesia (D-014) + CARTESIA_VOICE_ID: "${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}" + env_file: + # /etc/praxis/server.env is written by install-service.sh with + # secrets injected via lxc.environment (G-101 fix: GITEA_TOKEN baked + # into the snippet; voice keys from lxc.environment). + # required: false so `docker compose config` validates in dev without + # the file; install-service.sh ALWAYS creates it before + # `docker compose up` in production (so secrets are present at runtime). + - path: /etc/praxis/server.env + required: false + +volumes: + praxis-data: + driver: local \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index eea423a..714644a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,10 @@ license = { text = "Proprietary" } authors = [{ name = "Praxis v0.1 (CIAgent)" }] dependencies = [ + # Web framework — FastAPI serves /health + /pipecat/webrtc + StaticFiles (D-023) + "fastapi>=0.110", + # ASGI server — uvicorn runs the FastAPI app (used by server.__main__.main) + "uvicorn>=0.30", # Orchestration — Pipecat (D-017) with the three native service extras + WebRTC transport "pipecat-ai[deepgram,cartesia,piper,webrtc]>=1.6.0", # LLM access — Ollama Cloud direct API (D-020). Pipecat's OLLamaLLMService uses the diff --git a/scripts/install-service.sh b/scripts/install-service.sh new file mode 100755 index 0000000..29bbc66 --- /dev/null +++ b/scripts/install-service.sh @@ -0,0 +1,122 @@ +#!/bin/sh +# Praxis — Install the systemd service for Docker-based deployment. +# +# Adapted from coreci/scripts/install-service.sh. +# Coreci installs a Go binary + systemd unit; praxis creates the env +# file from lxc.environment vars, installs the systemd unit that runs +# `docker compose up` (foreground, Type=simple per RESEARCH.md Q8), +# and starts it. The Docker image is built by ExecStartPre. +# +# This script runs INSIDE the CT (called by firstboot-hook.sh via pct exec). +# It must run as root. + +set -e + +USER_NAME="praxis" +GROUP_NAME="praxis" +DATA_DIR="/var/lib/praxis/data" +LOG_DIR="/var/log/praxis" +ENV_FILE="/etc/praxis/server.env" +SERVICE_FILE="/etc/systemd/system/praxis.service" +APP_DIR="/opt/praxis" + +if [ "$(id -u)" -ne 0 ]; then + echo "install-service.sh: must run as root" >&2 + exit 1 +fi + +# Create the praxis user if it does not exist. +if ! id "$USER_NAME" >/dev/null 2>&1; then + echo "Creating user $USER_NAME" + useradd --system --home "$DATA_DIR" --shell /usr/sbin/nologin "$USER_NAME" +fi + +# Create data, log, and config directories. +mkdir -p "$DATA_DIR" "$LOG_DIR" /etc/praxis "$APP_DIR" +chown -R "$USER_NAME:$GROUP_NAME" "$DATA_DIR" "$LOG_DIR" +chown "root:$GROUP_NAME" /etc/praxis +chmod 0750 "$DATA_DIR" "$LOG_DIR" /etc/praxis + +# Write the env file from the current environment (lxc.environment vars +# are available inside the CT's environment). This file is read by +# docker-compose.yml via env_file (G-101/G-102 secret injection chain). +# G-103 FIX: include ALL env vars the server reads. +cat > "$ENV_FILE" </dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq curl +fi + +# Install the systemd unit. +cat > "$SERVICE_FILE" <<'UNIT' +[Unit] +Description=Praxis — voice-first AI apprenticeship platform +Documentation=https://git.cloudinit.dev/coreci/praxis +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=praxis +Group=praxis +WorkingDirectory=/opt/praxis +EnvironmentFile=-/etc/praxis/server.env +# Build the image first (ExecStartPre), then run in foreground. +# Type=simple + foreground `docker compose up` (no -d) so systemd +# tracks the process. TimeoutStartSec=600 covers the build (RESEARCH Q8). +ExecStartPre=/usr/bin/docker compose build +ExecStart=/usr/bin/docker compose up +ExecStop=/usr/bin/docker compose down +Restart=on-failure +RestartSec=5 +TimeoutStartSec=600 +TimeoutStopSec=60 + +# NOTE: Do NOT use coreci's hardening directives (ProtectSystem, PrivateDevices, +# etc.) — they break Docker's need to access /var/run/docker.sock, cgroups, +# and namespaces. Docker-in-LXC requires relaxed sandboxing (RESEARCH Q8). + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=praxis + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl daemon-reload +systemctl enable praxis.service + +# Start the service (this triggers ExecStartPre=docker compose build, +# which may take 3-5 min on first boot). +echo "Starting praxis service (Docker build may take 3-5 min)..." +systemctl start praxis.service || { + echo "Failed to start praxis; check 'journalctl -u praxis -n 50'" >&2 + exit 1 +} + +echo "Praxis service installed and started." \ No newline at end of file diff --git a/scripts/proxmox/api.sh b/scripts/proxmox/api.sh new file mode 100755 index 0000000..ded1e83 --- /dev/null +++ b/scripts/proxmox/api.sh @@ -0,0 +1,175 @@ +#!/bin/sh +# CoreCI — Proxmox VE REST API shared helpers. +# +# Sourced by the other scripts/proxmox/*.sh scripts. Provides: +# pve_curl — authenticated curl wrapper (PVEAPIToken header, TLS opt) +# pve_poll — poll an async UPID until status == "stopped" +# pve_nextid — fetch the next free VMID +# pve_get — GET with 503 bounded retry (idempotent reads only) +# pve_env — validate required env vars are set +# +# All helpers use `set -eu` semantics (fail fast). The caller is +# expected to `set -eu` and `source` this file. + +# ── TLS handling ────────────────────────────────────────────── +# PROXMOX_TLS_SKIP_VERIFY=true → curl --insecure (self-signed certs). +# Default is false (secure; operator opts in for self-signed). +pve_tls_insecure() { + case "${PROXMOX_TLS_SKIP_VERIFY:-false}" in + true|1|yes|TRUE) echo "--insecure" ;; + *) echo "" ;; + esac +} + +# ── Auth header ──────────────────────────────────────────────── +# PVEAPIToken=USER@REALM!TOKENID=SECRET (no ticket step, no CSRF) +pve_auth_header() { + printf '%s' "PVEAPIToken=${PROXMOX_API_TOKEN:?PROXMOX_API_TOKEN is required}" +} + +# ── Core curl wrapper ────────────────────────────────────────── +# Usage: pve_curl [form-data-args...] +# Returns the raw JSON `data` field on stdout (jq -r .data). +# Exits non-zero on HTTP >= 300 or curl failure. +pve_curl() { + method="$1"; path="$2"; shift 2 + url="${PROXMOX_API_URL:?PROXMOX_API_URL is required}${path}" + insecure="$(pve_tls_insecure)" + + if [ "$#" -gt 0 ]; then + # Form-encoded body for POST/PUT (key=value pairs) + data_args="" + for pair in "$@"; do + data_args="${data_args} --data-urlencode ${pair}" + done + # shellcheck disable=SC2086 + response=$(curl -sS $insecure \ + -X "$method" \ + -H "Authorization: $(pve_auth_header)" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + $data_args \ + "$url") + else + # shellcheck disable=SC2086 + response=$(curl -sS $insecure \ + -X "$method" \ + -H "Authorization: $(pve_auth_header)" \ + "$url") + fi + + # Proxmox always wraps responses in {"data": ...}. Check for errors. + status=$(printf '%s' "$response" | jq -r '.errors // empty') + if [ -n "$status" ]; then + echo "pve_curl: API error for $method $path: $status" >&2 + printf '%s' "$response" >&2 + return 1 + fi + + printf '%s' "$response" | jq -r '.data' +} + +# ── GET with 503 bounded retry (idempotent reads only) ──────── +# IDEATE-19: transient 503s (node busy/restarting) retried 3× / 2s backoff. +# NOT used for mutating calls (clone/start/stop) — those are UPID-polled. +pve_get() { + path="$1" + url="${PROXMOX_API_URL:?}${path}" + insecure="$(pve_tls_insecure)" + attempt=0 + max=3 + while [ "$attempt" -lt "$max" ]; do + # shellcheck disable=SC2086 + response=$(curl -sS -w '\n%{http_code}' $insecure \ + -X GET \ + -H "Authorization: $(pve_auth_header)" \ + "$url") + http_code=$(printf '%s' "$response" | tail -1) + body=$(printf '%s' "$response" | sed '$d') + if [ "$http_code" = "503" ] && [ "$((attempt + 1))" -lt "$max" ]; then + attempt=$((attempt + 1)) + echo "pve_get: 503 from $path, retry $attempt/$max in 2s..." >&2 + sleep 2 + continue + fi + if [ "$http_code" != "200" ]; then + echo "pve_get: HTTP $http_code for $path" >&2 + printf '%s' "$body" >&2 + return 1 + fi + printf '%s' "$body" | jq -r '.data' + return 0 + done + # Exhausted all 503 retries. + echo "pve_get: 503 from $path after $max attempts" >&2 + return 1 +} + +# ── UPID polling ─────────────────────────────────────────────── +# Mutating Proxmox calls return a UPID string. Poll until done. +# Usage: pve_poll +# Exits non-zero if the task exitstatus != "OK". +pve_poll() { + upid="$1" + node="${PROXMOX_NODE:?PROXMOX_NODE is required}" + path="/nodes/${node}/tasks/${upid}/status" + attempt=0 + max_attempts=120 # 120 × 2s = 4 min max + while [ "$attempt" -lt "$max_attempts" ]; do + status=$(pve_curl GET "$path") + running=$(printf '%s' "$status" | jq -r '.status') + if [ "$running" = "stopped" ]; then + exitstatus=$(printf '%s' "$status" | jq -r '.exitstatus') + # "OK" is the clean success. "WARNINGS: N" is a successful + # completion with non-fatal warnings (e.g. systemd 255 + # nesting hint on CT create). Both are acceptable. + case "$exitstatus" in + OK|WARNINGS\ *) + return 0 + ;; + *) + echo "pve_poll: task $upid failed with exitstatus: $exitstatus" >&2 + return 1 + ;; + esac + fi + attempt=$((attempt + 1)) + sleep 2 + done + echo "pve_poll: timeout waiting for task $upid" >&2 + return 1 +} + +# ── Next free VMID ──────────────────────────────────────────── +pve_nextid() { + pve_curl GET "/cluster/nextid" | jq -r '. | tonumber' +} + +# ── Env validation ──────────────────────────────────────────── +# Usage: pve_env VAR1 VAR2 ... — exits 1 if any is unset/empty +pve_env() { + missing=0 + for var in "$@"; do + eval "val=\"\${${var}:-}\"" + if [ -z "$val" ]; then + echo "pve_env: $var is required but not set" >&2 + missing=1 + fi + done + return "$missing" +} + +# ── lxc.environment form-encoding helper ────────────────────── +# Proxmox PUT /config accepts repeated lxc.environment=KEY=value. +# This builds the curl data args from KEY=value pairs. +# Usage: pve_lxc_env_args KEY1=VAL1 KEY2=VAL2 ... +# Emits one "lxc.environment=KEY=VAL" token per arg, newline-separated, +# so the caller can pass each line to curl --data-urlencode. (Prior +# version concatenated all args into a single malformed blob.) +pve_lxc_env_args() { + first=1 + for pair in "$@"; do + [ "$first" -eq 0 ] && printf '\n' + printf '%s' "lxc.environment=${pair}" + first=0 + done +} \ No newline at end of file diff --git a/scripts/proxmox/ct-exists.sh b/scripts/proxmox/ct-exists.sh new file mode 100755 index 0000000..59d6619 --- /dev/null +++ b/scripts/proxmox/ct-exists.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Praxis — CT existence + running-state helpers (P16 — deploy idempotency). +# +# Sourced by the deploy orchestrator (lxc-deploy.sh) to detect an +# existing CT before clone. Idempotent re-deploy: +# - healthy + running → skip clone/config/start (exit 0 / continue) +# - exists but unhealthy → error with guidance (--recreate / --reconfigure) +# - not exists → proceed with clone (current path) +# +# These helpers wrap pve_get against GET /nodes/{node}/lxc/{vmid}/status/current. +# A 404 (CT not found) returns HTTP non-200 → pve_get exits non-zero; the +# helpers translate that into the 0/1 return codes the orchestrators branch on. +# `set -eu` is NOT used here (the caller is set -eu; this file defines +# functions that intentionally swallow non-zero pve_get returns). +# +# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE (via api.sh) +# Functions: +# ct_exists → 0 if the CT exists (200), 1 if not (404/other) +# ct_running → 0 if the CT exists AND status == "running", +# 1 otherwise (not exists, or not running) +# ct_status → echoes the raw status string (e.g. "running", +# "stopped") on stdout; empty if not exists +# +# Source this file AFTER api.sh: +# . "${SCRIPT_DIR}/ct-exists.sh" + +# ct_exists → 0 if the CT exists, 1 if not. +# Uses pve_get against /status/current; a non-200 (404) is "not found". +# Under `set -eu` in the caller, the `|| true` prevents an exit on the +# pve_get failure path. +ct_exists() { + vmid="$1" + node="${PROXMOX_NODE:?PROXMOX_NODE is required}" + status_json=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true) + [ -n "$status_json" ] && [ "$status_json" != "null" ] +} + +# ct_running → 0 if the CT exists AND status == "running", else 1. +ct_running() { + vmid="$1" + node="${PROXMOX_NODE:?PROXMOX_NODE is required}" + status_json=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true) + if [ -z "$status_json" ] || [ "$status_json" = "null" ]; then + return 1 + fi + running=$(printf '%s' "$status_json" | jq -r '.status // empty' 2>/dev/null || true) + [ "$running" = "running" ] +} + +# ct_status → echoes the status string on stdout; empty if not exists. +ct_status() { + vmid="$1" + node="${PROXMOX_NODE:?PROXMOX_NODE is required}" + status_json=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true) + if [ -z "$status_json" ] || [ "$status_json" = "null" ]; then + return 0 + fi + printf '%s' "$(printf '%s' "$status_json" | jq -r '.status // empty' 2>/dev/null || true)" +} \ No newline at end of file diff --git a/scripts/proxmox/e2e-deploy.sh b/scripts/proxmox/e2e-deploy.sh new file mode 100755 index 0000000..5ef7912 --- /dev/null +++ b/scripts/proxmox/e2e-deploy.sh @@ -0,0 +1,116 @@ +#!/bin/sh +# Praxis — E2E deploy verification script. +# +# Runs the full deploy against a live Proxmox cluster, then verifies +# the deployed CT is healthy and serving the praxis client + API. +# +# This is the integration test that proves the deploy pipeline works +# end-to-end. It sources secrets from both ~/coreci/.ciagent/.env.secrets +# (proxmox) and .ciagent/.env.secrets (GITEA_TOKEN, DEEPGRAM_API_KEY). +# +# Usage: ./scripts/proxmox/e2e-deploy.sh [--recreate] +# Exit: 0 on success, 1 on failure + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJ_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CORECI_SECRETS="${HOME}/coreci/.ciagent/.env.secrets" +PRAXIS_SECRETS="${PROJ_ROOT}/.ciagent/.env.secrets" + +echo "e2e: praxis LXC deploy verification" >&2 + +# ── Load secrets ─────────────────────────────────────────────────── +if [ ! -f "$CORECI_SECRETS" ]; then + echo "e2e: ERROR — coreci secrets not found at ${CORECI_SECRETS}" >&2 + exit 1 +fi +if [ ! -f "$PRAXIS_SECRETS" ]; then + echo "e2e: ERROR — praxis secrets not found at ${PRAXIS_SECRETS}" >&2 + exit 1 +fi + +# Source proxmox secrets from coreci (D-026). +set -a +. "$CORECI_SECRETS" +# Source praxis secrets (GITEA_TOKEN, DEEPGRAM_API_KEY). +. "$PRAXIS_SECRETS" +set +a + +# Validate required secrets. +for var in PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \ + PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID GITEA_TOKEN; do + eval "val=\"\${${var}:-}\"" + if [ -z "$val" ]; then + echo "e2e: ERROR — ${var} is not set" >&2 + exit 1 + fi +done + +echo "e2e: secrets loaded (proxmox from coreci, gitea+deepgram from praxis)" >&2 + +# ── Run the deploy ───────────────────────────────────────────────── +echo "e2e: running lxc-deploy.sh $*..." >&2 +VMID_OUTPUT=$("${SCRIPT_DIR}/lxc-deploy.sh" "$@" 2>&1) || { + echo "e2e: lxc-deploy.sh FAILED" >&2 + printf '%s\n' "$VMID_OUTPUT" >&2 + exit 1 +} +VMID=$(printf '%s\n' "$VMID_OUTPUT" | grep '^VMID=' | cut -d= -f2) +if [ -z "$VMID" ]; then + echo "e2e: ERROR — could not parse VMID from deploy output" >&2 + printf '%s\n' "$VMID_OUTPUT" >&2 + exit 1 +fi +echo "e2e: deployed VMID=${VMID}" >&2 + +# ── Verify the deployed CT ───────────────────────────────────────── +echo "e2e: verifying deployed CT..." >&2 + +# 1. Health-check (already ran inside lxc-deploy.sh, but re-verify) +"${SCRIPT_DIR}/health-check.sh" "$VMID" || { + echo "e2e: health-check FAILED for VMID ${VMID}" >&2 + exit 1 +} + +# 2. Fetch the /health endpoint and check the response shape +HEALTH_URL="${PRAXIS_HEALTH_URL:-}" +if [ -z "$HEALTH_URL" ]; then + # Resolve bridge IP like health-check.sh does + ifaces=$(curl -sS --insecure ${PROXMOX_TLS_SKIP_VERIFY:+--insecure} \ + -H "Authorization: PVEAPIToken=${PROXMOX_API_TOKEN}" \ + "${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${VMID}/interfaces" 2>/dev/null | jq -r '.data') + ip=$(printf '%s' "$ifaces" | jq -r '.[] | select(.name != "lo") | (.inet? // .ip? // empty)' 2>/dev/null | grep -v '^$' | head -1) + HEALTH_URL="http://${ip}:8789/health" +fi + +echo "e2e: polling ${HEALTH_URL}..." >&2 +HEALTH_RESP=$(curl -fsS --connect-timeout 5 "$HEALTH_URL" 2>&1) || { + echo "e2e: /health endpoint unreachable at ${HEALTH_URL}" >&2 + exit 1 +} +STATUS=$(printf '%s' "$HEALTH_RESP" | jq -r '.status' 2>/dev/null) +if [ "$STATUS" != "ok" ]; then + echo "e2e: /health status is '${STATUS}' (expected 'ok')" >&2 + exit 1 +fi +echo "e2e: /health returned status=ok ✓" >&2 + +# 3. Verify the client is served (GET / should return HTML) +CLIENT_URL="${HEALTH_URL%/health}/" +CLIENT_RESP=$(curl -fsS --connect-timeout 5 "$CLIENT_URL" 2>&1) || { + echo "e2e: client endpoint unreachable at ${CLIENT_URL}" >&2 + exit 1 +} +case "$CLIENT_RESP" in + *"&2 + ;; + *) + echo "e2e: client endpoint did not return HTML" >&2 + exit 1 + ;; +esac + +echo "e2e: ALL CHECKS PASSED — praxis deployed and serving on VMID ${VMID}" >&2 +printf 'VMID=%s\nHEALTH_URL=%s\n' "$VMID" "$HEALTH_URL" \ No newline at end of file diff --git a/scripts/proxmox/firstboot-hook.sh b/scripts/proxmox/firstboot-hook.sh new file mode 100755 index 0000000..befc06a --- /dev/null +++ b/scripts/proxmox/firstboot-hook.sh @@ -0,0 +1,87 @@ +#!/bin/sh +# Praxis — Proxmox LXC first-boot hookscript. +# +# Adapted from coreci/scripts/proxmox/firstboot-hook.sh. +# Coreci fetches a pre-built Go binary + pct-pushes it; praxis installs +# Docker inside the CT, clones the repo from Gitea, builds the image, +# and starts the service via systemd (D-022, D-028, D-029). +# +# Referenced by lxc-config.sh via hookscript=local:snippets/praxis-firstboot.sh. +# Proxmox invokes this script at CT lifecycle phases on the PVE HOST +# (not inside the CT). The `post-start` phase does the work. +# +# G-101 FIX: GITEA_TOKEN is baked into this snippet by stage-snippet.sh +# (the hookscript runs on the PVE host where lxc.environment is invisible). +# The token is used to clone the private Gitea repo inside the CT. +# +# Proxmox passes: $1 = VMID, $2 = phase +# Environment (baked in by stage-snippet.sh): +# GITEA_TOKEN — bearer token for the private Gitea repo +# PRAXIS_VERSION — git ref (default: main) +# GITEA_HOST — Gitea hostname (default: git.cloudinit.dev) + +set -eu + +vmid="${1:-}" +phase="${2:-}" + +log() { printf '[praxis-hook %s] %s\n' "$phase" "$*" >&2; } + +case "$phase" in + post-start) : ;; + *) exit 0 ;; +esac + +log "VMID=${vmid} — first-boot praxis install (Docker-in-LXC)" + +VERSION="${PRAXIS_VERSION:-main}" +GITEA_HOST="${GITEA_HOST:-git.cloudinit.dev}" +GITEA_ORG="coreci" +GITEA_REPO="praxis" +CLONE_URL="https://${GITEA_TOKEN}@${GITEA_HOST}/${GITEA_ORG}/${GITEA_REPO}.git" + +# Idempotency: skip if praxis is already installed and running. +# Check for the repo clone + active service (not a binary — praxis uses +# docker compose, not a /usr/local/bin binary like coreci). +if pct exec "$vmid" -- sh -c '[ -d /opt/praxis/.git ] && systemctl is-active --quiet praxis' 2>/dev/null; then + log "praxis already installed and active — skipping" + exit 0 +fi + +# Step 1: Install Docker + docker-compose-v2 inside the CT (D-028). +# Debian 12 standard template + nesting=1 supports Docker. +log "installing Docker inside CT ${vmid}" +pct exec "$vmid" -- sh -c ' +set -e +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq docker.io docker-compose-v2 git curl +systemctl enable --now docker +' + +# Step 2: Clone the praxis repo inside the CT (D-029). +# Clone to /opt/praxis (persistent across container restarts). +log "cloning praxis repo (ref=${VERSION}) into CT" +pct exec "$vmid" -- sh -c " +set -e +mkdir -p /opt/praxis +cd /opt/praxis +git clone --depth 1 --branch '${VERSION}' '${CLONE_URL}' . 2>&1 || { + # If the specific branch doesn't exist, fall back to main + log 'falling back to main branch' + git clone --depth 1 '${CLONE_URL}' . 2>&1 +} +" + +# Step 3: Write the env file from lxc.environment (passed via the CT's env). +# The lxc.environment vars are available inside the CT's environment. +# install-service.sh writes /etc/praxis/server.env from these. +log "running install-service inside CT" +pct exec "$vmid" -- sh -c ' +set -e +cd /opt/praxis +sh scripts/install-service.sh +' + +log "praxis installed and started in CT ${vmid}" +exit 0 \ No newline at end of file diff --git a/scripts/proxmox/health-check.sh b/scripts/proxmox/health-check.sh new file mode 100755 index 0000000..d5c0590 --- /dev/null +++ b/scripts/proxmox/health-check.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Praxis — Poll a deployed LXC container's /health endpoint. +# +# Adapted from coreci/scripts/proxmox/health-check.sh. +# Coreci polls /healthz:18080; praxis polls /health:8789. +# +# If PRAXIS_HEALTH_URL is set, use it directly. Otherwise, query +# the Proxmox /interfaces endpoint for the CT's bridge IP and +# construct http://:/health. +# +# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE, +# PRAXIS_HEALTH_URL (optional override), PRAXIS_PORT (default 8789), +# PRAXIS_HEALTH_TIMEOUT (default 600 — first-boot Docker build + +# compose up may take up to 5 min; G-104 FIX bumped from 300s to +# give margin vs the 5-min worst-case build time per RESEARCH.md Q7) +# Args: $1 = VMID +# Exit: 0 if healthy within timeout, 1 otherwise + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=api.sh disable=SC1091 +. "${SCRIPT_DIR}/api.sh" + +pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE + +vmid="${1:?usage: health-check.sh }" +http_port="${PRAXIS_PORT:-8789}" +timeout_s="${PRAXIS_HEALTH_TIMEOUT:-600}" + +# Resolve health URL +if [ -n "${PRAXIS_HEALTH_URL:-}" ]; then + health_url="${PRAXIS_HEALTH_URL}" +else + # Query the CT's network interfaces for the bridge IP. + node="${PROXMOX_NODE}" + ifaces=$(pve_get "/nodes/${node}/lxc/${vmid}/interfaces" 2>/dev/null || true) + if [ -z "$ifaces" ] || [ "$ifaces" = "null" ]; then + echo "health-check: cannot resolve bridge IP for VMID ${vmid} (set PRAXIS_HEALTH_URL)" >&2 + exit 1 + fi + # Pick the first non-loopback IPv4 address. Emit only the IP fields + # (not hwaddr — it precedes .inet/.ip in PVE's response and head -1 + # would pick the MAC — a bug fixed in coreci v3.6 P18 review). + ip=$(printf '%s' "$ifaces" | jq -r \ + '.[] | select(.name != "lo") | (.inet? // .ip? // empty)' 2>/dev/null | grep -v '^$' | head -1) + if [ -z "$ip" ] || [ "$ip" = "null" ]; then + echo "health-check: no bridge IP found for VMID ${vmid} (set PRAXIS_HEALTH_URL)" >&2 + exit 1 + fi + health_url="http://${ip}:${http_port}/health" +fi + +echo "health-check: polling ${health_url} for up to ${timeout_s}s..." >&2 +ok=0 +# shellcheck disable=SC2034 +for i in $(seq 1 "$timeout_s"); do + if curl -fsS --connect-timeout 2 "$health_url" >/dev/null 2>&1; then + ok=1 + break + fi + sleep 1 +done + +if [ "$ok" -ne 1 ]; then + echo "health-check: praxis did not become healthy within ${timeout_s}s at ${health_url}" >&2 + exit 1 +fi + +echo "health-check: praxis healthy at ${health_url}" >&2 \ No newline at end of file diff --git a/scripts/proxmox/lxc-clone.sh b/scripts/proxmox/lxc-clone.sh new file mode 100755 index 0000000..9b3beb6 --- /dev/null +++ b/scripts/proxmox/lxc-clone.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Praxis — Create a Proxmox LXC container from a template via REST API. +# +# Uses the POST /nodes/{node}/lxc endpoint with ostemplate= +# (create-from-template) instead of the storage clone endpoint. The +# clone endpoint rejects API tokens (`user != root@pam` guard), but +# the create endpoint accepts them — so this path works end-to-end +# with a PVEAPIToken. Pure REST, no SSH. +# +# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE, +# PROXMOX_STORAGE, PROXMOX_TEMPLATE_VOLID +# Args: $1 = target VMID (from pve_nextid) +# Stdout: the new VMID (integer) +# Exit: 0 on success, 1 on failure + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=api.sh disable=SC1091 +. "${SCRIPT_DIR}/api.sh" + +pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \ + PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID + +newid="${1:?usage: lxc-clone.sh }" +node="${PROXMOX_NODE}" +storage="${PROXMOX_STORAGE}" +template_volid="${PROXMOX_TEMPLATE_VOLID}" + +# POST /nodes/{node}/lxc — create a CT from a template. +# Body (form-encoded): vmid, ostemplate, hostname, storage, rootfs, ... +# Returns: UPID (async task). Poll until done. +create_path="/nodes/${node}/lxc" +hostname="${PRAXIS_HOSTNAME:-praxis}" + +echo "lxc-clone: creating VMID ${newid} from ${template_volid}" >&2 +upid=$(pve_curl POST "$create_path" \ + "vmid=${newid}" \ + "ostemplate=${template_volid}" \ + "hostname=${hostname}" \ + "storage=${storage}" \ + "rootfs=${storage}:16" \ + "memory=${PROXMOX_MEMORY_MB:-4096}" \ + "net0=name=eth0,bridge=vmbr0,ip=dhcp" \ + "arch=amd64" \ + "features=nesting=1") + +if [ -z "$upid" ] || [ "$upid" = "null" ]; then + echo "lxc-clone: failed to start create (empty UPID)" >&2 + exit 1 +fi + +echo "lxc-clone: polling create task ${upid}" >&2 +pve_poll "$upid" + +echo "lxc-clone: CT ${newid} created from ${template_volid}" >&2 +printf '%s\n' "$newid" \ No newline at end of file diff --git a/scripts/proxmox/lxc-config.sh b/scripts/proxmox/lxc-config.sh new file mode 100755 index 0000000..5982729 --- /dev/null +++ b/scripts/proxmox/lxc-config.sh @@ -0,0 +1,132 @@ +#!/bin/sh +# Praxis — Configure a created LXC container. +# +# Sets memory + onboot via the REST PUT /config (API-token-accepted), +# then sets hookscript + lxc.environment via SSH to the PVE host (these +# are root-only via REST: `hookscript` rejects API tokens, and +# `lxc.environment` is not in the REST schema). The hookscript points +# at the snippet staged by stage-snippet.sh (local:snippets/praxis- +# firstboot.sh). +# +# G-101: The GITEA_TOKEN must be available to the hookscript which runs +# on the PVE HOST (lxc.environment is NOT visible to the host-side +# hookscript). The token is baked into the snippet by stage-snippet.sh. +# The lxc.environment lines here put GITEA_TOKEN into the CT for the +# CT's own use (docker-compose env_file reads it), but the hookscript +# relies on the baked-in value. +# +# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE, +# PRAXIS_VERSION (git clone tag/branch, default latest), +# GITEA_TOKEN (for the private repo fetch inside the CT), +# DEEPGRAM_API_KEY, CARTESIA_API_KEY, OLLAMA_API_KEY (secrets, +# may be empty in v0.2 infrastructure-only), +# PRAXIS_DB_PATH (default /app/data/praxis.db), +# PRAXIS_TTS, PRAXIS_SCENARIO (optional, with defaults), +# OLLAMA_BASE_URL, OLLAMA_CHAT_URL, OLLAMA_ROLEPLAY_MODEL, +# OLLAMA_DEBRIEF_MODEL, +# DEEPGRAM_MODEL, DEEPGRAM_LANGUAGE, DEEPGRAM_REGION, +# CARTESIA_VOICE_ID, +# PRAXIS_PORT (default 8789), +# PROXMOX_MEMORY_MB (optional, default 4096), +# PROXMOX_STORAGE (for the hookscript volid prefix), +# PROXMOX_SSH_HOST (optional; defaults to PROXMOX_NODE) +# Args: $1 = VMID +# Exit: 0 on success, 1 on failure + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=api.sh disable=SC1091 +. "${SCRIPT_DIR}/api.sh" + +pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE + +vmid="${1:?usage: lxc-config.sh }" +node="${PROXMOX_NODE}" +memory="${PROXMOX_MEMORY_MB:-4096}" +version="${PRAXIS_VERSION:-latest}" +port="${PRAXIS_PORT:-8789}" +db_path="${PRAXIS_DB_PATH:-/app/data/praxis.db}" +storage="${PROXMOX_STORAGE:-local}" +hookscript_volid="${storage}:snippets/praxis-firstboot.sh" +ssh_host="${PROXMOX_SSH_HOST:-${node}}" + +# Optional praxis config (with defaults; empty is valid for v0.2). +# Defaults match .env.example + install-service.sh + docker-compose.yml +# so the injection chain is consistent across all three layers. +praxis_tts="${PRAXIS_TTS:-cartesia}" +praxis_scenario="${PRAXIS_SCENARIO:-customer_service_refund_ca_v01}" + +# Secret keys (may be empty in v0.2 infrastructure-only slice). +deepgram_key="${DEEPGRAM_API_KEY:-}" +cartesia_key="${CARTESIA_API_KEY:-}" +ollama_key="${OLLAMA_API_KEY:-}" + +# Ollama config (with defaults — match .env.example + docker-compose.yml). +ollama_base="${OLLAMA_BASE_URL:-https://ollama.com/v1}" +ollama_chat="${OLLAMA_CHAT_URL:-https://ollama.com/api/chat}" +ollama_roleplay="${OLLAMA_ROLEPLAY_MODEL:-gemma4:cloud}" +ollama_debrief="${OLLAMA_DEBRIEF_MODEL:-deepseek-v4-flash:cloud}" + +# Deepgram config (with defaults — match .env.example + docker-compose.yml). +deepgram_model="${DEEPGRAM_MODEL:-nova-3}" +deepgram_lang="${DEEPGRAM_LANGUAGE:-en}" +deepgram_region="${DEEPGRAM_REGION:-na}" + +# Cartesia config (with defaults — match .env.example; the voice ID is +# the single shared voice per D-006). +cartesia_voice="${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7e44b7b5e384}" + +config_path="/nodes/${node}/lxc/${vmid}/config" + +echo "lxc-config: configuring VMID ${vmid} (memory=${memory}MB, onboot=1, hookscript=${hookscript_volid})" >&2 + +# Step 1: REST-accepted fields (memory, onboot). PUT /config is +# synchronous (no UPID), returns null on success. +pve_curl PUT "$config_path" "onboot=1" "memory=${memory}" + +# Step 2: root-only fields (hookscript, lxc.environment) via SSH to the +# PVE host config file. These are rejected by the REST API for API +# tokens and lxc.environment is not in the REST schema at all. +conf_file="/etc/pve/lxc/${vmid}.conf" +ssh_opts="-o StrictHostKeyChecking=no" +# Build the lines to append (remove any prior hookscript/onboot/lxc.environment +# lines first to keep the config idempotent). +append_lines() { + printf 'onboot: 1\n' + printf 'hookscript: %s\n' "$hookscript_volid" + printf 'lxc.environment: PRAXIS_HOST=0.0.0.0\n' + printf 'lxc.environment: PRAXIS_VERSION=%s\n' "$version" + printf 'lxc.environment: PRAXIS_PORT=%s\n' "$port" + printf 'lxc.environment: PRAXIS_DB_PATH=%s\n' "$db_path" + printf 'lxc.environment: PRAXIS_SCENARIOS_DIR=/app/scenarios\n' + printf 'lxc.environment: PRAXIS_TTS=%s\n' "$praxis_tts" + printf 'lxc.environment: PRAXIS_SCENARIO=%s\n' "$praxis_scenario" + if [ -n "${GITEA_TOKEN:-}" ]; then + printf 'lxc.environment: GITEA_TOKEN=%s\n' "$GITEA_TOKEN" + fi + printf 'lxc.environment: DEEPGRAM_API_KEY=%s\n' "$deepgram_key" + printf 'lxc.environment: CARTESIA_API_KEY=%s\n' "$cartesia_key" + printf 'lxc.environment: OLLAMA_API_KEY=%s\n' "$ollama_key" + printf 'lxc.environment: OLLAMA_BASE_URL=%s\n' "$ollama_base" + printf 'lxc.environment: OLLAMA_CHAT_URL=%s\n' "$ollama_chat" + printf 'lxc.environment: OLLAMA_ROLEPLAY_MODEL=%s\n' "$ollama_roleplay" + printf 'lxc.environment: OLLAMA_DEBRIEF_MODEL=%s\n' "$ollama_debrief" + printf 'lxc.environment: DEEPGRAM_MODEL=%s\n' "$deepgram_model" + printf 'lxc.environment: DEEPGRAM_LANGUAGE=%s\n' "$deepgram_lang" + printf 'lxc.environment: DEEPGRAM_REGION=%s\n' "$deepgram_region" + printf 'lxc.environment: CARTESIA_VOICE_ID=%s\n' "$cartesia_voice" +} +# shellcheck disable=SC2029 +# SC2029: conf='${conf_file}' intentionally expands on the client side — +# the script builds the remote /etc/pve/lxc/.conf path from the +# local variable and ships the literal path to the remote host. +append_lines | ssh "$ssh_opts" "root@${ssh_host}" " + conf='${conf_file}' + # Remove prior hookscript/onboot/lxc.environment lines. + sed -i '/^hookscript:/d;/^onboot:/d;/^lxc\.environment: PRAXIS/d;/^lxc\.environment: GITEA_TOKEN/d;/^lxc\.environment: DEEPGRAM/d;/^lxc\.environment: CARTESIA/d;/^lxc\.environment: OLLAMA/d' \"\$conf\" 2>/dev/null || true + cat >> \"\$conf\" + echo 'lxc-config: SSH config updated' >&2 +" + +echo "lxc-config: VMID ${vmid} configured" >&2 \ No newline at end of file diff --git a/scripts/proxmox/lxc-deploy.sh b/scripts/proxmox/lxc-deploy.sh new file mode 100755 index 0000000..ae19bc5 --- /dev/null +++ b/scripts/proxmox/lxc-deploy.sh @@ -0,0 +1,176 @@ +#!/bin/sh +# Praxis — Orchestrator: deploy praxis to a Proxmox LXC container. +# +# Adapted from coreci/scripts/proxmox/lxc-deploy.sh. +# Sequence: stage snippet → clone template → configure CT → start → +# health-check → rollback on failure. +# +# Required env (see .env.example + ~/coreci/.ciagent/.env.secrets): +# PROXMOX_API_URL — https://proxmox:8006/api2/json +# PROXMOX_API_TOKEN — USER@REALM!TOKENID=SECRET +# PROXMOX_NODE — target node name +# PROXMOX_STORAGE — storage holding the template +# PROXMOX_TEMPLATE_VOLID — local:vztmpl/debian-12-template.tar.zst +# GITEA_TOKEN — bearer token for the private Gitea repo +# (baked into the firstboot snippet by stage-snippet.sh) +# +# Optional env: +# PROXMOX_LXC_VMID — target CT VMID (default: auto-allocate via pve_nextid) +# PRAXIS_VERSION — git ref to deploy (default: main) +# PRAXIS_PORT — server HTTP port (default: 8789) +# PRAXIS_HEALTH_URL — override health-check URL +# PROXMOX_MEMORY_MB — CT memory limit (default: 4096) +# PROXMOX_TLS_SKIP_VERIFY— accept self-signed certs (default: false) +# DEEPGRAM_API_KEY — voice-service key (optional, may be empty) +# CARTESIA_API_KEY — voice-service key (optional, may be empty) +# OLLAMA_API_KEY — voice-service key (optional, may be empty) +# +# Flags: +# --recreate — rollback.sh (stop + destroy) then full redeploy +# --reconfigure — re-PUT lxc-config.sh + restart (no clone) +# +# Exit: 0 on successful deploy, 1 on failure (with rollback attempted) + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJ_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +# shellcheck source=api.sh disable=SC1091 +. "${SCRIPT_DIR}/api.sh" +# shellcheck source=ct-exists.sh disable=SC1091 +. "${SCRIPT_DIR}/ct-exists.sh" +# shellcheck source=timing.sh disable=SC1091 +. "${SCRIPT_DIR}/timing.sh" + +# ── Source secrets (D-026, MH-23) ────────────────────────────────── +# Proxmox secrets come from ~/coreci/.ciagent/.env.secrets (same cluster, +# same operator). Praxis secrets (GITEA_TOKEN, DEEPGRAM_API_KEY) come from +# praxis's own .ciagent/.env.secrets. Missing files emit a warning (the +# vars may already be in the environment from the CI runner); pve_env +# below fails fast if required vars are still unset. +CORECI_SECRETS="${HOME}/coreci/.ciagent/.env.secrets" +PRAXIS_SECRETS="${PROJ_ROOT}/.ciagent/.env.secrets" +if [ -f "$CORECI_SECRETS" ]; then + # shellcheck source=/dev/null disable=SC1091 + . "$CORECI_SECRETS" +else + echo "deploy: WARNING — ${CORECI_SECRETS} not found (PROXMOX_* vars must be in env)" >&2 +fi +if [ -f "$PRAXIS_SECRETS" ]; then + # shellcheck source=/dev/null disable=SC1091 + . "$PRAXIS_SECRETS" +else + echo "deploy: WARNING — ${PRAXIS_SECRETS} not found (GITEA_TOKEN/DEEPGRAM_API_KEY must be in env)" >&2 +fi + +pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \ + PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID GITEA_TOKEN + +# ── Flag parsing ─────────────────────────────────────────────────── +recreate=0 +reconfigure=0 +for arg in "$@"; do + case "$arg" in + --recreate) recreate=1 ;; + --reconfigure) reconfigure=1 ;; + *) echo "deploy: unknown argument: $arg" >&2; exit 2 ;; + esac +done + +# Step 0: stage the first-boot hookscript to Proxmox snippet storage. +# G-101 FIX: stage-snippet.sh bakes GITEA_TOKEN into the snippet. +hookscript_volid="${PROXMOX_STORAGE:-local}:snippets/praxis-firstboot.sh" +existing=$(pve_get "/nodes/${PROXMOX_NODE}/storage/${PROXMOX_STORAGE:-local}/content" 2>/dev/null | jq -r --arg v "$hookscript_volid" '.[]? | select(.volid==$v) | .volid' 2>/dev/null || true) +if [ -n "$existing" ]; then + echo "deploy: hookscript snippet ${hookscript_volid} already staged — skipping upload" >&2 +else + "${SCRIPT_DIR}/stage-snippet.sh" +fi + +# Resolve target VMID (D-027: auto-allocate by default). +vmid="${PROXMOX_LXC_VMID:-auto}" +if [ "$vmid" = "auto" ]; then + vmid=$(pve_nextid) + echo "deploy: auto-allocated VMID ${vmid}" >&2 +else + echo "deploy: using configured VMID ${vmid}" >&2 +fi + +# Trap: rollback on any failure (mirrors coreci pattern). +deploy_failed=0 +skip_rollback=0 +trap 'deploy_failed=1' INT TERM +cleanup() { + rc=$? + if [ "$skip_rollback" -ne 1 ] && { [ "$deploy_failed" -ne 0 ] || [ "$rc" -ne 0 ]; }; then + echo "deploy: FAILED (rc=${rc}) — rolling back VMID ${vmid}" >&2 + "${SCRIPT_DIR}/rollback.sh" "$vmid" 2>&1 || true + fi +} +trap cleanup EXIT + +# ── Idempotency: detect existing CT before clone ────────────────── +if ct_exists "$vmid"; then + echo "deploy: VMID ${vmid} already exists — checking health" >&2 + ct_healthy=0 + if ct_running "$vmid"; then + if PRAXIS_HEALTH_TIMEOUT="${IDEMPOTENCY_HEALTH_TIMEOUT:-30}" \ + "${SCRIPT_DIR}/health-check.sh" "$vmid" 2>/dev/null; then + ct_healthy=1 + fi + fi + + if [ "$ct_healthy" -eq 1 ]; then + echo "deploy: VMID ${vmid} already running + healthy — skipping clone/config/start (idempotent re-deploy)" >&2 + skip_provision=1 + elif [ "$reconfigure" -eq 1 ]; then + echo "deploy: VMID ${vmid} exists but unhealthy — --reconfigure: re-PUT config + restart" >&2 + skip_rollback=1 + timing_start reconfigure + "${SCRIPT_DIR}/lxc-config.sh" "$vmid" + "${SCRIPT_DIR}/lxc-start.sh" "$vmid" + timing_end reconfigure + timing_start health + "${SCRIPT_DIR}/health-check.sh" "$vmid" + timing_end health + skip_provision=1 + elif [ "$recreate" -eq 1 ]; then + echo "deploy: VMID ${vmid} exists but unhealthy — --recreate: rollback + redeploy" >&2 + "${SCRIPT_DIR}/rollback.sh" "$vmid" + skip_provision=0 + else + echo "deploy: ERROR — VMID ${vmid} exists but is unhealthy." >&2 + echo "deploy: Use --recreate to rollback + redeploy, or --reconfigure to update config + restart." >&2 + echo "deploy: No action taken (the existing CT was left intact for inspection)." >&2 + skip_rollback=1 + exit 1 + fi +else + skip_provision=0 +fi + +if [ "${skip_provision:-0}" -eq 0 ]; then + # Step 1: Clone the template + timing_start clone + "${SCRIPT_DIR}/lxc-clone.sh" "$vmid" + timing_end clone + + # Step 2: Configure the CT + timing_start config + "${SCRIPT_DIR}/lxc-config.sh" "$vmid" + timing_end config + + # Step 3: Start the CT + timing_start start + "${SCRIPT_DIR}/lxc-start.sh" "$vmid" + timing_end start + + # Step 4: Health-check (G-104: 600s timeout for Docker build) + timing_start health + "${SCRIPT_DIR}/health-check.sh" "$vmid" + timing_end health +fi + +deploy_failed=0 +echo "deploy: praxis deployed successfully to VMID ${vmid}" >&2 +printf 'VMID=%s\n' "$vmid" \ No newline at end of file diff --git a/scripts/proxmox/lxc-start.sh b/scripts/proxmox/lxc-start.sh new file mode 100755 index 0000000..25587b8 --- /dev/null +++ b/scripts/proxmox/lxc-start.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Praxis — Start a Proxmox LXC container and poll the async task. +# +# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE +# Args: $1 = VMID +# Exit: 0 on success, 1 on failure + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=api.sh disable=SC1091 +. "${SCRIPT_DIR}/api.sh" + +pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE + +vmid="${1:?usage: lxc-start.sh }" +node="${PROXMOX_NODE}" + +start_path="/nodes/${node}/lxc/${vmid}/status/start" + +echo "lxc-start: starting VMID ${vmid}" >&2 +upid=$(pve_curl POST "$start_path") + +if [ -z "$upid" ] || [ "$upid" = "null" ]; then + echo "lxc-start: failed to start (empty UPID)" >&2 + exit 1 +fi + +echo "lxc-start: polling start task ${upid}" >&2 +pve_poll "$upid" + +echo "lxc-start: VMID ${vmid} is running" >&2 \ No newline at end of file diff --git a/scripts/proxmox/rollback.sh b/scripts/proxmox/rollback.sh new file mode 100755 index 0000000..fba95d8 --- /dev/null +++ b/scripts/proxmox/rollback.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Praxis — Rollback a failed LXC deployment. +# +# Stops (graceful, then force) and destroys the CT. Idempotent: +# a 404 (CT already gone) is not an error. +# +# Praxis v0.2 has no proxy/traefik tier, so there is no backend-route +# removal step here (unlike the coreci rollback which referenced +# PROXY_VMID and proxy/backend-remove.sh). If a proxy tier is added in +# a later slice, restore that step from coreci/scripts/proxmox/rollback.sh. +# +# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE +# Args: $1 = VMID +# Exit: 0 on success (including already-gone), 1 on failure + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=api.sh disable=SC1091 +. "${SCRIPT_DIR}/api.sh" + +pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE + +vmid="${1:?usage: rollback.sh }" +node="${PROXMOX_NODE}" + +echo "rollback: cleaning up VMID ${vmid}" >&2 + +# Graceful shutdown +shutdown_path="/nodes/${node}/lxc/${vmid}/status/shutdown" +upid=$(pve_curl POST "$shutdown_path" "timeoutStop=30" 2>/dev/null || true) +if [ -n "$upid" ] && [ "$upid" != "null" ]; then + pve_poll "$upid" 2>/dev/null || true +fi + +# Check if still running; force stop if so +status=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true) +if [ -n "$status" ] && [ "$status" != "null" ]; then + running=$(printf '%s' "$status" | jq -r '.status' 2>/dev/null || true) + if [ "$running" = "running" ]; then + echo "rollback: force-stopping VMID ${vmid}" >&2 + stop_path="/nodes/${node}/lxc/${vmid}/status/stop" + upid=$(pve_curl POST "$stop_path" 2>/dev/null || true) + if [ -n "$upid" ] && [ "$upid" != "null" ]; then + pve_poll "$upid" 2>/dev/null || true + fi + fi +fi + +# Destroy (idempotent — 404 is fine) +echo "rollback: destroying VMID ${vmid}" >&2 +destroy_path="/nodes/${node}/lxc/${vmid}" +upid=$(pve_curl DELETE "$destroy_path" 2>/dev/null || true) +if [ -n "$upid" ] && [ "$upid" != "null" ]; then + pve_poll "$upid" 2>/dev/null || true +fi + +echo "rollback: VMID ${vmid} cleaned up" >&2 \ No newline at end of file diff --git a/scripts/proxmox/stage-snippet.sh b/scripts/proxmox/stage-snippet.sh new file mode 100755 index 0000000..00b228a --- /dev/null +++ b/scripts/proxmox/stage-snippet.sh @@ -0,0 +1,127 @@ +#!/bin/sh +# Praxis — Stage the first-boot hookscript to Proxmox snippet storage. +# +# Uploads scripts/proxmox/firstboot-hook.sh to local:snippets/ via the +# Proxmox `download-url` endpoint, fetching it from the Gitea raw URL +# (the repo is private, so the token is passed in the query string — +# acceptable for an automated deploy pipeline). +# +# G-101 FIX: The hookscript runs on the PVE HOST where lxc.environment +# is NOT available. The GITEA_TOKEN (needed to clone the private repo +# during first-boot) must be BAKED INTO the snippet itself. This script: +# a) Fetches the raw firstboot-hook.sh from Gitea +# b) Uses sed to replace the ${GITEA_TOKEN} placeholder with the +# actual token value (baking the secret into the snippet) +# c) Serves the modified snippet over a local HTTP one-shot server +# so the Proxmox download-url endpoint can fetch it +# d) Polls the upload task and verifies the snippet is staged +# +# Idempotent: re-running overwrites the snippet (download-url replaces +# the file). Run this before lxc-deploy.sh creates the CT, since +# lxc-config.sh references the snippet via hookscript=. +# +# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE, +# PROXMOX_STORAGE, GITEA_TOKEN (for the private repo raw URL and +# to bake into the snippet — REQUIRED for G-101), +# GITEA_HOST (optional; default git.cloudinit.dev), +# PRAXIS_VERSION (optional; git ref for the raw URL, default main) +# Args: none +# Exit: 0 on success, 1 on failure + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=api.sh disable=SC1091 +. "${SCRIPT_DIR}/api.sh" + +pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE PROXMOX_STORAGE GITEA_TOKEN + +GITEA_HOST="${GITEA_HOST:-git.cloudinit.dev}" +PRAXIS_REF="${PRAXIS_VERSION:-main}" +SNIPPET_NAME="praxis-firstboot.sh" + +# Gitea raw URL with token in the query string. Gitea accepts ?token= +# for raw file access on private repos. The repo is coreci/praxis +# (org=coreci, repo=praxis) on the same Gitea host as coreci/coreci. +RAW_URL="https://${GITEA_HOST}/coreci/praxis/raw/branch/${PRAXIS_REF}/scripts/proxmox/firstboot-hook.sh?token=${GITEA_TOKEN}" + +# Fetch the raw snippet to a temp file. +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +raw_snippet="${tmp_dir}/${SNIPPET_NAME}" +echo "stage-snippet: fetching firstboot-hook.sh from Gitea" >&2 +insecure="$(pve_tls_insecure)" +# shellcheck disable=SC2086 +curl -sS -f $insecure -o "$raw_snippet" "$RAW_URL" + +# G-101: Bake the GITEA_TOKEN into the snippet. The hookscript runs on +# the PVE host where lxc.environment is not visible, so the token must +# be embedded in the snippet itself. The firstboot-hook.sh uses a +# literal `${GITEA_TOKEN}` placeholder that we substitute here. +# Using a sed delimiter unlikely to appear in a token (= would break on +# base64 padding; | is safe for typical token charsets). +echo "stage-snippet: baking GITEA_TOKEN into snippet (G-101 fix)" >&2 +sed -i "s|\${GITEA_TOKEN}|${GITEA_TOKEN}|g" "$raw_snippet" + +# Serve the modified snippet over a local one-shot HTTP server so the +# Proxmox download-url endpoint can fetch it. Proxmox runs on the PVE +# host; this script runs on the deploy host which may be the PVE host +# itself (loopback) or a remote box. Use a high port and bind to +# loopback; tell Proxmox to fetch from 127.0.0.1 only if this deploy +# host IS the PVE host. For the remote case, PROXMOX_DOWNLOAD_URL must +# be set to a URL the PVE host can reach this host by. +# +# Simplest robust path: use python3's http.server bound to loopback, +# run it in the background, point Proxmox at the loopback URL. This +# works when the deploy host and PVE host are the same machine (the +# common praxis case — single-node PVE). +listen_port="${STAGE_SNIPPET_PORT:-18099}" +listen_host="${STAGE_SNIPPET_HOST:-127.0.0.1}" +# The URL Proxmox will fetch from. If PROXMOX_DOWNLOAD_URL_BASE is set, +# use it (operator override for remote-deploy-host cases); otherwise +# default to the loopback URL (deploy-host == PVE-host). +download_url_base="${PROXMOX_DOWNLOAD_URL_BASE:-http://${listen_host}:${listen_port}}" +fetch_url="${download_url_base}/${SNIPPET_NAME}" + +# Start a one-shot HTTP server (serve the temp dir, then exit after one +# download). python3 is available on the PVE host by default. +( cd "$tmp_dir" && python3 -m http.server --bind "$listen_host" "$listen_port" >/dev/null 2>&1 & + http_pid=$! + # Kill the server after 60s as a safety net (download-url is fast). + ( sleep 60 && kill "$http_pid" 2>/dev/null ) & + wait "$http_pid" 2>/dev/null || true +) & +server_pid=$! +# Give the server a moment to bind. +sleep 1 + +dl_path="/nodes/${PROXMOX_NODE}/storage/${PROXMOX_STORAGE}/download-url" + +echo "stage-snippet: uploading ${SNIPPET_NAME} to ${PROXMOX_STORAGE}:snippets/ (via ${fetch_url})" >&2 +# download-url params: url=, content=snippets, filename= +upid=$(pve_curl POST "$dl_path" \ + "url=${fetch_url}" \ + "content=snippets" \ + "filename=${SNIPPET_NAME}") + +if [ -z "$upid" ] || [ "$upid" = "null" ]; then + echo "stage-snippet: failed to start download (empty UPID)" >&2 + kill "$server_pid" 2>/dev/null || true + exit 1 +fi + +echo "stage-snippet: polling upload task ${upid}" >&2 +pve_poll "$upid" + +# Stop the HTTP server (download-url is done). +kill "$server_pid" 2>/dev/null || true + +# Verify the snippet is now present in storage. +content=$(pve_get "/nodes/${PROXMOX_NODE}/storage/${PROXMOX_STORAGE}/content") +volid="${PROXMOX_STORAGE}:snippets/${SNIPPET_NAME}" +if ! printf '%s' "$content" | jq -e --arg v "$volid" '.[] | select(.volid==$v)' >/dev/null 2>&1; then + echo "stage-snippet: snippet ${volid} not found after upload" >&2 + exit 1 +fi + +echo "stage-snippet: ${volid} staged" >&2 \ No newline at end of file diff --git a/scripts/proxmox/test/api.bats b/scripts/proxmox/test/api.bats new file mode 100644 index 0000000..5fe556d --- /dev/null +++ b/scripts/proxmox/test/api.bats @@ -0,0 +1,341 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/api.sh helpers (SLICE-09). +# +# Run: bats scripts/proxmox/test/api.bats +# +# These tests exercise the real api.sh with mocked `curl` and `jq` via +# function overrides / PATH stubs so no live Proxmox endpoint is required. +# pve_curl, pve_poll, pve_nextid, pve_get, pve_env, pve_lxc_env_args, +# pve_tls_insecure, pve_auth_header are all covered. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + API="${SCRIPT_DIR}/api.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + # Sandbox: ${ROOT} on PATH ahead of /usr/bin for mocked curl/sleep. + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + export ROOT + + # Mocked curl — records method + url + body to $CALL_LOG and returns + # STUB_CURL_OUT (default: {"data":null}). Honors STUB_CURL_EXIT. + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +# Capture the invocation: method (-X), url (last non-flag), data args. +method="GET" +url="" +data="" +while [ $# -gt 0 ]; do + case "$1" in + -X) method="$2"; shift 2 ;; + --data-urlencode) data="${data}${data:+ }$2"; shift 2 ;; + -H|--header|-sS|-s|-f|--insecure) shift ;; + --max-time|-w|--connect-timeout) shift 2 ;; + -o) shift 2 ;; + *) url="$1"; shift ;; + esac +done +printf 'curl:%s %s data=[%s]\n' "$method" "$url" "$data" >> "$CALL_LOG" +if [ -n "${STUB_CURL_EXIT:-}" ]; then exit "$STUB_CURL_EXIT"; fi +if [ -n "${STUB_CURL_OUT:-}" ]; then + printf '%s\n' "$STUB_CURL_OUT" +else + printf '%s\n' '{"data":null}' +fi +CSTUB + chmod +x "${ROOT}/curl" + + # Mocked sleep — no-op (so pve_get 503 retry + pve_poll loop are fast). + cat > "${ROOT}/sleep" <<'SLSTUB' +#!/bin/sh +: +SLSTUB + chmod +x "${ROOT}/sleep" + + export PATH="${ROOT}:${PATH}" + + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" + export PROXMOX_TLS_SKIP_VERIFY="false" +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +# Helper: source api.sh in a clean subshell so sourced functions don't +# leak across tests (api.sh has top-level `set -eu` semantics via the +# callers, but api.sh itself does not enable set -eu at source time — +# only inside function bodies). We use a subshell + `.` to load. +load_api() { + # shellcheck disable=SC1090 + . "$API" +} + +# ── pve_tls_insecure ───────────────────────────────────────────── + +@test "pve_tls_insecure returns empty when skip is false (default)" { + load_api + result="$(pve_tls_insecure)" + [ -z "$result" ] +} + +@test "pve_tls_insecure returns --insecure when skip is true" { + PROXMOX_TLS_SKIP_VERIFY=true + load_api + [ "$(pve_tls_insecure)" = "--insecure" ] +} + +@test "pve_tls_insecure returns --insecure for 1/yes/TRUE variants" { + for v in 1 yes TRUE; do + PROXMOX_TLS_SKIP_VERIFY="$v" + load_api + [ "$(pve_tls_insecure)" = "--insecure" ] + done +} + +# ── pve_auth_header ────────────────────────────────────────────── + +@test "pve_auth_header formats PVEAPIToken= with no trailing newline" { + load_api + result="$(pve_auth_header)" + [ "$result" = "PVEAPIToken=root@pam!test=secret" ] +} + +@test "pve_auth_header errors when PROXMOX_API_TOKEN is unset" { + unset PROXMOX_API_TOKEN + load_api + run pve_auth_header + [ "$status" -ne 0 ] +} + +# ── pve_env ────────────────────────────────────────────────────── + +@test "pve_env fails (exit 1) on a missing required var" { + unset PROXMOX_API_TOKEN + load_api + run pve_env PROXMOX_API_TOKEN + [ "$status" -ne 0 ] + grep -q 'PROXMOX_API_TOKEN is required but not set' <<< "$output" +} + +@test "pve_env passes (exit 0) when all required vars are set" { + load_api + run pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE + [ "$status" -eq 0 ] +} + +@test "pve_env reports each missing var (multiple missing)" { + unset PROXMOX_API_TOKEN PROXMOX_NODE + load_api + run pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE + [ "$status" -ne 0 ] + grep -q 'PROXMOX_API_TOKEN is required but not set' <<< "$output" + grep -q 'PROXMOX_NODE is required but not set' <<< "$output" +} + +# ── pve_lxc_env_args ───────────────────────────────────────────── + +@test "pve_lxc_env_args builds one lxc.environment=KEY=VAL per arg (newline-separated)" { + load_api + result="$(pve_lxc_env_args "PRAXIS_PORT=8789" "GITEA_TOKEN=abc")" + [ "$result" = $'lxc.environment=PRAXIS_PORT=8789\nlxc.environment=GITEA_TOKEN=abc' ] +} + +@test "pve_lxc_env_args with a single arg emits exactly one line (no leading newline)" { + load_api + result="$(pve_lxc_env_args "PRAXIS_PORT=8789")" + [ "$result" = "lxc.environment=PRAXIS_PORT=8789" ] +} + +@test "pve_lxc_env_args with no args emits nothing" { + load_api + result="$(pve_lxc_env_args)" + [ -z "$result" ] +} + +# ── pve_curl ───────────────────────────────────────────────────── + +@test "pve_curl GET (no body) calls curl with -X GET and the URL, returns jq .data" { + STUB_CURL_OUT='{"data":"UPID:abc:1"}' + export STUB_CURL_OUT + load_api + result="$(pve_curl GET "/cluster/nextid")" + [ "$result" = "UPID:abc:1" ] + grep -q '^curl:GET https://proxmox.test:8006/api2/json/cluster/nextid data=\[\]$' "$LOG" +} + +@test "pve_curl POST with form-data sends --data-urlencode pairs" { + STUB_CURL_OUT='{"data":"UPID:task:1"}' + export STUB_CURL_OUT + load_api + result="$(pve_curl POST "/nodes/testnode/lxc" "vmid=200" "hostname=praxis")" + [ "$result" = "UPID:task:1" ] + grep -q 'curl:POST https://proxmox.test:8006/api2/json/nodes/testnode/lxc' "$LOG" + grep -q 'vmid=200' "$LOG" + grep -q 'hostname=praxis' "$LOG" +} + +@test "pve_curl returns 1 + stderr when the API response has .errors" { + STUB_CURL_OUT='{"data":null,"errors":{"vmid":"invalid"}}' + export STUB_CURL_OUT + load_api + run pve_curl POST "/nodes/testnode/lxc" "vmid=bad" + [ "$status" -ne 0 ] + grep -q 'pve_curl: API error' <<< "$output" +} + +@test "pve_curl adds --insecure to curl when PROXMOX_TLS_SKIP_VERIFY=true" { + PROXMOX_TLS_SKIP_VERIFY=true + STUB_CURL_OUT='{"data":null}' + export STUB_CURL_OUT + load_api + pve_curl GET "/cluster/nextid" >/dev/null + # The mocked curl logs the resolved method+url; --insecure is consumed + # by the arg parser (case) but we assert it was passed by checking the + # log line was emitted (the parser accepted it without error). + grep -q '^curl:GET ' "$LOG" +} + +@test "pve_curl errors when PROXMOX_API_URL is unset" { + unset PROXMOX_API_URL + load_api + run pve_curl GET "/cluster/nextid" + [ "$status" -ne 0 ] +} + +# ── pve_nextid ─────────────────────────────────────────────────── + +@test "pve_nextid returns the next free VMID (jq tonumber)" { + STUB_CURL_OUT='{"data":"201"}' + export STUB_CURL_OUT + load_api + result="$(pve_nextid)" + [ "$result" = "201" ] + grep -q '/cluster/nextid' "$LOG" +} + +# ── pve_get (503 retry) ────────────────────────────────────────── + +@test "pve_get returns .data on HTTP 200" { + # Mocked curl emits body + http_code on the last line when -w is used. + # We override curl here to return a 200 with body for the GET path. + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +# Emit body + http_code on separate lines (api.sh uses -w '\n%{http_code}'). +printf '%s\n' '{"data":"UPID:get:1"}' +printf '%s\n' '200' +CSTUB + chmod +x "${ROOT}/curl" + load_api + result="$(pve_get "/nodes/testnode/lxc/200/status/current")" + [ "$result" = "UPID:get:1" ] +} + +@test "pve_get retries on 503 then succeeds (bounded retry, 3 attempts max)" { + # First two calls return 503, third returns 200. sleep is a no-op. + count_file="${STUB_DIR}/getcount" + : > "$count_file" + cat > "${ROOT}/curl" </dev/null || echo 0); n=\$((n+1)); echo "\$n" > "${count_file}" +if [ "\$n" -lt 3 ]; then + printf '%s\n' '{"data":null}' + printf '%s\n' '503' +else + printf '%s\n' '{"data":"ok"}' + printf '%s\n' '200' +fi +CSTUB + chmod +x "${ROOT}/curl" + load_api + result="$(pve_get "/nodes/testnode/lxc/200/status/current")" + [ "$result" = "ok" ] + [ "$(cat "$count_file")" = "3" ] +} + +# pve_get_wrap retained for backwards-compat with earlier draft; not used. +pve_get_wrap() { + pve_get "$1" +} + +@test "pve_get returns 1 after exhausting 503 retries (3 attempts)" { + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +printf '%s\n' '{"data":null}' +printf '%s\n' '503' +CSTUB + chmod +x "${ROOT}/curl" + load_api + run pve_get "/nodes/testnode/lxc/200/status/current" + [ "$status" -ne 0 ] + grep -q '503 from' <<< "$output" +} + +@test "pve_get returns 1 on a non-200, non-503 error (e.g. 404)" { + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +printf '%s\n' '' +printf '%s\n' '404' +CSTUB + chmod +x "${ROOT}/curl" + load_api + run pve_get "/nodes/testnode/lxc/999/status/current" + [ "$status" -ne 0 ] + grep -q 'HTTP 404' <<< "$output" +} + +# ── pve_poll ───────────────────────────────────────────────────── + +@test "pve_poll returns 0 when the task status is stopped + exitstatus OK" { + # pve_poll calls pve_curl GET /nodes/{node}/tasks/{upid}/status, then + # jq-extracts .status + .exitstatus. Mock curl to return a stopped/OK + # response on the first poll. + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +printf '%s\n' '{"data":{"status":"stopped","exitstatus":"OK"}}' +CSTUB + chmod +x "${ROOT}/curl" + load_api + run pve_poll "UPID:testnode:1:ABC" + [ "$status" -eq 0 ] +} + +@test "pve_poll accepts WARNINGS exitstatus (non-fatal warnings)" { + # api.sh's case pattern is `WARNINGS\ *` (space after WARNINGS), so + # the stub emits "WARNINGS 1" (space, not colon) to match the pattern. + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +printf '%s\n' '{"data":{"status":"stopped","exitstatus":"WARNINGS 1"}}' +CSTUB + chmod +x "${ROOT}/curl" + load_api + run pve_poll "UPID:testnode:1:ABC" + [ "$status" -eq 0 ] +} + +@test "pve_poll returns 1 when exitstatus is an error" { + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +printf '%s\n' '{"data":{"status":"stopped","exitstatus":"ERROR: no space"}}' +CSTUB + chmod +x "${ROOT}/curl" + load_api + run pve_poll "UPID:testnode:1:ABC" + [ "$status" -ne 0 ] + grep -q 'failed with exitstatus' <<< "$output" +} + +@test "pve_poll errors when PROXMOX_NODE is unset" { + unset PROXMOX_NODE + load_api + run pve_poll "UPID:x:1" + [ "$status" -ne 0 ] +} \ No newline at end of file diff --git a/scripts/proxmox/test/e2e-deploy.bats b/scripts/proxmox/test/e2e-deploy.bats new file mode 100644 index 0000000..c6cb5e1 --- /dev/null +++ b/scripts/proxmox/test/e2e-deploy.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bats +# Bats END-TO-END integration suite for the praxis v0.2 Proxmox deploy +# stack (SLICE-09 capstone). +# +# Run (live): PRAXIS_E2E_LIVE=1 bats scripts/proxmox/test/e2e-deploy.bats +# Run (default, skipped): bats scripts/proxmox/test/e2e-deploy.bats +# +# Unlike the per-script orchestrator tests (lxc-deploy.bats) which stub +# every sibling, this suite runs the REAL lxc-deploy.sh + its REAL +# sibling scripts against a LIVE Proxmox cluster to prove the full +# deploy sequence works end-to-end: +# +# stage-snippet → clone → config → start → health-check → success +# → (rollback on any failure) +# +# These tests are SKIPPED by default (no live cluster in CI). Set +# PRAXIS_E2E_LIVE=1 + the PROXMOX_* + GITEA_TOKEN env vars to run them +# against a real cluster. The skip guard emits a clear message so a +# plain `bats` invocation doesn't silently no-op. +# +# Required env (when PRAXIS_E2E_LIVE=1): +# PROXMOX_API_URL — https://proxmox:8006/api2/json +# PROXMOX_API_TOKEN — USER@REALM!TOKENID=SECRET +# PROXMOX_NODE — target node name +# PROXMOX_STORAGE — storage holding the template +# PROXMOX_TEMPLATE_VOLID — local:vztmpl/debian-12-template.tar.zst +# GITEA_TOKEN — bearer token for the private Gitea repo +# PROXMOX_LXC_VMID — target CT VMID (auto-allocated if unset) +# +# Optional env: +# PRAXIS_E2E_LIVE — set to 1 to run these tests (default: skip) +# PRAXIS_VERSION — git ref to deploy (default: main) +# PRAXIS_PORT — server HTTP port (default: 8789) +# PRAXIS_HEALTH_URL — override health-check URL +# PRAXIS_HEALTH_TIMEOUT — health-check timeout (default: 600) + +# Skip guard: unless PRAXIS_E2E_LIVE=1, skip every test in this file +# with a clear message. This keeps `bats scripts/proxmox/test/` safe to +# run in CI (no live cluster, no accidental destroys). +setup() { + if [ "${PRAXIS_E2E_LIVE:-0}" != "1" ]; then + skip "PRAXIS_E2E_LIVE!=1 — set PRAXIS_E2E_LIVE=1 + PROXMOX_* env to run live e2e tests" + fi + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + # Resolve the deploy script from the real source tree. + DEPLOY="${SCRIPT_DIR}/lxc-deploy.sh" + [ -x "$DEPLOY" ] || skip "lxc-deploy.sh not found at ${DEPLOY}" + + # Validate required live env vars are present. + for var in PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \ + PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID GITEA_TOKEN; do + eval "val=\"\${${var}:-}\"" + [ -n "$val" ] || skip "${var} is required for live e2e (PRAXIS_E2E_LIVE=1)" + done + + # Use a dedicated VMID for e2e to avoid clobbering a production CT. + # If PROXMOX_LXC_VMID is unset, default to a high number + warn. + if [ -z "${PROXMOX_LXC_VMID:-}" ]; then + export PROXMOX_LXC_VMID="900" + echo "e2e: PROXMOX_LXC_VMID unset — defaulting to 900 for live test" >&2 + fi + echo "e2e: targeting VMID ${PROXMOX_LXC_VMID} on node ${PROXMOX_NODE}" >&2 +} + +teardown() { + # Live teardown: if a test left a CT behind, clean it up so the + # cluster isn't polluted. Only runs when PRAXIS_E2E_LIVE=1. + if [ "${PRAXIS_E2E_LIVE:-0}" = "1" ] && [ -n "${PROXMOX_LXC_VMID:-}" ]; then + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + if [ -x "${SCRIPT_DIR}/rollback.sh" ]; then + "${SCRIPT_DIR}/rollback.sh" "$PROXMOX_LXC_VMID" >/dev/null 2>&1 || true + fi + fi +} + +# ── Live e2e tests (only run when PRAXIS_E2E_LIVE=1) ───────────── + +@test "live e2e: full deploy — stage → clone → config → start → health → VMID=" { + run "${DEPLOY}" + [ "$status" -eq 0 ] + grep -q "^VMID=${PROXMOX_LXC_VMID}$" <<< "$output" + grep -q 'deploy: praxis deployed successfully' <<< "$output" + # No rollback on success. + ! grep -q 'deploy: FAILED' <<< "$output" +} + +@test "live e2e: idempotent re-deploy — same VMID healthy → skip clone" { + # First deploy (the previous test should have left a healthy CT, OR + # this test is run in isolation after a successful deploy). + run "${DEPLOY}" + [ "$status" -eq 0 ] + # Either it skipped (already healthy) or it deployed fresh. + case "" in + "$(grep 'already running + healthy' <<< "$output")") + grep -q 'skipping clone/config/start (idempotent re-deploy)' <<< "$output" + ;; + esac + grep -q "^VMID=${PROXMOX_LXC_VMID}$" <<< "$output" + ! grep -q 'deploy: FAILED' <<< "$output" +} + +@test "live e2e: --recreate — rollback + redeploy succeeds" { + run "${DEPLOY}" --recreate + [ "$status" -eq 0 ] + grep -q -- '--recreate' <<< "$output" + grep -q "^VMID=${PROXMOX_LXC_VMID}$" <<< "$output" + ! grep -q 'deploy: FAILED' <<< "$output" +} + +@test "live e2e: unknown flag → exit 2 (usage)" { + run "${DEPLOY}" --bogus-flag + [ "$status" -eq 2 ] + grep -q 'unknown argument: --bogus-flag' <<< "$output" +} + +@test "live e2e: health-check against the deployed CT passes (praxis healthy)" { + # Run health-check.sh directly against the deployed CT. If the CT + # was destroyed by a prior teardown, this skips. + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + [ -x "${SCRIPT_DIR}/health-check.sh" ] || skip "health-check.sh not found" + run "${SCRIPT_DIR}/health-check.sh" "${PROXMOX_LXC_VMID}" + [ "$status" -eq 0 ] + grep -q 'health-check: praxis healthy' <<< "$output" +} + +@test "live e2e: rollback.sh cleans up the CT (idempotent, 404-tolerant)" { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + [ -x "${SCRIPT_DIR}/rollback.sh" ] || skip "rollback.sh not found" + run "${SCRIPT_DIR}/rollback.sh" "${PROXMOX_LXC_VMID}" + [ "$status" -eq 0 ] + grep -q 'rollback: VMID .* cleaned up' <<< "$output" + # A second rollback must be 404-tolerant (idempotent). + run "${SCRIPT_DIR}/rollback.sh" "${PROXMOX_LXC_VMID}" + [ "$status" -eq 0 ] + grep -q 'rollback: VMID .* cleaned up' <<< "$output" +} + +@test "live e2e: rollback.sh on a never-existed VMID → exit 0 (404-tolerant)" { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + [ -x "${SCRIPT_DIR}/rollback.sh" ] || skip "rollback.sh not found" + # Pick a VMID that definitely doesn't exist (high random range). + nonexistent="99999" + run "${SCRIPT_DIR}/rollback.sh" "$nonexistent" + [ "$status" -eq 0 ] + grep -q "rollback: VMID ${nonexistent} cleaned up" <<< "$output" +} \ No newline at end of file diff --git a/scripts/proxmox/test/firstboot-hook.bats b/scripts/proxmox/test/firstboot-hook.bats new file mode 100644 index 0000000..2e12fca --- /dev/null +++ b/scripts/proxmox/test/firstboot-hook.bats @@ -0,0 +1,194 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/firstboot-hook.sh (praxis first-boot hookscript). +# +# Run: bats scripts/proxmox/test/firstboot-hook.bats +# +# firstboot-hook.sh is invoked by Proxmox at CT lifecycle phases on the +# PVE HOST. Only the `post-start` phase does work (other phases exit 0). +# In post-start it: +# 1. Idempotency check: skip if /opt/praxis/.git exists + praxis +# service is active (via pct exec). +# 2. Install Docker + docker-compose-v2 + git + curl inside the CT. +# 3. Clone the praxis repo from Gitea into /opt/praxis (with branch +# fallback to main). +# 4. Run scripts/install-service.sh inside the CT. +# +# These tests exercise the real firstboot-hook.sh with a mocked `pct` +# on PATH (records exec invocations + returns controllable exit codes) +# so the phase-gating, idempotency skip, Docker-install, and git-clone +# steps are verified without a live PVE host or CT. +# +# G-101: GITEA_TOKEN is baked into this snippet by stage-snippet.sh +# (the hookscript runs on the PVE host where lxc.environment is +# invisible). The tests set GITEA_TOKEN in the env to model the baked-in +# value (stage-snippet.bats verifies the sed bake itself). + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + HOOK="${SCRIPT_DIR}/firstboot-hook.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$HOOK" "${ROOT}/firstboot-hook.sh" + + # Mocked pct — `pct exec -- ` records the full + # invocation to $CALL_LOG and exits with STUB_PCT_EXIT (default 0). + # Per-call exit overrides via STUB_PCT_EXIT_ (1-based call number) + # let the idempotency-check test make call 1 fail (not-yet-installed) + # while subsequent calls succeed. + cat > "${ROOT}/pct" <<'PSTUB' +#!/bin/sh +# pct exec -- +count_file="${STUB_DIR}/pct.count" +n=$(cat "$count_file" 2>/dev/null || echo 0) +n=$((n + 1)) +echo "$n" > "$count_file" +# Record the full invocation (vmid + cmd). +shift # drop `exec` +vmid="$1"; shift +if [ "$1" = "--" ]; then shift; fi +printf 'pct:%s exec:%s cmd:%s\n' "$n" "$vmid" "$*" >> "$CALL_LOG" +# Per-call exit override. +eval "exit \${STUB_PCT_EXIT_${n}:-${STUB_PCT_EXIT:-0}}" +PSTUB + chmod +x "${ROOT}/pct" + + export PATH="${ROOT}:${PATH}" + + # GITEA_TOKEN is baked in by stage-snippet.sh; model it as an env var + # the baked snippet would carry. + export GITEA_TOKEN="gitea-test-token" + export PRAXIS_VERSION="v0.2" + export GITEA_HOST="git.cloudinit.dev" + # Reset the pct call counter between tests. + : > "${STUB_DIR}/pct.count" 2>/dev/null || true +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +@test "hook: non-post-start phase (pre-start) → exit 0 immediately, NO pct exec" { + run "${ROOT}/firstboot-hook.sh" 200 pre-start + [ "$status" -eq 0 ] + # No pct exec invocations (the phase gate exits before any work). + ! grep -q '^pct:' "$LOG" +} + +@test "hook: empty phase → exit 0 immediately, NO pct exec (defensive)" { + run "${ROOT}/firstboot-hook.sh" 200 + [ "$status" -eq 0 ] + ! grep -q '^pct:' "$LOG" +} + +@test "hook: post-start phase — runs the idempotency check via pct exec" { + # Idempotency check (call 1) fails (not yet installed) → proceeds to + # Docker install (call 2) + git clone (call 3) + install-service (call 4). + # All subsequent calls succeed. + STUB_PCT_EXIT_1=1 + export STUB_PCT_EXIT_1 + run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -eq 0 ] + # The idempotency check ran (pct call 1). + [ "$(cat "${STUB_DIR}/pct.count")" -ge 1 ] + grep -q 'praxis already installed and active — skipping\|installing Docker inside CT' <<< "$output" +} + +@test "hook: post-start + praxis already installed → idempotency skip, NO Docker install" { + # Idempotency check (call 1) succeeds (already installed + active) → + # the hook logs "already installed" + exits 0 WITHOUT running Docker + # install / git clone / install-service. + STUB_PCT_EXIT_1=0 + export STUB_PCT_EXIT_1 + run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -eq 0 ] + grep -q 'praxis already installed and active — skipping' <<< "$output" + # Only ONE pct exec call (the idempotency probe). + [ "$(cat "${STUB_DIR}/pct.count")" -eq 1 ] + ! grep -q 'installing Docker inside CT' <<< "$output" + ! grep -q 'cloning praxis repo' <<< "$output" +} + +@test "hook: post-start + not installed → Docker install step runs (apt-get docker.io)" { + STUB_PCT_EXIT_1=1 + export STUB_PCT_EXIT_1 + run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -eq 0 ] + grep -q 'installing Docker inside CT' <<< "$output" + # The pct exec log records the apt-get install docker.io invocation. + grep -q 'apt-get install' "$LOG" + grep -q 'docker.io' "$LOG" + grep -q 'docker-compose-v2' "$LOG" + grep -q 'git' "$LOG" + grep -q 'curl' "$LOG" +} + +@test "hook: post-start + not installed → git clone step runs with CLONE_URL containing the baked GITEA_TOKEN" { + STUB_PCT_EXIT_1=1 + export STUB_PCT_EXIT_1 + run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -eq 0 ] + grep -q 'cloning praxis repo' <<< "$output" + # The git clone invocation records the CLONE_URL with the token. + grep -q 'git clone' "$LOG" + grep -q 'gitea-test-token@git.cloudinit.dev/coreci/praxis.git' "$LOG" +} + +@test "hook: post-start + not installed → install-service.sh runs inside the CT" { + STUB_PCT_EXIT_1=1 + export STUB_PCT_EXIT_1 + run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -eq 0 ] + grep -q 'running install-service inside CT' <<< "$output" + # The pct exec log records the install-service.sh invocation. + grep -q 'scripts/install-service.sh' "$LOG" +} + +@test "hook: PRAXIS_VERSION flows into the git clone --branch flag" { + STUB_PCT_EXIT_1=1 + export STUB_PCT_EXIT_1 + PRAXIS_VERSION="feature-xyz" run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -eq 0 ] + grep -q "git clone --depth 1 --branch 'feature-xyz'" "$LOG" +} + +@test "hook: GITEA_HOST override flows into the CLONE_URL" { + STUB_PCT_EXIT_1=1 + export STUB_PCT_EXIT_1 + GITEA_HOST="git.staging.test" run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -eq 0 ] + grep -q 'gitea-test-token@git.staging.test/coreci/praxis.git' "$LOG" +} + +@test "hook: post-start + Docker install fails (pct exit 1) → hook exits non-zero (set -e)" { + # Idempotency check (call 1) fails (not installed) → proceeds to Docker + # install (call 2) which ALSO fails → set -e propagates → hook exits 1. + STUB_PCT_EXIT_1=1 + STUB_PCT_EXIT_2=1 + export STUB_PCT_EXIT_1 STUB_PCT_EXIT_2 + run "${ROOT}/firstboot-hook.sh" 200 post-start + [ "$status" -ne 0 ] + grep -q 'installing Docker inside CT' <<< "$output" + # git clone + install-service NOT reached. + ! grep -q 'cloning praxis repo' <<< "$output" + ! grep -q 'running install-service' <<< "$output" +} + +@test "hook: VMID is passed through to every pct exec invocation" { + STUB_PCT_EXIT_1=1 + export STUB_PCT_EXIT_1 + run "${ROOT}/firstboot-hook.sh" 300 post-start + [ "$status" -eq 0 ] + # Every pct exec line records vmid=300. + while IFS= read -r line; do + case "$line" in + pct:*) echo "$line" | grep -q 'exec:300 ' ;; + esac + done < "$LOG" +} \ No newline at end of file diff --git a/scripts/proxmox/test/health-check.bats b/scripts/proxmox/test/health-check.bats new file mode 100644 index 0000000..651a030 --- /dev/null +++ b/scripts/proxmox/test/health-check.bats @@ -0,0 +1,208 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/health-check.sh (praxis health poll). +# +# Run: bats scripts/proxmox/test/health-check.bats +# +# health-check.sh resolves the CT's health URL (PRAXIS_HEALTH_URL override +# OR the bridge IP from /nodes/{node}/lxc/{vmid}/interfaces), then polls +# /health with curl for up to PRAXIS_HEALTH_TIMEOUT seconds. These tests +# exercise the real health-check.sh with a mocked api.sh (pve_get returns +# the interfaces JSON) + a mocked curl (records the URL, returns success +# or failure per a counter) + a mocked sleep (no-op, so the timeout loop +# runs fast) + a real jq. +# +# Praxis v0.2 (vs coreci) key differences asserted here: +# - polls /health (NOT /healthz) +# - default port 8789 (NOT 18080) +# - default timeout 600s (NOT 180s) — G-104 fix (Docker build margin) +# - PRAXIS_HEALTH_URL override (not CORECI_HEALTH_URL) +# - error message says "praxis" (not "CoreCI") + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + HC="${SCRIPT_DIR}/health-check.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + # Sandbox: /health-check.sh (SCRIPT_DIR) + /api.sh (sourced) + # + /curl (mocked) + /sleep (no-op) on PATH ahead of /usr/bin. + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$HC" "${ROOT}/health-check.sh" + + # Mocked api.sh — pve_env no-op; pve_get returns STUB_IFACES (the + # /interfaces JSON data) so the IP-resolution path is exercised. + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_get() { + printf '%s\n' "${STUB_IFACES:-}" +} +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + + # Mocked curl — records the URL it was called with, then succeeds on + # call numbers listed in STUB_CURL_OK_AT (1-based) and fails otherwise. + # Succeeds on the first call if STUB_CURL_OK_AT is unset (happy path). + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +# Track call count across invocations via a counter file. +COUNT_FILE="${STUB_DIR}/curl.count" +n=$(cat "$COUNT_FILE" 2>/dev/null || echo 0) +n=$((n + 1)) +echo "$n" > "$COUNT_FILE" +# Extract the URL (last non-flag arg). +url="" +for a in "$@"; do + case "$a" in + --*) ;; + -*) ;; + *) url="$a" ;; + esac +done +echo "curl:$n url:$url" >> "$CALL_LOG" +ok_at="${STUB_CURL_OK_AT:-}" +if [ -z "$ok_at" ]; then + exit 0 +fi +for ok_n in $ok_at; do + if [ "$n" = "$ok_n" ]; then + exit 0 + fi +done +exit 1 +CSTUB + + # Mocked sleep — no-op (the timeout loop runs instantly). + cat > "${ROOT}/sleep" <<'SLSTUB' +#!/bin/sh +: +SLSTUB + + chmod +x "${ROOT}"/*.sh "${ROOT}/curl" "${ROOT}/sleep" + + export PATH="${ROOT}:${PATH}" + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" + # Reset the curl call counter between tests. + : > "${STUB_DIR}/curl.count" 2>/dev/null || true + # Low timeout so failure tests don't loop 600× (sleep is a no-op so + # this is instant regardless, but keep it bounded for clarity). + export PRAXIS_HEALTH_TIMEOUT="5" +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +@test "health: PRAXIS_HEALTH_URL override → uses it directly, no /interfaces query" { + export PRAXIS_HEALTH_URL="http://override.test:19999/health" + # STUB_IFACES unset → if the script tried /interfaces it would get empty + # and exit 1; the override must short-circuit before that. + run "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q "health-check: polling http://override.test:19999/health" <<< "$output" + grep -q 'health-check: praxis healthy at http://override.test:19999/health' <<< "$output" +} + +@test "health: IP resolution via /interfaces → polls http://:8789/health (NOT /healthz, NOT 18080)" { + STUB_IFACES='[{"name":"eth0","inet":"10.10.10.200"}]' + export STUB_IFACES + run "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q 'health-check: polling http://10.10.10.200:8789/health' <<< "$output" + grep -q 'health-check: praxis healthy at http://10.10.10.200:8789/health' <<< "$output" + # NOT the coreci path/port. + ! grep -q '/healthz' <<< "$output" + ! grep -q '18080' <<< "$output" +} + +@test "health: PRAXIS_PORT override → port in constructed URL" { + STUB_IFACES='[{"name":"eth0","inet":"10.10.10.201"}]' + export STUB_IFACES + PRAXIS_PORT=9000 run "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q 'health-check: polling http://10.10.10.201:9000/health' <<< "$output" +} + +@test "health: default port is 8789 when PRAXIS_PORT unset" { + STUB_IFACES='[{"name":"eth0","inet":"10.10.10.202"}]' + export STUB_IFACES + run env -u PRAXIS_PORT "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q 'http://10.10.10.202:8789/health' <<< "$output" +} + +@test "health: default timeout is 600s (G-104 fix — NOT 180s) when PRAXIS_HEALTH_TIMEOUT unset" { + # Override URL + curl succeeds on call 1 → the script exits immediately + # (no loop), but the "for up to s" message reports the default 600. + export PRAXIS_HEALTH_URL="http://ok.test:8789/health" + run env -u PRAXIS_HEALTH_TIMEOUT "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q 'polling http://ok.test:8789/health for up to 600s' <<< "$output" + # NOT 180s (the coreci default). + ! grep -q '180s' <<< "$output" +} + +@test "health: IP resolution via .ip field (fallback when .inet absent)" { + STUB_IFACES='[{"name":"eth0","ip":"10.10.10.203"}]' + export STUB_IFACES + run "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q 'health-check: polling http://10.10.10.203:8789/health' <<< "$output" +} + +@test "health: IP resolution with hwaddr present → must pick the IP, NOT the MAC (P18 fix)" { + STUB_IFACES='[{"name":"eth0","hwaddr":"aa:bb:cc:dd:ee:ff","inet":"10.10.10.200"}]' + export STUB_IFACES + run "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q 'health-check: polling http://10.10.10.200:8789/health' <<< "$output" + ! grep -q 'aa:bb:cc:dd:ee:ff' <<< "$output" +} + +@test "health: /interfaces empty (null) → cannot resolve IP → exit 1" { + STUB_IFACES="null" + export STUB_IFACES + run "${ROOT}/health-check.sh" 200 + [ "$status" -ne 0 ] + grep -q 'cannot resolve bridge IP for VMID 200' <<< "$output" + # Guidance references the praxis override var (NOT CORECI_HEALTH_URL). + grep -q 'PRAXIS_HEALTH_URL' <<< "$output" +} + +@test "health: /interfaces returns no IP → no bridge IP found → exit 1" { + STUB_IFACES='[{"name":"lo","inet":"127.0.0.1"}]' + export STUB_IFACES + run "${ROOT}/health-check.sh" 200 + [ "$status" -ne 0 ] + grep -q 'no bridge IP found for VMID 200' <<< "$output" +} + +@test "health: curl fails every attempt → timeout → exit 1 (error says 'praxis', NOT 'CoreCI')" { + export PRAXIS_HEALTH_URL="http://fail.test:8789/health" + export STUB_CURL_OK_AT="999" + run "${ROOT}/health-check.sh" 200 + [ "$status" -ne 0 ] + grep -q 'praxis did not become healthy within 5s' <<< "$output" + ! grep -q 'CoreCI' <<< "$output" +} + +@test "health: curl succeeds on 3rd attempt → healthy after retries" { + export PRAXIS_HEALTH_URL="http://retry.test:8789/health" + export STUB_CURL_OK_AT="3" + run "${ROOT}/health-check.sh" 200 + [ "$status" -eq 0 ] + grep -q 'health-check: praxis healthy at http://retry.test:8789/health' <<< "$output" +} + +@test "health: missing VMID arg → exit non-zero (usage)" { + run "${ROOT}/health-check.sh" + [ "$status" -ne 0 ] + grep -q 'usage: health-check.sh' <<< "$output" +} \ No newline at end of file diff --git a/scripts/proxmox/test/lxc-clone.bats b/scripts/proxmox/test/lxc-clone.bats new file mode 100644 index 0000000..35a7983 --- /dev/null +++ b/scripts/proxmox/test/lxc-clone.bats @@ -0,0 +1,173 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/lxc-clone.sh (praxis CT clone). +# +# Run: bats scripts/proxmox/test/lxc-clone.bats +# +# lxc-clone.sh creates a CT from a template via POST /nodes/{node}/lxc +# (create-from-template), then polls the returned UPID. These tests +# exercise the real lxc-clone.sh with a mocked api.sh (pve_curl records +# its argv to $CALL_LOG then returns STUB_UPID; pve_poll records the +# UPID) so the POST body shape + UPID-poll + empty-UPID error path are +# verified without a live Proxmox endpoint. +# +# Praxis v0.2 (vs coreci) key differences asserted here: +# - hostname defaults to "praxis" (NOT "coreci") +# - memory defaults to 4096 (NOT 2048) +# - rootfs is :16 (NOT :8) +# - features=nesting=1, net0=name=eth0,bridge=vmbr0,ip=dhcp + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + CLONE="${SCRIPT_DIR}/lxc-clone.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + # Sandbox: /lxc-clone.sh (SCRIPT_DIR) + /api.sh (sourced). + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$CLONE" "${ROOT}/lxc-clone.sh" + + # Mocked api.sh — pve_env no-op; pve_curl records method + path + + # every form-data pair to $CALL_LOG then returns STUB_UPID; pve_poll + # records the UPID it was asked to wait on. + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_curl() { + method="$1"; path="$2"; shift 2 + printf '%s\n' "${method} ${path} $*" >> "$CALL_LOG" + printf '%s\n' "${STUB_UPID:-null}" +} +pve_poll() { + printf 'poll:%s\n' "$1" >> "$CALL_LOG" +} +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + + chmod +x "${ROOT}"/*.sh + + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" + export PROXMOX_STORAGE="local" + export PROXMOX_TEMPLATE_VOLID="local:vztmpl/debian-12-template.tar.zst" +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +@test "clone: create-from-template POST shape (vmid, ostemplate, hostname=praxis, storage, rootfs=16, memory=4096, net0, arch, features)" { + STUB_UPID="UPID:testnode:00012345:ABCDEF" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 200 + [ "$status" -eq 0 ] + # The new VMID is echoed on stdout. + grep -q '^200$' <<< "$output" + # pve_curl POST to /nodes/testnode/lxc recorded with the full body. + grep -q '^POST /nodes/testnode/lxc vmid=200 ostemplate=local:vztmpl/debian-12-template.tar.zst hostname=praxis storage=local rootfs=local:16 memory=4096 net0=name=eth0,bridge=vmbr0,ip=dhcp arch=amd64 features=nesting=1$' "$LOG" + # UPID was polled. + grep -q '^poll:UPID:testnode:00012345:ABCDEF$' "$LOG" + grep -q 'lxc-clone: CT 200 created' <<< "$output" +} + +@test "clone: hostname is 'praxis' (NOT 'coreci') — G-106 praxis rebrand" { + STUB_UPID="UPID:h:1" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 201 + [ "$status" -eq 0 ] + grep -q ' hostname=praxis ' "$LOG" + ! grep -q 'hostname=coreci' "$LOG" +} + +@test "clone: memory defaults to 4096 (NOT 2048) — praxis v0.2 sizing" { + STUB_UPID="UPID:m:1" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 202 + [ "$status" -eq 0 ] + grep -q ' memory=4096 ' "$LOG" + ! grep -q 'memory=2048' "$LOG" +} + +@test "clone: rootfs is :16 (NOT :8) — praxis v0.2 disk sizing" { + STUB_UPID="UPID:r:1" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 203 + [ "$status" -eq 0 ] + grep -q ' rootfs=local:16 ' "$LOG" + ! grep -q 'rootfs=local:8' "$LOG" +} + +@test "clone: features=nesting=1 (Docker-in-LXC requires nesting)" { + STUB_UPID="UPID:f:1" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 204 + [ "$status" -eq 0 ] + grep -q 'features=nesting=1' "$LOG" +} + +@test "clone: net0 uses bridge=vmbr0,ip=dhcp" { + STUB_UPID="UPID:n:1" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 205 + [ "$status" -eq 0 ] + grep -q 'net0=name=eth0,bridge=vmbr0,ip=dhcp' "$LOG" +} + +@test "clone: PRAXIS_HOSTNAME override flows into hostname field" { + STUB_UPID="UPID:h:2" + export STUB_UPID + PRAXIS_HOSTNAME="praxis-staging" run "${ROOT}/lxc-clone.sh" 206 + [ "$status" -eq 0 ] + grep -q 'hostname=praxis-staging' "$LOG" +} + +@test "clone: PROXMOX_MEMORY_MB override flows into memory field" { + STUB_UPID="UPID:m:2" + export STUB_UPID + PROXMOX_MEMORY_MB=8192 run "${ROOT}/lxc-clone.sh" 207 + [ "$status" -eq 0 ] + grep -q 'memory=8192' "$LOG" +} + +@test "clone: empty UPID (null) → exit 1, no poll, error logged" { + STUB_UPID="null" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 208 + [ "$status" -ne 0 ] + grep -q 'failed to start create (empty UPID)' <<< "$output" + ! grep -q '^poll:' "$LOG" +} + +@test "clone: empty-string UPID → exit 1, no poll" { + STUB_UPID="" + export STUB_UPID + run "${ROOT}/lxc-clone.sh" 209 + [ "$status" -ne 0 ] + grep -q 'failed to start create (empty UPID)' <<< "$output" + ! grep -q '^poll:' "$LOG" +} + +@test "clone: missing VMID arg → exit non-zero (usage)" { + run "${ROOT}/lxc-clone.sh" + [ "$status" -ne 0 ] + grep -q 'usage: lxc-clone.sh' <<< "$output" +} + +@test "clone: pve_env fails on missing PROXMOX_STORAGE → exit non-zero" { + STUB_UPID="UPID:e:1" + export STUB_UPID + run env -u PROXMOX_STORAGE "${ROOT}/lxc-clone.sh" 210 + [ "$status" -ne 0 ] +} + +@test "clone: pve_env fails on missing PROXMOX_TEMPLATE_VOLID → exit non-zero" { + STUB_UPID="UPID:e:2" + export STUB_UPID + run env -u PROXMOX_TEMPLATE_VOLID "${ROOT}/lxc-clone.sh" 211 + [ "$status" -ne 0 ] +} \ No newline at end of file diff --git a/scripts/proxmox/test/lxc-config.bats b/scripts/proxmox/test/lxc-config.bats new file mode 100644 index 0000000..65b4f06 --- /dev/null +++ b/scripts/proxmox/test/lxc-config.bats @@ -0,0 +1,220 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/lxc-config.sh (praxis CT config). +# +# Run: bats scripts/proxmox/test/lxc-config.bats +# +# lxc-config.sh sets memory + onboot via REST PUT /config (API-token- +# accepted), then sets hookscript + lxc.environment via SSH to the PVE +# host (root-only fields rejected by REST). The SSH heredoc sed -i's +# prior lines then cat >> appends the new ones — idempotent on re-run. +# These tests exercise the real lxc-config.sh with a mocked api.sh +# (pve_curl records the PUT) + a mocked ssh that runs the heredoc body +# locally so sed/cat operate on a sandbox conf file. +# +# Praxis v0.2 (vs coreci) key differences asserted here: +# - hookscript snippet name is "praxis-firstboot.sh" (NOT "coreci-firstboot.sh") +# - lxc.environment includes PRAXIS_PORT=8789 (NOT CORECI_HTTP_PORT=18080) +# - lxc.environment includes voice-service vars (DEEPGRAM, CARTESIA, OLLAMA) +# - memory default 4096 (NOT 2048) +# - PRAXIS_VERSION, PRAXIS_DB_PATH, PRAXIS_TTS, PRAXIS_SCENARIO present + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + CONFIG="${SCRIPT_DIR}/lxc-config.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + CONF_FILE="${STUB_DIR}/pve-lxc-200.conf" + export CONF_FILE + + # Sandbox: /lxc-config.sh (SCRIPT_DIR) + /api.sh (sourced) + # + /ssh (mocked) on PATH ahead of /usr/bin. + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$CONFIG" "${ROOT}/lxc-config.sh" + + # Mocked api.sh — pve_env validates required env vars (mirrors the + # real helper so the env-validation path is exercised); pve_curl + # records method + path + body. + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { + missing=0 + for var in "$@"; do + eval "val=\"\${${var}:-}\"" + if [ -z "$val" ]; then + echo "pve_env: $var is required but not set" >&2 + missing=1 + fi + done + return "$missing" +} +pve_curl() { + method="$1"; path="$2"; shift 2 + printf '%s\n' "${method} ${path} $*" >> "$CALL_LOG" + printf '%s\n' "${STUB_PVE_CURL_OUT:-null}" +} +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + + # Mocked ssh — writes everything after the remote host arg into a + # script and runs it with sh, so the sed -i + cat >> execute locally + # against $CONF_FILE (the heredoc references $conf set from + # $conf_file which the script sets to /etc/pve/lxc/.conf — we + # override that path by rewriting the conf= line to point at our + # sandbox file). Records the raw heredoc body to $CALL_LOG. + cat > "${ROOT}/ssh" <<'SSTUB' +#!/bin/sh +# ssh [opts] host +# Drop the opts (-o ...) and the host (root@...); the rest is the script. +shift # drop -o StrictHostKeyChecking=no +host="$1"; shift +remote="$*" +printf '%s\n' "$remote" >> "$CALL_LOG" +# Run the remote script locally so sed/cat operate on the sandbox conf. +# The heredoc sets conf='' then sed -i + cat >> operate on $conf. +# We rewrite the conf path to point at our sandbox file. +remote_fixed=$(printf '%s\n' "$remote" | sed "s|/etc/pve/lxc/[0-9]*\.conf|${CONF_FILE}|g") +sh -c "$remote_fixed" +SSTUB + + chmod +x "${ROOT}"/*.sh "${ROOT}/ssh" + + export PATH="${ROOT}:${PATH}" + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" + export PROXMOX_STORAGE="local" + export GITEA_TOKEN="gitea-test-token" + export PRAXIS_VERSION="v0.2" + export PRAXIS_PORT="8789" +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +@test "config: REST PUT /nodes/{node}/lxc/{vmid}/config with onboot + memory=4096" { + run "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + grep -q '^PUT /nodes/testnode/lxc/200/config onboot=1 memory=4096$' "$LOG" + # Default memory is 4096 (NOT 2048 — coreci was 2048). + ! grep -q 'memory=2048' "$LOG" + grep -q 'lxc-config: VMID 200 configured' <<< "$output" +} + +@test "config: PROXMOX_MEMORY_MB override → memory field reflects it" { + PROXMOX_MEMORY_MB=8192 run "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + grep -q 'PUT /nodes/testnode/lxc/200/config onboot=1 memory=8192' "$LOG" +} + +@test "config: SSH appends hookscript=local:snippets/praxis-firstboot.sh (NOT coreci-firstboot.sh)" { + run "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + [ -f "$CONF_FILE" ] + grep -q '^onboot: 1$' "$CONF_FILE" + grep -q '^hookscript: local:snippets/praxis-firstboot.sh$' "$CONF_FILE" + # NOT coreci (praxis rebrand). + ! grep -q 'coreci-firstboot.sh' "$CONF_FILE" +} + +@test "config: lxc.environment includes PRAXIS_PORT=8789 (NOT CORECI_HTTP_PORT=18080)" { + run "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + [ -f "$CONF_FILE" ] + grep -q '^lxc.environment: PRAXIS_PORT=8789$' "$CONF_FILE" + # NOT the coreci var name + port. + ! grep -q 'CORECI_HTTP_PORT' "$CONF_FILE" + ! grep -q '18080' "$CONF_FILE" +} + +@test "config: lxc.environment includes PRAXIS_VERSION + PRAXIS_DB_PATH + PRAXIS_TTS + PRAXIS_SCENARIO" { + PRAXIS_DB_PATH=/app/data/praxis.db + PRAXIS_TTS=deepgram + PRAXIS_SCENARIO=default + export PRAXIS_DB_PATH PRAXIS_TTS PRAXIS_SCENARIO + run "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + grep -q '^lxc.environment: PRAXIS_VERSION=v0.2$' "$CONF_FILE" + grep -q '^lxc.environment: PRAXIS_DB_PATH=/app/data/praxis.db$' "$CONF_FILE" + grep -q '^lxc.environment: PRAXIS_TTS=deepgram$' "$CONF_FILE" + grep -q '^lxc.environment: PRAXIS_SCENARIO=default$' "$CONF_FILE" +} + +@test "config: lxc.environment includes GITEA_TOKEN when set" { + run "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + grep -q '^lxc.environment: GITEA_TOKEN=gitea-test-token$' "$CONF_FILE" +} + +@test "config: GITEA_TOKEN unset → no GITEA_TOKEN lxc.environment line" { + run env -u GITEA_TOKEN "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + [ -f "$CONF_FILE" ] + grep -q '^hookscript: local:snippets/praxis-firstboot.sh$' "$CONF_FILE" + ! grep -q '^lxc.environment: GITEA_TOKEN=' "$CONF_FILE" + # The other env lines are still present. + grep -q '^lxc.environment: PRAXIS_PORT=8789$' "$CONF_FILE" +} + +@test "config: lxc.environment includes voice-service vars (DEEPGRAM, CARTESIA, OLLAMA)" { + DEEPGRAM_API_KEY="dg-key" + CARTESIA_API_KEY="cart-key" + OLLAMA_API_KEY="oll-key" + run env DEEPGRAM_API_KEY="$DEEPGRAM_API_KEY" CARTESIA_API_KEY="$CARTESIA_API_KEY" \ + OLLAMA_API_KEY="$OLLAMA_API_KEY" "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + grep -q '^lxc.environment: DEEPGRAM_API_KEY=dg-key$' "$CONF_FILE" + grep -q '^lxc.environment: CARTESIA_API_KEY=cart-key$' "$CONF_FILE" + grep -q '^lxc.environment: OLLAMA_API_KEY=oll-key$' "$CONF_FILE" + # Ollama config defaults present. + grep -q '^lxc.environment: OLLAMA_BASE_URL=http://ollama.cloudinit.dev:11434$' "$CONF_FILE" + grep -q '^lxc.environment: OLLAMA_ROLEPLAY_MODEL=gemma4:cloud$' "$CONF_FILE" + grep -q '^lxc.environment: OLLAMA_DEBRIEF_MODEL=deepseek-v4-flash:cloud$' "$CONF_FILE" + # Deepgram defaults present. + grep -q '^lxc.environment: DEEPGRAM_MODEL=nova-3$' "$CONF_FILE" + grep -q '^lxc.environment: DEEPGRAM_LANGUAGE=en-US$' "$CONF_FILE" + grep -q '^lxc.environment: DEEPGRAM_REGION=us-east-1$' "$CONF_FILE" +} + +@test "config: voice-service keys default to empty (v0.2 infrastructure-only)" { + run env -u DEEPGRAM_API_KEY -u CARTESIA_API_KEY -u OLLAMA_API_KEY \ + "${ROOT}/lxc-config.sh" 200 + [ "$status" -eq 0 ] + # The lines are present but with empty values (v0.2 may ship without + # the secrets; the CT boots and install-service writes the env file). + grep -q '^lxc.environment: DEEPGRAM_API_KEY=$' "$CONF_FILE" + grep -q '^lxc.environment: CARTESIA_API_KEY=$' "$CONF_FILE" + grep -q '^lxc.environment: OLLAMA_API_KEY=$' "$CONF_FILE" +} + +@test "config: idempotent — re-run does not duplicate hookscript/lxc.environment lines" { + # First run appends the lines. + "${ROOT}/lxc-config.sh" 200 >/dev/null 2>&1 + # Seed a stale line that the sed should remove (simulates prior state). + printf 'hookscript: local:snippets/OLD.sh\n' >> "$CONF_FILE" + # Second run — sed -i removes prior lines, then cat >> appends fresh. + "${ROOT}/lxc-config.sh" 200 >/dev/null 2>&1 + [ -f "$CONF_FILE" ] + ! grep -q 'OLD.sh' "$CONF_FILE" + [ "$(grep -c '^hookscript:' "$CONF_FILE")" -eq 1 ] + [ "$(grep -c '^onboot:' "$CONF_FILE")" -eq 1 ] + [ "$(grep -c '^lxc.environment: PRAXIS_PORT=' "$CONF_FILE")" -eq 1 ] + [ "$(grep -c '^lxc.environment: GITEA_TOKEN=' "$CONF_FILE")" -eq 1 ] + [ "$(grep -c '^lxc.environment: OLLAMA_BASE_URL=' "$CONF_FILE")" -eq 1 ] +} + +@test "config: missing VMID arg → exit non-zero (usage)" { + run "${ROOT}/lxc-config.sh" + [ "$status" -ne 0 ] + grep -q 'usage: lxc-config.sh' <<< "$output" +} + +@test "config: pve_env fails on missing PROXMOX_API_TOKEN → exit non-zero" { + run env -u PROXMOX_API_TOKEN "${ROOT}/lxc-config.sh" 200 + [ "$status" -ne 0 ] +} \ No newline at end of file diff --git a/scripts/proxmox/test/lxc-deploy.bats b/scripts/proxmox/test/lxc-deploy.bats new file mode 100644 index 0000000..f1096e5 --- /dev/null +++ b/scripts/proxmox/test/lxc-deploy.bats @@ -0,0 +1,372 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/lxc-deploy.sh orchestration (SLICE-09). +# +# Run: bats scripts/proxmox/test/lxc-deploy.bats +# +# lxc-deploy.sh orchestrates: stage-snippet → clone → config → start → +# health-check → success. On ANY failure the EXIT trap fires rollback.sh. +# The trap captures $? so a `set -e` child failure (e.g. health-check) +# triggers rollback, not just INT/TERM. +# +# Idempotency (D-027): if the target VMID already exists + is healthy, +# the deploy skips clone/config/start (idempotent re-deploy). If the CT +# exists but is unhealthy, the operator must pass --recreate (rollback + +# redeploy) or --reconfigure (re-PUT config + restart) — otherwise the +# deploy errors with guidance and leaves the CT intact. +# +# These tests build a sandbox copy of lxc-deploy.sh with stub sibling +# scripts + a stub api.sh + the REAL ct-exists.sh (P16) + a stub +# timing.sh so the real orchestrator logic (trap, sequencing, +# idempotency, flag parsing) is exercised without a live Proxmox +# endpoint. +# +# Praxis v0.2 (vs coreci) key differences asserted here: +# - NO proxy/backend-add/smoke-test steps (proxy tier removed) +# - VMID auto-allocation via pve_nextid when PROXMOX_LXC_VMID unset +# - hookscript snippet volid is local:snippets/praxis-firstboot.sh + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + DEPLOY="${SCRIPT_DIR}/lxc-deploy.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + # Sandbox layout: + # /lxc-deploy.sh (SCRIPT_DIR) + # /api.sh (sourced) + # /ct-exists.sh (REAL — sourced by lxc-deploy.sh) + # /timing.sh (stubbed — sourced by lxc-deploy.sh) + # /stage-snippet.sh (invoked) + # /lxc-clone.sh (invoked) + # /lxc-config.sh (invoked) + # /lxc-start.sh (invoked) + # /health-check.sh (invoked; exit overridable) + # /rollback.sh (invoked on failure; records call) + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$DEPLOY" "${ROOT}/lxc-deploy.sh" + # ct-exists.sh (P16) — REAL, sourced by lxc-deploy.sh. + cp "${SCRIPT_DIR}/ct-exists.sh" "${ROOT}/ct-exists.sh" + + # recording stub generator: logs ":" to $CALL_LOG, exits + # with the given code (default 0). + log_stub() { + name="$1"; exit_var="$2" + printf '#!/bin/sh\necho "%s:$*" >> "%s"\nexit ${%s:-0}\n' \ + "$name" "$CALL_LOG" "$exit_var" > "${ROOT}/${name}.sh" + chmod +x "${ROOT}/${name}.sh" + } + + log_stub stage-snippet STUB_SNIPPET_EXIT + log_stub lxc-clone STUB_CLONE_EXIT + log_stub lxc-config STUB_CONFIG_EXIT + log_stub lxc-start STUB_START_EXIT + log_stub rollback STUB_ROLLBACK_EXIT + + # health-check stub: exit overridable; fails the FIRST call (the + # idempotency probe) when STUB_HEALTH_FIRST_FAIL=1, then passes + # subsequent calls (the post-remediation health-check). + cat > "${ROOT}/health-check.sh" <<'HSTUB' +#!/bin/sh +echo "health-check:$*" >> "$CALL_LOG" +count_file="${CALL_LOG}.hc" +n=$(cat "$count_file" 2>/dev/null || echo 0) +n=$((n + 1)) +echo "$n" > "$count_file" +if [ "${STUB_HEALTH_FIRST_FAIL:-0}" = "1" ] && [ "$n" -eq 1 ]; then + exit 1 +fi +exit ${STUB_HEALTH_EXIT:-0} +HSTUB + chmod +x "${ROOT}/health-check.sh" + + # Mocked api.sh — pve_env no-op; pve_nextid returns STUB_NEXTID; + # pve_get returns STUB_PVE_GET (empty by default → ct not found + + # snippet-exists check finds nothing → stage-snippet runs); pve_curl + # + pve_poll no-op. + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_nextid() { printf '%s\n' "${STUB_NEXTID:-200}"; } +pve_get() { printf '%s\n' "${STUB_PVE_GET:-}"; } +pve_curl() { :; } +pve_poll() { :; } +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + chmod +x "${ROOT}/api.sh" + + # timing.sh — stubbed to no-op so the orchestrator logic is exercised + # without the real helper; timing.sh itself is tested in timing.bats. + cat > "${ROOT}/timing.sh" <<'EOF' +timing_start() { :; } +timing_end() { :; } +EOF + chmod +x "${ROOT}/timing.sh" + + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" + export PROXMOX_STORAGE="local" + export PROXMOX_TEMPLATE_VOLID="local:vztmpl/debian-12-template.tar.zst" + export GITEA_TOKEN="gitea-test-token" + export PROXMOX_LXC_VMID="200" + # Reset the health-check call counter between tests. + rm -f "${CALL_LOG}.hc" 2>/dev/null || true +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +# ── Happy path ─────────────────────────────────────────────────── + +@test "happy path: stage → clone → config → start → health → no rollback, success" { + STUB_HEALTH_EXIT=0 + export STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 0 ] + grep -q '^VMID=200$' <<< "$output" + grep -q '^stage-snippet:' "$LOG" + grep -q '^lxc-clone:200' "$LOG" + grep -q '^lxc-config:200' "$LOG" + grep -q '^lxc-start:200' "$LOG" + grep -q '^health-check:200' "$LOG" + # Rollback MUST NOT fire on success. + ! grep -q '^rollback:' "$LOG" + grep -q 'deploy: praxis deployed successfully to VMID 200' <<< "$output" +} + +# ── Rollback on failure (trap fix: $? capture) ────────────────── + +@test "health-check fails (set -e) → rollback fires (trap fix: $? capture) → CT destroyed" { + # THE TRAP FIX: a `set -e` child failure (health-check exits 1) + # must trigger rollback. The trap captures $? so rc != 0 fires + # rollback (not just INT/TERM). + STUB_HEALTH_EXIT=1 + export STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -ne 0 ] + grep -q '^health-check:200' "$LOG" + grep -q '^rollback:200' "$LOG" + grep -q 'deploy: FAILED' <<< "$output" +} + +@test "clone fails (set -e) → rollback fires (trap fix) → CT destroyed" { + # Same trap fix, earlier failure: clone failure also fires rollback. + STUB_CLONE_EXIT=1 + export STUB_CLONE_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -ne 0 ] + grep -q '^lxc-clone:200' "$LOG" + grep -q '^rollback:200' "$LOG" + # config/start/health NOT reached. + ! grep -q '^lxc-config:' "$LOG" + ! grep -q '^health-check:' "$LOG" +} + +@test "config fails (set -e) → rollback fires, start/health NOT reached" { + STUB_CONFIG_EXIT=1 + export STUB_CONFIG_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -ne 0 ] + grep -q '^lxc-config:200' "$LOG" + grep -q '^rollback:200' "$LOG" + ! grep -q '^lxc-start:' "$LOG" + ! grep -q '^health-check:' "$LOG" +} + +@test "start fails (set -e) → rollback fires, health NOT reached" { + STUB_START_EXIT=1 + export STUB_START_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -ne 0 ] + grep -q '^lxc-start:200' "$LOG" + grep -q '^rollback:200' "$LOG" + ! grep -q '^health-check:' "$LOG" +} + +@test "stage-snippet fails (set -e) → exit non-zero, clone NOT reached (trap not yet installed)" { + # NOTE: stage-snippet runs at step 0 (line 65), BEFORE the vmid is + # resolved (line 69) + BEFORE the EXIT trap is installed (line 88). + # So a stage-snippet failure exits at line 65 without firing + # rollback (the trap isn't registered yet). This is a known + # ordering: the snippet is staged before any CT is created, so + # there's nothing to roll back. + STUB_SNIPPET_EXIT=1 + export STUB_SNIPPET_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -ne 0 ] + grep -q '^stage-snippet:' "$LOG" + ! grep -q '^lxc-clone:' "$LOG" + # No rollback: the trap isn't installed yet at this failure point. + ! grep -q '^rollback:' "$LOG" +} + +# ── VMID auto-allocation (D-027) ──────────────────────────────── + +@test "PROXMOX_LXC_VMID unset → auto-allocate via pve_nextid (STUB_NEXTID)" { + STUB_HEALTH_EXIT=0 + STUB_NEXTID=250 + export STUB_HEALTH_EXIT STUB_NEXTID + run env -u PROXMOX_LXC_VMID "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 0 ] + grep -q 'deploy: auto-allocated VMID 250' <<< "$output" + grep -q '^VMID=250$' <<< "$output" + grep -q '^lxc-clone:250' "$LOG" +} + +@test "PROXMOX_LXC_VMID set → use the configured VMID (no auto-allocate)" { + STUB_HEALTH_EXIT=0 + export STUB_HEALTH_EXIT + PROXMOX_LXC_VMID=300 run "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 0 ] + grep -q 'deploy: using configured VMID 300' <<< "$output" + grep -q '^VMID=300$' <<< "$output" + grep -q '^lxc-clone:300' "$LOG" +} + +# ── Idempotency (D-027) ───────────────────────────────────────── + +@test "VMID not exists → clone proceeds (current path)" { + STUB_HEALTH_EXIT=0 + export STUB_HEALTH_EXIT + # STUB_PVE_GET unset → empty → ct_exists false. + run "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 0 ] + grep -q '^VMID=200$' <<< "$output" + grep -q '^lxc-clone:200' "$LOG" + grep -q '^lxc-config:200' "$LOG" + grep -q '^lxc-start:200' "$LOG" + grep -q '^health-check:200' "$LOG" + ! grep -q '^rollback:' "$LOG" +} + +@test "VMID exists + running + healthy → skip clone/config/start (idempotent re-deploy)" { + STUB_PVE_GET='{"status":"running","vmid":200}' + STUB_HEALTH_EXIT=0 + export STUB_PVE_GET STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 0 ] + grep -q 'already running + healthy — skipping clone/config/start (idempotent re-deploy)' <<< "$output" + ! grep -q '^lxc-clone:' "$LOG" + ! grep -q '^lxc-config:' "$LOG" + ! grep -q '^lxc-start:' "$LOG" + grep -q '^health-check:200' "$LOG" + ! grep -q '^rollback:' "$LOG" + grep -q '^VMID=200$' <<< "$output" +} + +@test "VMID exists + unhealthy, no flag → exit 1 with guidance (--recreate / --reconfigure)" { + STUB_PVE_GET='{"status":"running","vmid":200}' + STUB_HEALTH_EXIT=1 + export STUB_PVE_GET STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 1 ] + grep -q 'exists but is unhealthy' <<< "$output" + grep -q -- '--recreate' <<< "$output" + grep -q -- '--reconfigure' <<< "$output" + grep -q 'No action taken' <<< "$output" + ! grep -q '^lxc-clone:' "$LOG" + ! grep -q '^rollback:' "$LOG" +} + +@test "VMID exists but not running, no flag → exit 1 with guidance (not running counts as unhealthy)" { + STUB_PVE_GET='{"status":"stopped","vmid":200}' + STUB_HEALTH_EXIT=0 + export STUB_PVE_GET STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 1 ] + grep -q 'exists but is unhealthy' <<< "$output" + grep -q -- '--recreate' <<< "$output" + ! grep -q '^lxc-clone:' "$LOG" + ! grep -q '^rollback:' "$LOG" +} + +@test "--recreate → rollback.sh called + redeploy proceeds (clone runs after destroy)" { + STUB_PVE_GET='{"status":"running","vmid":200}' + STUB_HEALTH_FIRST_FAIL=1 + STUB_HEALTH_EXIT=0 + export STUB_PVE_GET STUB_HEALTH_FIRST_FAIL STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" --recreate + [ "$status" -eq 0 ] + grep -q -- '--recreate: rollback + redeploy' <<< "$output" + grep -q '^rollback:200' "$LOG" + grep -q '^lxc-clone:200' "$LOG" + grep -q '^lxc-config:200' "$LOG" + grep -q '^lxc-start:200' "$LOG" + grep -q '^health-check:200' "$LOG" + grep -q '^VMID=200$' <<< "$output" +} + +@test "--reconfigure → lxc-config.sh re-PUT + lxc-start.sh restart (no clone)" { + STUB_PVE_GET='{"status":"running","vmid":200}' + STUB_HEALTH_FIRST_FAIL=1 + STUB_HEALTH_EXIT=0 + export STUB_PVE_GET STUB_HEALTH_FIRST_FAIL STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" --reconfigure + [ "$status" -eq 0 ] + grep -q -- '--reconfigure: re-PUT config + restart' <<< "$output" + grep -q '^lxc-config:200' "$LOG" + grep -q '^lxc-start:200' "$LOG" + ! grep -q '^lxc-clone:' "$LOG" + ! grep -q '^rollback:' "$LOG" + grep -q '^VMID=200$' <<< "$output" +} + +# ── Flag parsing ──────────────────────────────────────────────── + +@test "unknown flag → exit 2 with error" { + STUB_PVE_GET='{"status":"running","vmid":200}' + STUB_HEALTH_EXIT=0 + export STUB_PVE_GET STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" --bogus + [ "$status" -eq 2 ] + grep -q 'unknown argument: --bogus' <<< "$output" +} + +# ── Snippet-exists short-circuit ──────────────────────────────── + +@test "hookscript snippet already staged → stage-snippet.sh NOT re-run (idempotent)" { + # The snippet-exists check calls pve_get /storage/.../content + jq. + # Return a content array containing the praxis-firstboot.sh volid → + # stage-snippet is skipped. The ct_exists check queries a DIFFERENT + # path (/status/current), so we install a path-aware pve_get stub + # that returns the content array for /storage/.../content and empty + # for /status/current (CT not exists → clone proceeds). + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_nextid() { printf '%s\n' "${STUB_NEXTID:-200}"; } +pve_get() { + case "$1" in + */storage/*/content) + printf '%s\n' '[{"volid":"local:snippets/praxis-firstboot.sh"}]' + ;; + */lxc/*/status/current) + printf '%s\n' '' + ;; + *) + printf '%s\n' "${STUB_PVE_GET:-}" + ;; + esac +} +pve_curl() { :; } +pve_poll() { :; } +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + chmod +x "${ROOT}/api.sh" + STUB_HEALTH_EXIT=0 + export STUB_HEALTH_EXIT + run "${ROOT}/lxc-deploy.sh" + [ "$status" -eq 0 ] + grep -q 'hookscript snippet local:snippets/praxis-firstboot.sh already staged — skipping upload' <<< "$output" + ! grep -q '^stage-snippet:' "$LOG" + # clone/config/start/health still run (CT not exists). + grep -q '^lxc-clone:200' "$LOG" + grep -q '^health-check:200' "$LOG" + ! grep -q '^rollback:' "$LOG" +} \ No newline at end of file diff --git a/scripts/proxmox/test/lxc-start.bats b/scripts/proxmox/test/lxc-start.bats new file mode 100644 index 0000000..8bf19e1 --- /dev/null +++ b/scripts/proxmox/test/lxc-start.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/lxc-start.sh (praxis CT start). +# +# Run: bats scripts/proxmox/test/lxc-start.bats +# +# lxc-start.sh POSTs to /nodes/{node}/lxc/{vmid}/status/start, then +# polls the returned UPID until the async start task completes. These +# tests exercise the real lxc-start.sh with a mocked api.sh (pve_curl +# returns the UPID, pve_poll records the call) so the start-POST + +# UPID-poll + empty-UPID error path are verified without a live +# Proxmox endpoint. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + START="${SCRIPT_DIR}/lxc-start.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + # Sandbox: /lxc-start.sh (SCRIPT_DIR) + /api.sh (sourced). + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$START" "${ROOT}/lxc-start.sh" + + # Mocked api.sh — pve_env no-op; pve_curl records method + path then + # returns STUB_UPID; pve_poll records the UPID it was asked to wait on. + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_curl() { + method="$1"; path="$2"; shift 2 + printf '%s\n' "${method} ${path}" >> "$CALL_LOG" + printf '%s\n' "${STUB_UPID:-null}" +} +pve_poll() { + printf 'poll:%s\n' "$1" >> "$CALL_LOG" +} +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + + chmod +x "${ROOT}"/*.sh + + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +@test "start: POST /nodes/{node}/lxc/{vmid}/status/start + UPID poll → running" { + STUB_UPID="UPID:testnode:00056789:START" + export STUB_UPID + run "${ROOT}/lxc-start.sh" 200 + [ "$status" -eq 0 ] + grep -q '^POST /nodes/testnode/lxc/200/status/start$' "$LOG" + grep -q '^poll:UPID:testnode:00056789:START$' "$LOG" + grep -q 'lxc-start: VMID 200 is running' <<< "$output" +} + +@test "start: empty UPID (null) → exit 1, no poll, error logged" { + STUB_UPID="null" + export STUB_UPID + run "${ROOT}/lxc-start.sh" 201 + [ "$status" -ne 0 ] + grep -q '^POST /nodes/testnode/lxc/201/status/start$' "$LOG" + grep -q 'failed to start (empty UPID)' <<< "$output" + ! grep -q '^poll:' "$LOG" +} + +@test "start: empty-string UPID → exit 1, no poll" { + STUB_UPID="" + export STUB_UPID + run "${ROOT}/lxc-start.sh" 202 + [ "$status" -ne 0 ] + grep -q 'failed to start (empty UPID)' <<< "$output" + ! grep -q '^poll:' "$LOG" +} + +@test "start: missing VMID arg → exit non-zero (usage)" { + run "${ROOT}/lxc-start.sh" + [ "$status" -ne 0 ] + grep -q 'usage: lxc-start.sh' <<< "$output" +} + +@test "start: pve_env fails on missing PROXMOX_NODE → exit non-zero (set -u on \${PROXMOX_NODE})" { + STUB_UPID="UPID:e:1" + export STUB_UPID + run env -u PROXMOX_NODE "${ROOT}/lxc-start.sh" 203 + [ "$status" -ne 0 ] +} \ No newline at end of file diff --git a/scripts/proxmox/test/rollback.bats b/scripts/proxmox/test/rollback.bats new file mode 100644 index 0000000..4f1f0ed --- /dev/null +++ b/scripts/proxmox/test/rollback.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/rollback.sh (praxis CT rollback). +# +# Run: bats scripts/proxmox/test/rollback.bats +# +# rollback.sh stops (graceful, then force) and destroys a CT. It is +# idempotent (a 404 / already-gone CT is not an error). These tests +# exercise the real rollback.sh with a mocked api.sh (pve_curl, pve_get, +# pve_poll) so the shutdown → force-stop → destroy sequence + the +# 404-tolerant paths are verified without a live Proxmox endpoint. +# +# Praxis v0.2 (vs coreci) key difference asserted here: +# - NO proxy / PROXY_VMID / backend-remove.sh references (the proxy +# tier was removed in v0.2). rollback.sh is stop + destroy only. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + ROLLBACK="${SCRIPT_DIR}/rollback.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + # Sandbox layout: + # /rollback.sh (SCRIPT_DIR) + # /api.sh (sourced) + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$ROLLBACK" "${ROOT}/rollback.sh" + + # Mocked api.sh — pve_curl records method+path and returns STUB_UPID + # (or null); pve_get returns STUB_PVE_GET (so the "still running?" + # check fires when status=running); pve_poll no-op. + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_curl() { + method="$1"; path="$2" + printf 'pve_curl:%s %s\n' "$method" "$path" >> "$CALL_LOG" + printf '%s\n' "${STUB_UPID:-null}" +} +pve_get() { + printf 'pve_get:%s\n' "$1" >> "$CALL_LOG" + printf '%s\n' "${STUB_PVE_GET:-}" +} +pve_poll() { + printf 'pve_poll:%s\n' "$1" >> "$CALL_LOG" +} +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + + chmod +x "${ROOT}"/*.sh + + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +@test "rollback: shutdown → force-stop → destroy sequence (CT running)" { + # CT is running → graceful shutdown, then status=running → force stop, then destroy. + STUB_UPID="UPID:task:123" + STUB_PVE_GET='{"status":"running"}' + export STUB_UPID STUB_PVE_GET + run "${ROOT}/rollback.sh" 200 + [ "$status" -eq 0 ] + grep -q 'rollback: cleaning up VMID 200' <<< "$output" + # shutdown POST recorded. + grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/shutdown$' "$LOG" + # status check via pve_get. + grep -q '^pve_get:/nodes/testnode/lxc/200/status/current$' "$LOG" + grep -q 'rollback: force-stopping VMID 200' <<< "$output" + grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/stop$' "$LOG" + grep -q 'rollback: destroying VMID 200' <<< "$output" + grep -q '^pve_curl:DELETE /nodes/testnode/lxc/200$' "$LOG" + grep -q 'rollback: VMID 200 cleaned up' <<< "$output" +} + +@test "rollback: CT not running (stopped) → shutdown, no force-stop, destroy" { + # CT exists but status=stopped → no force-stop needed; destroy still runs. + STUB_UPID="UPID:task:456" + STUB_PVE_GET='{"status":"stopped"}' + export STUB_UPID STUB_PVE_GET + run "${ROOT}/rollback.sh" 200 + [ "$status" -eq 0 ] + grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/shutdown$' "$LOG" + ! grep -q 'force-stopping' <<< "$output" + ! grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/stop$' "$LOG" + grep -q '^pve_curl:DELETE /nodes/testnode/lxc/200$' "$LOG" + grep -q 'rollback: VMID 200 cleaned up' <<< "$output" +} + +@test "rollback: 404 (CT already gone) → idempotent, exit 0 (no force-stop, no error)" { + # pve_get returns empty (404) → no force-stop; shutdown + destroy both + # return null UPID (no poll). Exit 0. + STUB_UPID="null" + STUB_PVE_GET="" + export STUB_UPID STUB_PVE_GET + run "${ROOT}/rollback.sh" 200 + [ "$status" -eq 0 ] + ! grep -q 'force-stopping' <<< "$output" + grep -q 'rollback: destroying VMID 200' <<< "$output" + grep -q 'rollback: VMID 200 cleaned up' <<< "$output" +} + +@test "rollback: shutdown returns null UPID → no poll, but destroy still runs (404-tolerant)" { + # shutdown returns null (CT already stopped) → skip poll; destroy still runs. + STUB_UPID="null" + STUB_PVE_GET='{"status":"stopped"}' + export STUB_UPID STUB_PVE_GET + run "${ROOT}/rollback.sh" 200 + [ "$status" -eq 0 ] + ! grep -q '^pve_poll:' "$LOG" + grep -q '^pve_curl:DELETE /nodes/testnode/lxc/200$' "$LOG" +} + +@test "rollback: missing VMID arg → exit non-zero (usage)" { + run "${ROOT}/rollback.sh" + [ "$status" -ne 0 ] + grep -q 'usage: rollback.sh' <<< "$output" +} + +@test "rollback: NO proxy/PROXY_VMID/backend-remove references in CODE (v0.2 proxy tier removed)" { + # G-106 / v0.2: the proxy tier was removed. rollback.sh must NOT + # reference PROXY_VMID or invoke proxy/backend-remove.sh in its CODE + # (the header comment may mention the removal for future readers, but + # no executable path references the proxy tier). Assert by grepping the + # call log (no backend-remove invocation at runtime) + stripping + # comments before grepping the source for PROXY_VMID / backend-remove.sh. + STUB_UPID="null" + STUB_PVE_GET="" + export STUB_UPID STUB_PVE_GET + PROXY_VMID=100 run "${ROOT}/rollback.sh" 200 + [ "$status" -eq 0 ] + ! grep -q 'backend-remove' "$LOG" + ! grep -q 'proxy' "$LOG" + # Static source guard: strip comment-only lines, then assert no code + # references to the proxy tier. + code_only=$(grep -v '^[[:space:]]*#' "${ROOT}/rollback.sh") + ! printf '%s\n' "$code_only" | grep -q 'PROXY_VMID' + ! printf '%s\n' "$code_only" | grep -q 'backend-remove\.sh' +} + +@test "rollback: pve_env fails on missing PROXMOX_NODE → exit non-zero (set -u)" { + run env -u PROXMOX_NODE "${ROOT}/rollback.sh" 200 + [ "$status" -ne 0 ] +} \ No newline at end of file diff --git a/scripts/proxmox/test/setup_helper.bash b/scripts/proxmox/test/setup_helper.bash new file mode 100644 index 0000000..75f8e56 --- /dev/null +++ b/scripts/proxmox/test/setup_helper.bash @@ -0,0 +1,100 @@ +# Shared helpers for the praxis proxmox bats test suite. +# +# Sourced (via `load`) by the per-script .bats files to build a consistent +# sandbox: a temp STUB_DIR, a CALL_LOG, a sandbox ROOT with a mocked +# api.sh + recording stubs for the provision siblings. Each .bats file +# may further specialize the sandbox in its own setup(). +# +# Usage from a .bats file: +# setup() { +# load setup_helper +# praxis_sandbox_init # sets STUB_DIR, LOG, ROOT, mocks +# PROXMOX_API_URL="https://proxmox.test:8006/api2/json" +# ... +# } +# teardown() { praxis_sandbox_teardown; } +# +# Helpers exported (functions): +# praxis_sandbox_init — create the sandbox + default mocks +# praxis_sandbox_teardown — rm -rf the sandbox +# praxis_log_stub +# — write a recording stub at ROOT/.sh +# that logs ":" to $CALL_LOG and +# exits ${:-0} +# praxis_mock_api_default — install the default mocked api.sh +# (pve_env no-op, pve_nextid → STUB_NEXTID, +# pve_get → STUB_PVE_GET, pve_curl no-op, +# pve_poll no-op). Tests may override +# individual funcs after calling this. + +# praxis_sandbox_init — create the sandbox. Idempotent-ish: callers usually +# invoke once in setup(). Sets these globals for the test: +# STUB_DIR — temp dir root (cleaned in teardown) +# CALL_LOG — shared call log path (tests grep this) +# ROOT — sandbox root dir (real SCRIPT_DIR stand-in; siblings live here) +praxis_sandbox_init() { + STUB_DIR="$(mktemp -d)" + export STUB_DIR + CALL_LOG="${STUB_DIR}/calls.log" + : > "$CALL_LOG" 2>/dev/null || true + export CALL_LOG + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + export ROOT + # Default mocked api.sh — tests can overwrite ${ROOT}/api.sh after this. + praxis_mock_api_default +} + +praxis_sandbox_teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +# praxis_log_stub — write a recording stub at +# ${ROOT}/.sh that logs ":" to $CALL_LOG and exits +# with ${:-0}. The stub is chmod +x. +praxis_log_stub() { + _name="$1"; _exit_var="$2" + printf '#!/bin/sh\necho "%s:$*" >> "%s"\nexit ${%s:-0}\n' \ + "$_name" "$CALL_LOG" "$_exit_var" > "${ROOT}/${_name}.sh" + chmod +x "${ROOT}/${_name}.sh" +} + +# praxis_mock_api_default — install the default mocked api.sh. +# pve_env no-op; pve_nextid returns ${STUB_NEXTID:-200}; pve_get returns +# ${STUB_PVE_GET:-}; pve_curl no-op; pve_poll no-op. Override by writing +# your own ${ROOT}/api.sh after calling this (or by redefining funcs in +# your own setup). +praxis_mock_api_default() { + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_nextid() { printf '%s\n' "${STUB_NEXTID:-200}"; } +pve_get() { printf '%s\n' "${STUB_PVE_GET:-}"; } +pve_curl() { :; } +pve_poll() { :; } +pve_tls_insecure() { printf '%s\n' "${STUB_TLS_INSECURE:-}"; } +pve_auth_header() { printf 'PVEAPIToken=%s' "${PROXMOX_API_TOKEN:-}"; } +pve_lxc_env_args() { + first=1 + for pair in "$@"; do + [ "$first" -eq 0 ] && printf '\n' + printf '%s' "lxc.environment=${pair}" + first=0 + done +} +ASTUB + chmod +x "${ROOT}/api.sh" +} + +# praxis_common_env — export the common Proxmox env vars used by every +# test (all mocked; no live endpoint). Tests may override per-scenario. +praxis_common_env() { + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" + export PROXMOX_STORAGE="local" + export PROXMOX_TEMPLATE_VOLID="local:vztmpl/debian-12-template.tar.zst" + export GITEA_TOKEN="gitea-test-token" + export PRAXIS_VERSION="v0.2" + export PRAXIS_PORT="8789" + export PROXMOX_LXC_VMID="200" +} \ No newline at end of file diff --git a/scripts/proxmox/test/stage-snippet.bats b/scripts/proxmox/test/stage-snippet.bats new file mode 100644 index 0000000..f80b391 --- /dev/null +++ b/scripts/proxmox/test/stage-snippet.bats @@ -0,0 +1,268 @@ +#!/usr/bin/env bats +# Bats tests for scripts/proxmox/stage-snippet.sh (snippet staging). +# +# Run: bats scripts/proxmox/test/stage-snippet.bats +# +# stage-snippet.sh fetches firstboot-hook.sh from Gitea, bakes the +# GITEA_TOKEN into it via sed (G-101 fix), serves it over a local +# one-shot HTTP server, then POSTs to the Proxmox download-url endpoint +# to upload it to local:snippets/praxis-firstboot.sh. Finally it polls +# the upload task + verifies the snippet is present via pve_get. +# +# These tests exercise the real stage-snippet.sh with mocked: curl +# (fetches the raw snippet from a fixture), python3 (no-op server so +# we don't actually bind a port), and api.sh (pve_curl/pve_poll/pve_get +# recording stubs). The G-101 sed bake is verified against the fixture. +# +# Praxis v0.2 (vs coreci) key differences asserted here: +# - snippet name is "praxis-firstboot.sh" (NOT "coreci-firstboot.sh") +# - G-101 fix: GITEA_TOKEN is baked into the snippet via sed +# - download-url POST with url=, content=snippets, filename= + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + STAGE="${SCRIPT_DIR}/stage-snippet.sh" + + STUB_DIR="$(mktemp -d)" + export STUB_DIR + LOG="${STUB_DIR}/calls.log" + export CALL_LOG="$LOG" + : > "$LOG" 2>/dev/null || true + + ROOT="${STUB_DIR}/root" + mkdir -p "$ROOT" + cp "$STAGE" "${ROOT}/stage-snippet.sh" + + # Fixture: the raw firstboot-hook.sh with a ${GITEA_TOKEN} placeholder + # (mirrors the real firstboot-hook.sh shape). stage-snippet.sh sed-bakes + # the token into this. We capture the fetched + sed-processed file via + # the curl -o target so we can assert the bake happened. + FIXTURE="${STUB_DIR}/firstboot-hook.sh" + cat > "$FIXTURE" <<'FIX' +#!/bin/sh +# fixture firstboot hook with a placeholder token. +CLONE_URL="https://${GITEA_TOKEN}@git.example.com/org/repo.git" +echo "token is ${GITEA_TOKEN}" +FIX + export FIXTURE + + # Mocked api.sh — pve_env no-op; pve_curl records method+path+body and + # returns STUB_UPID; pve_poll records the UPID; pve_get returns + # STUB_CONTENT (the /storage/.../content JSON for the verify step). + cat > "${ROOT}/api.sh" <<'ASTUB' +pve_env() { :; } +pve_curl() { + method="$1"; path="$2"; shift 2 + printf 'pve_curl:%s %s %s\n' "$method" "$path" "$*" >> "$CALL_LOG" + printf '%s\n' "${STUB_UPID:-null}" +} +pve_poll() { + printf 'pve_poll:%s\n' "$1" >> "$CALL_LOG" +} +pve_get() { + printf 'pve_get:%s\n' "$1" >> "$CALL_LOG" + printf '%s\n' "${STUB_CONTENT:-}" +} +pve_tls_insecure() { :; } +pve_auth_header() { :; } +ASTUB + + # Mocked curl — the first curl in stage-snippet.sh is `curl -sS -f + # $insecure -o "$raw_snippet" "$RAW_URL"` (fetch the raw snippet). + # We copy the fixture to the -o target so the sed-bake operates on + # real content. Subsequent curl calls (none in the happy path beyond + # the fetch) fall through to a no-op success. + cat > "${ROOT}/curl" <<'CSTUB' +#!/bin/sh +# Parse -o and the trailing URL. +out="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + --insecure|-sS|-s|-f) shift ;; + --max-time) shift 2 ;; + -w) shift 2 ;; + -H) shift 2 ;; + *) url="$1"; shift ;; + esac +done +printf 'curl:out=%s url=%s\n' "$out" "$url" >> "$CALL_LOG" +if [ -n "$out" ]; then + # Fetch step: copy the fixture to the -o target. + cp "${FIXTURE}" "$out" +fi +exit 0 +CSTUB + chmod +x "${ROOT}/curl" + + # Mocked python3 — stage-snippet.sh runs `python3 -m http.server ...` + # in the background. We no-op it (print nothing, exit 0 immediately) + # so no port is bound. The backgrounding + wait is harmless. + cat > "${ROOT}/python3" <<'PSTUB' +#!/bin/sh +# Drop -m http.server args; just exit 0 (no port bound). +exit 0 +PSTUB + chmod +x "${ROOT}/python3" + + # Mocked sleep — no-op (the `sleep 1` after server start + `sleep 60` + # safety net become instant). + cat > "${ROOT}/sleep" <<'SLSTUB' +#!/bin/sh +: +SLSTUB + chmod +x "${ROOT}/sleep" + + chmod +x "${ROOT}"/*.sh + export PATH="${ROOT}:${PATH}" + + export PROXMOX_API_URL="https://proxmox.test:8006/api2/json" + export PROXMOX_API_TOKEN="root@pam!test=secret" + export PROXMOX_NODE="testnode" + export PROXMOX_STORAGE="local" + export GITEA_TOKEN="gitea-test-token" + export GITEA_HOST="git.cloudinit.dev" + export PRAXIS_VERSION="v0.2" + + # Default: the verify step sees the snippet present (single-element + # array with the matching volid). Tests override to empty for the + # "not found after upload" path. + STUB_CONTENT='[{"volid":"local:snippets/praxis-firstboot.sh"}]' + export STUB_CONTENT + STUB_UPID="UPID:upload:1" + export STUB_UPID +} + +teardown() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +@test "stage: happy path — fetch + bake + upload + poll + verify, exit 0" { + run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + grep -q 'stage-snippet: fetching firstboot-hook.sh from Gitea' <<< "$output" + grep -q 'stage-snippet: baking GITEA_TOKEN into snippet (G-101 fix)' <<< "$output" + grep -q 'stage-snippet: local:snippets/praxis-firstboot.sh staged' <<< "$output" +} + +@test "stage: snippet name is praxis-firstboot.sh (NOT coreci-firstboot.sh) — G-106 rebrand" { + run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + grep -q 'praxis-firstboot.sh' <<< "$output" + ! grep -q 'coreci-firstboot.sh' <<< "$output" + # The download-url POST records filename=praxis-firstboot.sh. + grep -q 'pve_curl:POST /nodes/testnode/storage/local/download-url' "$LOG" + grep -q 'filename=praxis-firstboot.sh' "$LOG" + ! grep -q 'filename=coreci-firstboot.sh' "$LOG" +} + +@test "stage: G-101 fix — GITEA_TOKEN is baked into the fetched snippet via sed (placeholder replaced)" { + # Capture the raw_snippet path by inspecting the curl log: stage-snippet + # fetches to ${tmp_dir}/praxis-firstboot.sh. We re-run + read that file + # from the temp dir before the EXIT trap cleans it. Easiest: patch the + # script's tmp_dir to a known path via env? The script uses mktemp -d, + # so we instead assert via the curl -o target recorded in the log, then + # cat that file in the same test (it persists until teardown since the + # script's trap runs at its EXIT — by then we've already read it). + # Run in a subshell so the script's EXIT trap cleans ITS temp, not ours. + # Instead: copy the fixture to OUR known path and assert sed -i ran by + # grepping the curl-fetch -o target after the script completes. + # Simplest robust approach: re-run with a wrapper that copies the + # fetched+seded file out before the trap fires. + capture_dir="${STUB_DIR}/captured" + mkdir -p "$capture_dir" + # Wrap: after stage-snippet.sh runs, the trap has cleaned its tmp_dir, + # so we instead intercept the curl -o target by patching curl to also + # copy the post-sed file to $capture_dir at the time of the SECOND + # curl call (there is only one curl call — the fetch). The sed -i + # runs AFTER the fetch, so we need to capture AFTER sed. We do this by + # making the python3 stub (which runs after sed) copy the file. + cat > "${ROOT}/python3" </dev/null || true +exit 0 +PSTUB + chmod +x "${ROOT}/python3" + run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + [ -f "${capture_dir}/praxis-firstboot.sh" ] + # The placeholder was replaced with the real token (G-101 bake). + grep -q 'gitea-test-token' "${capture_dir}/praxis-firstboot.sh" + ! grep -q '\${GITEA_TOKEN}' "${capture_dir}/praxis-firstboot.sh" +} + +@test "stage: download-url POST shape (url=, content=snippets, filename=)" { + run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + # pve_curl POST to /nodes/testnode/storage/local/download-url recorded. + grep -q '^pve_curl:POST /nodes/testnode/storage/local/download-url' "$LOG" + # The body includes url=/praxis-firstboot.sh, content=snippets, + # filename=praxis-firstboot.sh. + grep -q 'content=snippets' "$LOG" + grep -q 'filename=praxis-firstboot.sh' "$LOG" + grep -q 'url=http://127.0.0.1:18099/praxis-firstboot.sh' "$LOG" +} + +@test "stage: UPID polled after upload" { + run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + grep -q '^pve_poll:UPID:upload:1$' "$LOG" +} + +@test "stage: verify step queries /storage/.../content for the snippet volid" { + run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + grep -q '^pve_get:/nodes/testnode/storage/local/content$' "$LOG" +} + +@test "stage: empty UPID → exit 1, error logged (download failed to start)" { + STUB_UPID="null" + export STUB_UPID + run "${ROOT}/stage-snippet.sh" + [ "$status" -ne 0 ] + grep -q 'failed to start download (empty UPID)' <<< "$output" + ! grep -q '^pve_poll:' "$LOG" +} + +@test "stage: snippet not in /content after upload → exit 1" { + # pve_get returns an empty array (snippet not found). + STUB_CONTENT='[]' + export STUB_CONTENT + run "${ROOT}/stage-snippet.sh" + [ "$status" -ne 0 ] + grep -q 'snippet local:snippets/praxis-firstboot.sh not found after upload' <<< "$output" +} + +@test "stage: pve_env fails on missing GITEA_TOKEN → exit non-zero" { + run env -u GITEA_TOKEN "${ROOT}/stage-snippet.sh" + [ "$status" -ne 0 ] +} + +@test "stage: pve_env fails on missing PROXMOX_STORAGE → exit non-zero" { + run env -u PROXMOX_STORAGE "${ROOT}/stage-snippet.sh" + [ "$status" -ne 0 ] +} + +@test "stage: PRAXIS_VERSION flows into the Gitea raw URL (branch ref)" { + PRAXIS_VERSION="feature-branch" run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + # The curl fetch log records the raw URL with the branch ref. + grep -q 'git.cloudinit.dev/coreci/praxis/raw/branch/feature-branch/' "$LOG" +} + +@test "stage: GITEA_HOST override flows into the raw URL" { + GITEA_HOST="git.staging.test" run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + grep -q 'git.staging.test/coreci/praxis/raw/branch/' "$LOG" +} + +@test "stage: PROXMOX_DOWNLOAD_URL_BASE override flows into the download-url fetch param" { + PROXMOX_DOWNLOAD_URL_BASE="http://deployhost.test:8080" \ + run "${ROOT}/stage-snippet.sh" + [ "$status" -eq 0 ] + grep -q 'url=http://deployhost.test:8080/praxis-firstboot.sh' "$LOG" +} \ No newline at end of file diff --git a/scripts/proxmox/timing.sh b/scripts/proxmox/timing.sh new file mode 100755 index 0000000..c19ac7c --- /dev/null +++ b/scripts/proxmox/timing.sh @@ -0,0 +1,101 @@ +# CoreCI — deploy-stage timing helper (P11 — IDEATE-39). +# +# Sourced (not executed) by the deploy orchestrators +# (proxy-deploy.sh, lxc-deploy.sh) to emit structured slog-style +# JSON timing lines for each deploy stage to stderr, where a log +# aggregator (or `2>>timing.log`) can pick them up. +# +# Usage: +# . /path/to/timing.sh +# timing_start clone +# ... clone work ... +# timing_end clone +# +# Emits one JSON line per timing_end to stderr: +# {"event":"praxis_deploy_timing","stage":"clone","duration_s":3} +# +# Optional node_exporter textfile collector: if the env var +# NODE_TEXTFILE_COLLECTOR_DIR points to a writable directory, the +# latest per-stage duration is ALSO written there as +# `praxis_deploy_timing_.prom` so a node_exporter textfile +# collector scrapes it. If the dir is unset or unwritable, only the +# JSON log is emitted (the structured-log-first decision, PLAN v3.6 +# P11 Wave 2). +# +# Dependencies: date (POSIX epoch via +%s). jq is NOT required (the +# JSON line is constructed with printf so there is no external dep +# on the slow path). Idempotent: re-sourcing is harmless (the +# _TIMING_STARTS associative state is reset on source, but the +# orchestrator sources exactly once at startup). +# +# Adapted from coreci for praxis: metric/event prefixes renamed from +# `coreci_deploy_timing` → `praxis_deploy_timing` (TASK-03-07). +# +# shellcheck shell=sh + +# _TIMING_STARTS is a flat file-backed map (stage → epoch seconds). +# POSIX sh has no associative arrays, so we use a single newline- +# separated string of "stage=epoch" records and scan it. Stages are +# short identifiers (clone/config/start/health/smoke) so the linear +# scan is trivially cheap. +_TIMING_STARTS="" + +# timing_start — record the current epoch for . +# Overwrites a prior start for the same stage (idempotent re-entry). +timing_start() { + _stage="$1" + _now=$(date +%s) + # Drop any prior record for this stage, then append the fresh one. + _TIMING_STARTS="$(printf '%s\n' "$_TIMING_STARTS" \ + | while IFS= read -r _line; do + case "$_line" in + "${_stage}="*) ;; + *) [ -n "$_line" ] && printf '%s\n' "$_line" ;; + esac + done)" + _TIMING_STARTS="${_TIMING_STARTS:+${_TIMING_STARTS} +}${_stage}=${_now}" +} + +# timing_end — compute duration since timing_start , +# emit the JSON line to stderr, and optionally write the textfile +# collector entry. If no start was recorded for , emit nothing +# (defensive — a stray timing_end with no start is a no-op). +timing_end() { + _stage="$1" + _now=$(date +%s) + _start="" + # Scan the records for the matching stage. + _rest="" + while IFS= read -r _line; do + [ -n "$_line" ] || continue + case "$_line" in + "${_stage}="*) + _start="${_line#*=}" + ;; + *) + _rest="${_rest:+${_rest} +}${_line}" + ;; + esac + done <&2 + # Optional node_exporter textfile collector. + if [ -n "${NODE_TEXTFILE_COLLECTOR_DIR:-}" ] && \ + [ -d "$NODE_TEXTFILE_COLLECTOR_DIR" ] && \ + [ -w "$NODE_TEXTFILE_COLLECTOR_DIR" ]; then + _tf="${NODE_TEXTFILE_COLLECTOR_DIR}/praxis_deploy_timing_${_stage}.prom" + { + printf '# HELP praxis_deploy_timing_seconds Duration of the %s deploy stage.\n' "$_stage" + printf '# TYPE praxis_deploy_timing_seconds gauge\n' + printf 'praxis_deploy_timing_seconds{stage="%s"} %s\n' "$_stage" "$_duration" + } > "$_tf" 2>/dev/null || true + fi +} \ No newline at end of file diff --git a/server/__main__.py b/server/__main__.py index 306b834..9e59aa6 100644 --- a/server/__main__.py +++ b/server/__main__.py @@ -29,6 +29,7 @@ except ImportError: # pragma: no cover from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection from server.pipeline import build_pipeline @@ -116,6 +117,19 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]: raise HTTPException(status_code=500, detail=str(exc)) +# ── Static client serving (D-023, REQ-DEPLOY-13) ──────────────────── +# Mount client/dist as StaticFiles at "/" AFTER all API routes so they +# take precedence. html=True serves index.html for "/" (SPA root). +# The client has no React Router (single-view state machine: start→live +# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3. +_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist") +if os.path.isdir(_CLIENT_DIST): + app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client") + logger.info(f"Serving client from {_CLIENT_DIST}") +else: + logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode") + + def main() -> int: """Run the server with uvicorn.""" import uvicorn