KB-4487

Runbook — Reports MVP 2A (deploy, rollback, refresh, guardrails)

10 min read Revision 1
laws-newpg-read-pgrunbookreports-mvpchang-2avolume-based-deployrollbackdot-report-publishbase-table-snapshotv_report-guardtech-debt2026-06-25

RUNBOOK — Reports MVP 2A (deploy · rollback · refresh · guardrails)

File: knowledge/dev/laws-new/pg-read-pg/runbook-reports-mvp.md Created: 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.md Audience: 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

  1. Deploy is VOLUME-BASED, not image-swap. Production serves the host directory /opt/incomex/deploys/nuxt-output, bind-mounted to /app/.output inside container incomex-nuxt. The image nuxt-ssr-local:s174 is only the Node runtime; the volume shadows whatever .output is baked into the image. Rebuilding/replacing the image does NOT change what production serves.
  2. Rollback = restore the volume backup, not an image swap (see §2).
  3. 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-run dot-report-publish --refresh (Owner-gated). (see §4)
  4. dot-report-publish may 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, service nuxt (image nuxt-ssr-local:s174, container incomex-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, commit b4619b1).
  • The Nuxt SSR listens on :3000 inside the container (not published to the host). Host publishes only nginx (:80, :443); the nuxt container exposes :8080 but 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 .outputnitro.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 / 056b99a has been pushed.
  • Durable local bundles (2026-06-25): /opt/incomex/evidence/reports-mvp-2a/ (see its README.md).
    • nuxt-repo.20260625T143811Z.bundle (main tip b4619b1)
    • dot.20260625T143811Z.bundle (main tip 056b99a, 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 an id PK, registered in table_registry (the Nuxt SharedDirectusTable renders it generically; the route mold resolves page_url → table from data, no per-report hardcoding).
  • Why base table, not view: this Directus cannot serve a PK-less VIEW (proven: law_registry view = FORBIDDEN even to admin). This is the rev2-design DEVIATION, documented in the script header.
  • Numbers are a snapshot. dot-report-publish POPULATES the base table by recomputing the report's defining SQL (stored in DATA: table_registry.view_sql, never hardcoded in the binary) from pg_catalog via TRUNCATE ... RESTART IDENTITY + INSERT, stamping refreshed_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:
    dot-report-publish --refresh <spec>     # Owner-gated; recompute (step 5) + verify only
    
    Running this WRITES to the database (truncate+insert) — it is NOT a no-op read.

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 any INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|CALL|DO keyword, then a live READ-ONLY probe.
  • The only destructive statement is TRUNCATE TABLE quote_ident(vn) RESTART IDENTITY where vn is 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 on workflow_run after "Nuxt 3 CI" completes successfully on main (and on workflow_dispatch). The deploy-direct job (gated by repo variable DEPLOY_MODE == 'direct') builds Nuxt on GitHub, rsyncs .output/ to the VPS, backs up, and restarts — i.e. a real production deploy.
  • A .githooks/pre-push exists to block direct push to main, but it is NOT wired (core.hooksPath is unset; .git/hooks has no active hook) — so a local git push origin main is not locally blocked.
  • Therefore: pushing nuxt-repo to GitHub main is NOT harmless storage — it can trigger a production deploy. If unsure whether DEPLOY_MODE is direct, 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: noREADY_FOR_OWNER_STORAGE_DECISION (choose A push / B off-box copy).