Runbook — Reports MVP 2A (deploy, rollback, refresh, guardrails)
RUNBOOK — Reports MVP 2A (deploy · rollback · refresh · guardrails)
File:
knowledge/dev/laws-new/pg-read-pg/runbook-reports-mvp.mdCreated: 2026-06-25 · Status: DEPLOYED TO PRODUCTION (Chặng 2A MVP, base-table variant) Related:thiet-ke/thiet-ke-khuon-reports.md(rev2 design),bao-cao-pg-read-pg.md,de-bai-pg-read-pg.mdAudience: any Agent/person who touches/reports,dot-report-publish, or the Nuxt deploy. Every fact below was re-verified on the live VPS on 2026-06-25; the runbook also lists the verify command so you can re-check, not trust.
0. TL;DR — the 4 things that bite
- Deploy is VOLUME-BASED, not image-swap. Production serves the host directory
/opt/incomex/deploys/nuxt-output, bind-mounted to/app/.outputinside containerincomex-nuxt. The imagenuxt-ssr-local:s174is only the Node runtime; the volume shadows whatever.outputis baked into the image. Rebuilding/replacing the image does NOT change what production serves. - Rollback = restore the volume backup, not an image swap (see §2).
- Reports are Directus-compatible BASE-TABLE snapshots, recomputed at publish time — NOT live-on-read PostgreSQL views. Numbers are correct as of
refreshed_at; to update them you re-rundot-report-publish --refresh(Owner-gated). (see §4) dot-report-publishmay only truncate/refresh tables whose name matches^v_report_[a-z0-9_]+$— never a free-form business table name. (see §5)
1. Production deploy is volume-based
- Compose file:
/opt/incomex/docker/docker-compose.yml, servicenuxt(imagenuxt-ssr-local:s174, containerincomex-nuxt). - The mount (compose line ~153):
volumes: - /opt/incomex/deploys/nuxt-output:/app/.output - Live container confirms it:
docker inspect incomex-nuxt→/opt/incomex/deploys/nuxt-output -> /app/.output (bind, rw=true). - The currently-served build:
/opt/incomex/deploys/nuxt-output/nitro.json→"date":"2026-06-25T10:56:56.711Z"(the Reports MVP build, commitb4619b1). - The Nuxt SSR listens on :3000 inside the container (not published to the host).
Host publishes only nginx (
:80,:443); the nuxt container exposes:8080but it is not host-published. Front door = nginx → nuxt.
Verify:
grep -n "nuxt-output" /opt/incomex/docker/docker-compose.yml
docker inspect incomex-nuxt --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{end}}'
cat /opt/incomex/deploys/nuxt-output/nitro.json
2. Rollback (CORRECT method)
Production = a directory. Rolling back = replacing that directory's contents with a known-good backup, then restarting the service.
Known-good backup (taken before the Reports MVP 2A deploy):
/opt/incomex/deploys/nuxt-output.bak.pre-reports-mvp-2a-20260625T135429Z
(verified: contains a real built .output — nitro.json + public/ + server/index.mjs.)
Rollback procedure:
# 0. ALWAYS back up the CURRENT output first so the rollback is itself reversible:
cp -a /opt/incomex/deploys/nuxt-output \
/opt/incomex/deploys/nuxt-output.bak.pre-rollback-$(date -u +%Y%m%dT%H%M%SZ)
# 1. Restore the known-good backup into the live (mounted) directory:
rsync -a --delete \
/opt/incomex/deploys/nuxt-output.bak.pre-reports-mvp-2a-20260625T135429Z/ \
/opt/incomex/deploys/nuxt-output/
# 2. Restart the Nuxt service (re-reads the mounted .output):
cd /opt/incomex/docker && docker compose restart nuxt
# 3. Reload the front door:
docker exec incomex-nginx nginx -s reload # or: docker compose restart nginx
# 4. Smoke-test (in-container SSR, read-only):
docker exec incomex-nuxt sh -lc 'for u in /reports /reports/report-pg /knowledge/workflows; do wget -qO/dev/null -S http://127.0.0.1:3000$u 2>&1 | grep -m1 HTTP; done'
Why an image swap is NOT a rollback: because the bind-mounted volume shadows the image's /app/.output, swapping back to image s174 (or any image) leaves the same volume in place — production keeps serving whatever is in /opt/incomex/deploys/nuxt-output. Only restoring the volume changes what users see.
3. Commits behind the MVP (source of truth = git, durably bundled)
| Commit | Repo (on VPS) | What |
|---|---|---|
b4619b1 |
/opt/incomex/docker/nuxt-repo (branch main) |
feat(reports): generic /reports route mold (Chặng 2A MVP) — web/pages/reports/index.vue + web/pages/reports/[report]/index.vue + .gitignore negation |
7188acd |
/opt/incomex/docker/nuxt-repo (branch main) |
feat(reports): generic row-link template in SharedDirectusTable (Chặng 1) — ancestor of b4619b1 |
056b99a |
/opt/incomex/dot (branch main) |
feat(reports): DOT-B dot-report-publish + report specs |
- nuxt-repo has GitHub remote
origin = https://github.com/Huyen1974/web-test.git. - dot has NO remote → its only durable copy off the live checkout is a bundle.
- None of
b4619b1/7188acd/056b99ahas been pushed. - Durable local bundles (2026-06-25):
/opt/incomex/evidence/reports-mvp-2a/(see itsREADME.md).nuxt-repo.20260625T143811Z.bundle(main tipb4619b1)dot.20260625T143811Z.bundle(main tip056b99a, complete history)
4. How the Reports MVP actually works (mechanism)
- Each report = a Directus collection that is a base table named
v_report_<key>with anidPK, registered intable_registry(the NuxtSharedDirectusTablerenders it generically; the route mold resolvespage_url→ table from data, no per-report hardcoding). - Why base table, not view: this Directus cannot serve a PK-less VIEW (proven:
law_registryview = FORBIDDEN even to admin). This is the rev2-design DEVIATION, documented in the script header. - Numbers are a snapshot.
dot-report-publishPOPULATES the base table by recomputing the report's defining SQL (stored in DATA:table_registry.view_sql, never hardcoded in the binary) frompg_catalogviaTRUNCATE ... RESTART IDENTITY + INSERT, stampingrefreshed_at = now(). - The figures are live-as-of-
refreshed_at, NOT live-on-read. A/reports/<key>page shows the last published snapshot. To update the numbers you must re-run the publisher in refresh mode:
Running this WRITES to the database (truncate+insert) — it is NOT a no-op read.dot-report-publish --refresh <spec> # Owner-gated; recompute (step 5) + verify only
5. DOT-B guardrail (dot-report-publish)
Real code in /opt/incomex/dot/bin/dot-report-publish (verified 2026-06-25):
validate_spec()enforces[[ "$VIEW_NAME" =~ ^v_report_[a-z0-9_]+$ ]] || exit 4— the collection/table name MUST match^v_report_[a-z0-9_]+$. A free-form business table name is rejected.- The defining SQL (
view_sql) passes a read-only validator that rejects anyINSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|CALL|DOkeyword, then a live READ-ONLY probe. - The only destructive statement is
TRUNCATE TABLE quote_ident(vn) RESTART IDENTITYwherevnis the already-validated^v_report_name — so the publisher can only ever truncate a report table.
Verify:
grep -nE '\^v_report_|INSERT\|UPDATE\|DELETE|TRUNCATE TABLE' /opt/incomex/dot/bin/dot-report-publish
6. Production URLs — PASS
Verified 2026-06-25 ~14:36Z via the in-container SSR (read-only GET), all HTTP 200:
/reports/reports/report-pg/knowledge/workflows(regression check — unchanged)
(Host :80/:443 front via nginx returns 301 HTTP→HTTPS, expected; the app-level 200 is the in-container check.)
docker exec incomex-nuxt sh -lc 'for u in /reports /reports/report-pg /knowledge/workflows; do wget -qO/dev/null -S http://127.0.0.1:3000$u 2>&1 | grep -m1 HTTP; done'
7. CI / "is a push safe?" — push is a DEPLOY, treat with care
- nuxt-repo has GitHub Action
deploy-vps.yml("Deploy to VPS"): triggered onworkflow_runafter "Nuxt 3 CI" completes successfully onmain(and onworkflow_dispatch). Thedeploy-directjob (gated by repo variableDEPLOY_MODE == 'direct') builds Nuxt on GitHub, rsyncs.output/to the VPS, backs up, and restarts — i.e. a real production deploy. - A
.githooks/pre-pushexists to block direct push tomain, but it is NOT wired (core.hooksPathis unset;.git/hookshas no active hook) — so a localgit push origin mainis not locally blocked. - Therefore: pushing nuxt-repo to GitHub
mainis NOT harmless storage — it can trigger a production deploy. If unsure whetherDEPLOY_MODEisdirect, assume YES. To preserve source durably without risking a deploy, prefer the off-box BUNDLE/ARCHIVE path over push.
8. Tech debt / not-yet-done (carry forward)
- Least-privilege role: publish/refresh currently runs as
workflow_admin(superuser). Needs a dedicated least-privilege role split later. - Design note rev3 / addendum: the base-table-snapshot deviation from the rev2 view design is recorded only in the script header + this runbook; fold it into a design-doc rev3/addendum.
- Source off-box: bundles exist LOCALLY only (
/opt/incomex/evidence/reports-mvp-2a/). Source is NOT yet off-box. Owner must choose: (A) controlled push [= a deploy, see §7], or (B) copy bundle/archive to an off-box location. - Đ30/31 route protection;
report-workflow/report-dot; the 5 breakdown reports; theme/Cowork — all still pending.
9. Durability status (as of this runbook)
- Local git bundles: yes (
/opt/incomex/evidence/reports-mvp-2a/). - Pushed to remote: no.
- Off-box: no →
READY_FOR_OWNER_STORAGE_DECISION(choose A push / B off-box copy).