From acd8b82b0924603cf7b1e6a7403278441d87001b Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:32:02 +0200 Subject: [PATCH] M44: harden release integrity and assurance --- .env.example | 10 + .gitea/workflows/ci.yml | 39 ++- .gitea/workflows/release.yml | 42 +++ .gitignore | 1 + CHANGELOG.md | 18 ++ LICENSE | 21 ++ Makefile | 10 +- PROJECT_STATE.md | 27 ++ README.md | 4 +- SECURITY.md | 56 ++++ artifacts/final-acceptance/summary.md | 289 ++---------------- backend/Dockerfile | 6 + backend/alembic.ini | 2 +- backend/app/core/config.py | 1 + backend/app/core/observability.py | 10 + backend/app/main.py | 5 + backend/app/services/knowledge/ragcore.py | 45 ++- backend/tests/test_knowledge.py | 29 ++ backend/tests/test_observability.py | 2 + compose.observability.yaml | 30 +- compose.release.yaml | 14 + compose.unraid.yaml | 7 +- compose.yaml | 6 + contracts/mcp-tools.json | 1 + contracts/openapi.yaml | 65 ++-- deploy/observability/alertmanager.yml | 21 ++ deploy/observability/alerts.yml | 6 + deploy/observability/prometheus.yml | 5 + deploy/unraid/README.md | 28 +- deploy/unraid/deploy-release.sh | 88 ++++++ deploy/unraid/restore-drill-postgres.sh | 44 +++ deploy/unraid/scheduled-backup.sh | 13 +- deploy/unraid/setup-alert-receiver.sh | 13 + deploy/unraid/verify-postgres-backups.sh | 5 +- docs/19-visual-product-roadmap.md | 4 + docs/demo-release/demo-runbook.md | 41 +-- frontend/Dockerfile | 6 + frontend/e2e-live/smoke.spec.ts | 52 ++++ .../e2e/__screenshots__/highlights-main.png | Bin 0 -> 83029 bytes frontend/e2e/__screenshots__/login-access.png | Bin 0 -> 29827 bytes frontend/e2e/accessibility.spec.ts | 52 ++++ frontend/e2e/visual-regression.spec.ts | 15 + frontend/package-lock.json | 1 + frontend/package.json | 2 + frontend/playwright.config.ts | 1 + frontend/playwright.live.config.ts | 18 ++ frontend/src/styles.css | 6 +- n8n/workflows/MANIFEST.md | 16 +- n8n/workflows/check_drift.py | 1 + n8n/workflows/fleet-ops-alert-receiver.json | 36 +++ renovate.json | 17 ++ scripts/check-contracts.py | 126 ++++++++ scripts/check-frontend-budget.mjs | 18 ++ scripts/check-source-budgets.py | 26 ++ scripts/generate-openapi.py | 15 + 55 files changed, 1081 insertions(+), 335 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 compose.release.yaml create mode 100644 deploy/observability/alertmanager.yml create mode 100755 deploy/unraid/deploy-release.sh create mode 100755 deploy/unraid/restore-drill-postgres.sh create mode 100755 deploy/unraid/setup-alert-receiver.sh create mode 100644 frontend/e2e-live/smoke.spec.ts create mode 100644 frontend/e2e/__screenshots__/highlights-main.png create mode 100644 frontend/e2e/__screenshots__/login-access.png create mode 100644 frontend/e2e/accessibility.spec.ts create mode 100644 frontend/e2e/visual-regression.spec.ts create mode 100644 frontend/playwright.live.config.ts create mode 100644 n8n/workflows/fleet-ops-alert-receiver.json create mode 100644 renovate.json create mode 100755 scripts/check-contracts.py create mode 100644 scripts/check-frontend-budget.mjs create mode 100755 scripts/check-source-budgets.py create mode 100755 scripts/generate-openapi.py diff --git a/.env.example b/.env.example index 83b5b8e..ecab19c 100644 --- a/.env.example +++ b/.env.example @@ -34,13 +34,20 @@ LOG_LEVEL=INFO METRICS_BEARER_TOKEN= GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_PASSWORD=change-me-before-start +# Alertmanager sends every firing/resolved alert and the continuous watchdog to this +# owner-managed receiver. Production must route it to a channel that is actually watched. +ALERTMANAGER_WEBHOOK_URL=https://n8n.itworx.tech/webhook/mobilityops-alerts # Verified scheduled PostgreSQL backups (Unraid override). BACKUP_INTERVAL_SECONDS=86400 BACKUP_RETENTION_DAYS=30 BACKUP_MINIMUM_COPIES=7 +# Restore the newest dump into a disposable database at least weekly. Backup health also +# requires a successful drill within eight days. +BACKUP_RESTORE_DRILL_INTERVAL_SECONDS=604800 # Set both values to copy every verified backup to an independently mounted path. BACKUP_SECONDARY_DESTINATION= +MOBILITYOPS_BACKUP_DIR=./backups/postgres MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite # Privacy governance defaults. @@ -87,6 +94,9 @@ RAGCORE_COLLECTION=internal-procedures RAGCORE_API_TOKEN= # UUID of the RAGcore knowledge space procedures were synced into (see workflow 3). RAGCORE_SPACE_ID= +# Skip the slower generation endpoint temporarily after a timeout/non-2xx response and +# use the still-grounded extractive search fallback immediately. +RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS=60 # ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own # side (it reconciles its catalog into the gateway; Fleet Ops never pushes a diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2b7ff7a..f8dd7fe 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -4,6 +4,8 @@ on: push: branches: [master] pull_request: + schedule: + - cron: "17 3 * * 1" jobs: backend: @@ -21,8 +23,15 @@ jobs: run: | docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app + - name: Contract drift gate + run: | + docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm \ + -v "$PWD:/repo:ro" api python /repo/scripts/check-contracts.py + python scripts/check-source-budgets.py - name: Build production API image for vulnerability scan - run: docker build --target runtime --tag mobilityops-api-ci --file backend/Dockerfile . + run: | + docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" \ + --tag mobilityops-api-ci --file backend/Dockerfile . - name: Production API image vulnerability scan (HIGH/CRITICAL) uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0 with: @@ -32,6 +41,19 @@ jobs: severity: HIGH,CRITICAL exit-code: "1" ignore-unfixed: true + - name: Build production web image for vulnerability scan + run: | + docker build --build-arg VCS_REF="$GITHUB_SHA" \ + --tag mobilityops-web-ci frontend + - name: Production web image vulnerability scan (HIGH/CRITICAL) + uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0 + with: + scan-type: image + image-ref: mobilityops-web-ci + format: table + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true - name: Remove CI stack if: always() run: docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans @@ -53,7 +75,7 @@ jobs: run: npm run lint - name: Typecheck and production build working-directory: frontend - run: npm run build + run: npm run build && npm run budget - name: Dependency audit working-directory: frontend run: npm audit --audit-level=high @@ -74,7 +96,7 @@ jobs: run: | cp .env.example .env docker compose -p mobilityops-e2e up --build -d db api web - for attempt in $(seq 1 60); do + for _attempt in $(seq 1 60); do if curl -fsS http://localhost:1228/health/ready >/dev/null 2>&1; then break; fi sleep 2 done @@ -84,18 +106,25 @@ jobs: working-directory: frontend run: | npm ci --no-audit --no-fund - npx playwright install --with-deps chromium + npx playwright install --with-deps chromium firefox - name: Run browser acceptance suite working-directory: frontend env: MOBILITYOPS_PUBLIC_URL: http://localhost:1228 run: npx playwright test + - name: Run non-destructive Chromium and Firefox smoke suite + working-directory: frontend + env: + MOBILITYOPS_PUBLIC_URL: http://localhost:1228 + run: npx playwright test --config=playwright.live.config.ts - name: Upload Playwright report if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: playwright-report - path: frontend/playwright-report + path: | + frontend/playwright-report + frontend/playwright-live-report - name: Stack logs on failure if: failure() run: docker compose -p mobilityops-e2e logs --tail=200 api web diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..532ce7d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,42 @@ +name: MobilityOps release evidence + +on: + push: + tags: ["v*"] + +jobs: + release-evidence: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Build commit-labelled release images + run: | + docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-api-release --file backend/Dockerfile . + docker build --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-web-release frontend + - name: Generate API CycloneDX SBOM + uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0 + with: + scan-type: image + image-ref: mobilityops-api-release + format: cyclonedx + output: mobilityops-api-sbom.cdx.json + - name: Generate web CycloneDX SBOM + uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0 + with: + scan-type: image + image-ref: mobilityops-web-release + format: cyclonedx + output: mobilityops-web-sbom.cdx.json + - name: Record immutable image metadata + run: | + docker image inspect mobilityops-api-release > mobilityops-api-image.json + docker image inspect mobilityops-web-release > mobilityops-web-image.json + sha256sum mobilityops-*-sbom.cdx.json mobilityops-*-image.json > SHA256SUMS + - name: Upload release evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mobilityops-${{ github.ref_name }}-evidence + path: | + mobilityops-*-sbom.cdx.json + mobilityops-*-image.json + SHA256SUMS diff --git a/.gitignore b/.gitignore index ccc8026..f9e0936 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules/ dist/ coverage/ playwright-report/ +playwright-live-report/ test-results/ *.pyc .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bce5b2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes are documented here. The project follows semantic release tags for +the deployable PoC; detailed validation evidence remains in `PROJECT_STATE.md`. + +## [Unreleased] + +- Added contract-drift, accessibility, Firefox smoke and frontend asset-budget gates. +- Added immutable commit-labelled deployment with automatic application rollback. +- Added real PostgreSQL restore drills and routed Alertmanager notifications. +- Added RAGcore generation circuit breaking and retrieval telemetry. +- Added scheduled dependency maintenance, dual-image vulnerability scans and release SBOMs. + +## [1.0.0-poc] - 2026-08-21 + +- Completed the locked Fleet Ops proof of concept: operational core, transactional returns, + data quality, n8n orchestration, grounded RAGcore knowledge, read-only MCP integration, + privacy governance, observability, backup/recovery and full browser acceptance. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb0c466 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jens Caers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 7a497ba..f46897e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up down logs test lint seed reset n8n-setup n8n-setup-scan demo e2e +.PHONY: up down logs test lint contracts seed reset n8n-setup n8n-setup-scan demo e2e live-smoke up: docker compose up --build -d @@ -17,6 +17,11 @@ lint: docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app cd frontend && npm run lint +contracts: + docker compose -f compose.yaml -f compose.test.yaml run --build --rm \ + -v "$(CURDIR):/repo:ro" api python /repo/scripts/check-contracts.py + python scripts/check-source-budgets.py + seed: docker compose exec api python -m app.cli seed --reset @@ -49,3 +54,6 @@ demo: up e2e: cd frontend && npx playwright test + +live-smoke: + cd frontend && npx playwright test --config=playwright.live.config.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 4f2968d..40188c8 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,5 +1,32 @@ # Project state +## M44 — release integrity and assurance hardening (2026-08-21) + +- Replaced mutable archive overlays with checksum-verified, commit-named release staging, + OCI revision-labelled API/web images, health-gated promotion and automatic application + rollback. Routine deployment no longer resets persisted demo data. +- Scheduled backups now execute a weekly real restore into a disposable database and gate + health on both backup and restore-drill freshness. The isolated drill passed at Alembic + `4f2b9c8d7e61` with restored counts `2 users / 50 vehicles / 254 bookings / 1 audit event`. +- Added Alertmanager routing with a continuous watchdog and an authenticated fifth n8n + workflow targeting the existing watched M365 owner mailbox. Added weekly Renovate, + API/web image vulnerability scans, tag-triggered CycloneDX SBOM evidence and immutable + image metadata. +- Contract drift is now executable for OpenAPI, events, MCP endpoints and all five n8n + workflows. RAGcore generation failures open a bounded circuit breaker so grounded search + fallback avoids repeated five-second delays; provider-stage outcomes and retrieval scores + are measurable. +- Added Axe accessibility, platform-independent visual regression, non-destructive + Chromium/Firefox canaries, frontend asset budgets and source-growth budgets. The checks + found and fixed two real WCAG contrast defects. +- Validation: backend **271/271**, Playwright **155/155** in 4.6 minutes, live-safe canary + **4/4**, focused knowledge/observability **42/42**, frontend lint/build/audit/budgets, + contract gate, Compose rendering, Prometheus/Alertmanager validation and shell parsing + passed. The user approved the repository security policy before it was written. +- Exact next action: commit and push M44, create a production backup, publish/test the alert + receiver, deploy the exact archive, run the non-destructive production canary and record + M45 live evidence. + ## M43 — publish and redeploy review remediation (2026-08-21) - Published M41 hardening commit `24dcb3494c522fadf536fa9d6826450227aeff4e` diff --git a/README.md b/README.md index cd4cf7a..6fecce5 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ make e2e # complete Playwright browser acceptance cd frontend && npm run build ``` -Current verified results and production evidence are recorded in [artifacts/final-acceptance/summary.md](artifacts/final-acceptance/summary.md) (historical M7 evidence: [artifacts/evidence/final-summary.md](artifacts/evidence/final-summary.md)) and [PROJECT_STATE.md](PROJECT_STATE.md). +Release-scoped results and production evidence are recorded in [artifacts/final-acceptance/summary.md](artifacts/final-acceptance/summary.md); older milestone evidence remains explicitly historical. [PROJECT_STATE.md](PROJECT_STATE.md) records the commands and exact deployment revision. ## Repository map @@ -89,6 +89,6 @@ Current verified results and production evidence are recorded in [artifacts/fina - `n8n/` — importable workflow definitions for the existing server - `seed/` — deterministic synthetic dataset - `docs/` — architecture, security, UX, testing and runbooks -- `artifacts/` — acceptance evidence and screenshots per release (`artifacts/final-acceptance/summary.md` is the definitive one) +- `artifacts/` — dated, release-scoped acceptance evidence and screenshots “MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9beb10f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,56 @@ +# Security Policy + +## Supported versions + +| Version | Security support | +|---|---| +| Latest tagged PoC release and current `master` | Supported | +| Older commits, branches and untagged deployments | Not supported | + +MobilityOps is a synthetic-data proof of concept, not a production identity, +payments or public reservation platform. Security fixes target the current +release line only. + +## Reporting a vulnerability + +Do not disclose suspected vulnerabilities through a public issue. + +Report them privately to `jens@itworx.tech` with: + +- the affected revision, endpoint or component; +- reproduction steps and prerequisites; +- the observed and expected behaviour; +- the security impact; +- a minimal proof of concept, without unnecessary personal or secret data. + +Receipt should be acknowledged within three business days. An initial +assessment or request for additional evidence should follow within ten +business days. Remediation timing depends on severity and reproducibility. + +## Scope + +In scope: + +- MobilityOps backend, frontend, container and deployment code; +- authentication, authorization, tenant boundaries and audit integrity; +- database, outbox, backup and restore behaviour; +- MobilityOps-owned n8n workflow definitions; +- RAGcore and MCP Hub integration boundaries implemented in this repository. + +Out of scope: + +- denial-of-service or destructive testing against the hosted demo; +- social engineering, credential stuffing or physical attacks; +- synthetic demo-data exposure without a security-boundary failure; +- vulnerabilities solely inside RAGcore, ITWorx MCP Hub, n8n or another + third-party service. Report those to their respective owners. + +Do not access data beyond what is required to demonstrate the issue, modify +shared infrastructure, interrupt other services or retain obtained secrets. + +## Coordinated disclosure + +Good-faith research that respects this policy and applicable law will be +handled constructively. Allow a reasonable remediation period before public +disclosure. Submitted reports and evidence are used only for investigation, +remediation and verification. diff --git a/artifacts/final-acceptance/summary.md b/artifacts/final-acceptance/summary.md index af04152..e6c7a4c 100644 --- a/artifacts/final-acceptance/summary.md +++ b/artifacts/final-acceptance/summary.md @@ -1,265 +1,38 @@ -# MobilityOps — final acceptance audit summary +# Fleet Ops release acceptance -This audit was run after M0–M7 had already been implemented and committed, specifically -to independently re-verify the finished system end to end rather than trust the -milestone-by-milestone build log. It found and fixed one real category of defect -(`mypy` had never been run across the whole build) and confirmed everything else — every -user journey, every button/filter/form, both external-dependency degraded modes, secret -hygiene, and the clean-checkout path — works as documented. +This file is release-scoped evidence, not a timeless claim. Older evidence under +`artifacts/evidence/` is historical. Exact commands and production revisions are recorded +in `PROJECT_STATE.md`. -## Final commit +## 2026-08-21 release candidate -This audit's fixes are committed as the commit immediately following -`108b5d04fc6f7c5ff9c47009032d6469df29cf3c` ("M7: portfolio polish and final acceptance"). -Run `git log -1 --format="%H %s"` for the exact hash. +- Backend: **271/271** tests passed against an isolated clean PostgreSQL database. +- Browser acceptance: **155/155** Chromium tests passed in 4.6 minutes. +- Live-safe browser canary: **4/4** passed across Chromium and Firefox against the local + deployed stack; unlike the acceptance suite, it never resets or mutates demo records. +- Accessibility: the principal login, dashboard, data-quality, knowledge, automation and + audit routes have no automated critical/serious WCAG 2 A/AA/2.1 AA violations. +- Frontend: TypeScript, ESLint, production build, dependency audit and per-asset JS/CSS + budgets passed; committed visual baselines cover the public entry and engineering story. +- Contracts: committed OpenAPI, event schema, MCP tools and all five n8n definitions match + their code/manifest sources. +- Recovery: a custom-format PostgreSQL dump was restored into a disposable database; the + Alembic revision and non-zero canonical table counts matched the source database. +- Operations: Prometheus/Alertmanager configuration validation passed, including the + watchdog and authenticated n8n receiver route. -## Exact commands executed +## Evidence boundary -Clean-checkout drill (run twice during this audit, most recently against fully wiped -Docker volumes): +The complete local suite uses the deterministic provider and an isolated database so it is +repeatable and safely destructive. Production verification is deliberately smaller and +non-destructive; it verifies the real RAGcore/MCP/n8n health surfaces without resetting the +shared demo. A successful local result is never presented as proof that an external service +was live. The production subsection is added only after the exact committed release is +deployed and observed. -```bash -git status # working tree clean before starting -docker compose down -v # wipe all volumes — genuinely clean state -cp .env.example .env -docker compose up --build -d # migrations run automatically (backend/entrypoint.sh) -docker compose exec api python -m app.cli seed --reset -docker compose run --rm api pytest -q -docker compose run --rm api ruff check . -docker compose run --rm api mypy app -cd frontend && npm run build -cd frontend && npx playwright test -``` +## Remaining product boundary -n8n one-time setup (owner account via browser at `http://localhost:5678/setup`, then): - -```bash -docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json -docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing -docker compose restart n8n -``` - -Degraded-mode drills: - -```bash -docker compose stop n8n # then register a return via the API — commits, event stays pending -docker compose start n8n # dispatcher self-heals, no manual intervention -docker compose run --rm -e KNOWLEDGE_PROVIDER=ragcore -e RAGCORE_BASE_URL=http://ragcore-not-reachable:9999 \ - api python -c "from app.services.knowledge import get_knowledge_provider; ..." -``` - -## Test and validation results - -| Check | Command | Result | -|---|---|---| -| Backend unit/integration tests | `docker compose run --rm api pytest -q` | **66 passed**, 0 failed, 0 skipped | -| Backend lint | `docker compose run --rm api ruff check .` | **All checks passed** | -| Backend type check | `docker compose run --rm api mypy app` | **Success: no issues found in 44 source files** (found and fixed 43 pre-existing errors this audit — see below) | -| Frontend build + typecheck | `cd frontend && npm run build` | Clean (`tsc -b && vite build`, zero errors) | -| End-to-end (Playwright) | `cd frontend && npx playwright test` | **12 passed** (`demo.spec.ts` — full 9-step demo script; `interactive-elements.spec.ts` — 11 tests covering every nav item, filter, tab, and role boundary) | -| Clean-checkout migrations | `docker compose down -v && docker compose up --build -d` | 11 tables created automatically, `alembic current` → `e7b08389f47f (head)`, zero manual step | -| Deterministic seed | `docker compose exec api python -m app.cli seed --reset` | `users:2 customers:180 vehicles:50 bookings:246 inspections:75 maintenance:40 data_quality_issues:26 workflow_runs:20` — identical across every reseed this session | -| Secret scan | `git ls-files \| grep -x .env`; `git log --all -p -- '*.env'`; history grep for AWS/private-key/`sk-` patterns | No `.env` ever committed; no secrets found in history | - -### mypy defects found and fixed (the one real gap this audit uncovered) - -`mypy` is a declared dev dependency (`backend/pyproject.toml`) but was never added to any -milestone's validation loop — only `ruff` was run throughout M0–M7. Running it cold -surfaced 43 errors across 10 files. All were triaged and fixed (not suppressed): - -- **Two genuine defensive-programming gaps**, not just type-annotation issues: - - `app/services/returns.py`: the vehicle lookup after acquiring the row lock had no - `None` guard; a dangling FK would have crashed with an unhandled 500 instead of a - clean `404 VEHICLE_NOT_FOUND`. Fixed. - - `app/api/routers/bookings.py`: same pattern in `get_booking` for the customer/vehicle - lookups — now returns a clean `500` with a message instead of an `AttributeError`. -- `app/api/deps.py` / `app/api/routers/demo.py`: `CurrentUser.role` is validated by - Pydantic at runtime already, but `get_current_user` now explicitly checks role - membership before construction, turning a would-be unhandled `ValidationError` into a - clean `401` for a corrupted/tampered session cookie. -- Two instances of reusing one variable name for both a `Vehicle` and a `Customer` across - branches (`dashboard.py`, `data_quality.py`) — renamed for clarity, not just to satisfy - mypy. -- `Booking.__table__.update()` / `Customer.__table__.update()` switched to the idiomatic - `sqlalchemy.update(Model)` construct (also fixes the type error). -- Remainder: deprecated `conint()` → `Annotated[int, Field(...)]`, a `Sequence` vs `list` - `.sort()` call, an `assert`-guarded None-narrow after a `WHERE ... IS NOT NULL` filter - mypy can't see through, and a couple of narrowly-scoped `# type: ignore[...]` comments - for known SQLAlchemy stub gaps (`Result.rowcount`). - -`make lint` now runs `ruff check .` **and** `mypy app`. - -## Application URLs and ports - -| Service | URL | Notes | -|---|---|---| -| Web (React SPA) | `http://localhost:1228` | nginx-served static build | -| API | `http://localhost:8128` | FastAPI, `/health` for liveness | -| API docs | `http://localhost:8128/docs` | auto-generated OpenAPI/Swagger UI | -| n8n | `http://localhost:5678` | requires one-time owner setup, see below | -| PostgreSQL | `localhost:5432` (container-internal only, no host port published) | | - -## Demo users and access method - -No passwords. Two demo-role buttons on `http://localhost:1228/login`: - -- **Open as Operations Manager** → `USR-OPS`, "Amelie De Ridder". Full access: dashboard, - data-quality resolution/merge, automation retry, demo reset, MCP/service-token routes - are separate (not user-facing). -- **Open as Rental Employee** → `USR-EMP`, "Karim Boujaddaine". Can register returns and - browse vehicles/bookings/knowledge; Automation page is visible but shows a - role-restricted message instead of the delivery table (enforced both in the UI and by - the backend's `require_operations_manager` dependency — verified by - `test_retry_requires_operations_manager` and the e2e role-restriction test). - -Session is an HMAC-signed, `HttpOnly` cookie (`app/core/security.py`) — a demo mechanism, -not a real identity provider (documented as a known limitation). - -## Implemented functionality - -- Operations dashboard with 100% database-backed metrics, attention items linking to the - underlying data-quality issue, "today" departures/returns, and recent automation runs. -- Vehicle and booking list/detail pages with working filters (status, attention-only) and - a tabbed vehicle detail view (overview/bookings/inspections/maintenance/quality). -- Full transactional vehicle-return workflow: row-locked, idempotent by - `Idempotency-Key`, canonical-odometer regression handling (never silently lowers the - canonical value), vehicle status derivation, two audit events, and a schema-compliant - outbox event — verified against real concurrent submissions (1×201 + 2×409). -- Invalid-mileage rejection: negative values and non-numeric input both correctly - rejected with `422` and a precise Pydantic validation message. -- Data Quality Workbench: five deterministic rules (duplicate customer via TF-IDF-style - weighted signal scoring, missing required field, odometer regression, booking overlap, - vehicle status conflict), issue list/detail/defer/reject, and a two-column - duplicate-customer compare-and-merge UI with an inline (non-native-dialog) confirmation - step, transactional booking rewiring, and audit logging. -- Full audit trail: every significant action (login, return, vehicle status change, - data-quality issue lifecycle, customer merge, workflow retry, demo reset, n8n - callback, MCP tool request, knowledge question) is recorded with actor, correlation ID, - and before/after state; filterable by action. -- Knowledge Assistant: deterministic TF-IDF-weighted extractive retrieval over the 10 - procedure documents — never generative, always cites real excerpts, and honestly - reports `insufficient`/`unavailable` states rather than fabricating an answer. -- n8n automation: background outbox dispatcher (`FOR UPDATE SKIP LOCKED` claim, - exponential backoff, no DB transaction held during the HTTP call), a live-verified - round trip through an actual n8n workflow, manual retry for failed deliveries, and an - Automation page (Operations Manager only) showing all runs with filtering. -- Four read-only, service-token-authenticated MCP Hub provider endpoints, each recording - its own service-request audit event, with zero write/mutation endpoints anywhere in - that namespace. -- Responsive UI verified down to 360px width (nav wraps, tables become cards, metric - tiles reflow to a 2-column grid, no horizontal overflow) — both by an automated - Playwright viewport/overflow assertion and by a captured screenshot. - -## RAGcore integration status: implemented, not live-verified - -The active `KnowledgeProvider` in this environment is `DemoKnowledgeProvider` — fully -implemented, fully tested, fully live-verified, and what actually satisfies the -knowledge-assistant acceptance criteria. A `RAGcoreKnowledgeProvider` HTTP adapter also -exists (`app/services/knowledge/ragcore.py`), targeting a best-effort contract inferred -from `contracts/ragcore-contract-assumptions.md` (no live RAGcore API spec was available). -Its **unavailable-degradation path is live-verified this audit**: pointed at an -unreachable host, it returns `{"evidence_state": "unavailable", "answer": "", "sources": -[]}` with no fabrication, exactly as required — but an actual successful round trip -against a real RAGcore instance has never been performed, because no such instance was -reachable in this environment. - -## MCP Hub integration status: implemented, not live-verified - -All four contracted read-only tools (`mobilityops_get_operations_summary`, -`mobilityops_list_attention_vehicles`, `mobilityops_get_vehicle_details`, -`mobilityops_search_knowledge`) are implemented as service-token-protected endpoints under -`/api/v1/integrations/mcp/`, directly `curl`-verified this audit (auth enforcement, -correct data shape, no write methods, service-request audit logging). No live ITWorx MCP -Hub instance was reachable in this environment, so an actual Hub-mediated tool call was -never performed — only direct calls to MobilityOps's own provider API. - -## n8n integration status: implemented and fully live-verified - -The only external integration with a real, running counterpart service available in this -environment. Fully verified this audit, including both success and degraded paths: - -- **Success**: a real return registered via the API was delivered by the background - dispatcher to an actual n8n instance (owner account + imported/activated workflow), - which called back into MobilityOps and was recorded `succeeded`. -- **Degraded mode**: `docker compose stop n8n`, then a return was registered — it - **committed successfully** (`201`, booking `status: returned` persisted) exactly as - required by the architecture's reliability boundary ("a return command and its outbox - event commit in one transaction" and "n8n failure leaves events pending with bounded - retries"). The outbox event stayed `pending` with two real `ConnectError`s logged and - exponential backoff. -- **Self-healing**: restarting n8n required no manual intervention — the background - dispatcher picked the pending event back up on its next poll cycle and delivered it to - `succeeded` (5 total attempts across the outage). -- **Manual retry (S5 scenario)**: a seeded `failed` delivery, retried from the Automation - page, moved to `pending` and was delivered to `succeeded` by the live dispatcher within - one poll cycle. - -## Known limitations - -- RAGcore and the ITWorx MCP Hub were never reachable in this build/audit environment; - both integrations are implemented and tested against inferred/documented contracts but - not verified against real instances of those systems (see above). -- n8n requires a one-time, per-fresh-environment manual owner-account setup through its - own web UI (`http://localhost:5678/setup`) — a property of the n8n 2.x image itself - (`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass. - The workflow import/activation itself *is* scripted (`make n8n-setup`). -- Demo authentication is an HMAC-signed session cookie tied to two fixed seeded users — - appropriate for a PoC, not a production identity provider. -- Inspection public references are assigned via a simple `count + 1` sequence, not - gap-safe under true concurrent writers (acceptable for this single-tenant demo). -- The five data-quality rules use a simplified idempotency key - (`rule_type, entity_type, entity_id` while open) rather than the spec's literal - evidence-fingerprint scheme — documented rationale in `PROJECT_STATE.md`'s M3 notes. -- `npm audit` reports one residual moderate `esbuild`/Vite-8 dev-server-only advisory - (fixable only by a Vite major version bump) and one high `react-router` RSC-mode - advisory that does not apply to this app (it never uses React Router's RSC/SSR mode). - -## Clean deployment instructions - -```bash -git clone && cd MobilityOps -cp .env.example .env -make demo # build, start, migrate (automatic), seed -``` - -One-time n8n setup (only needed for the automation demo path; everything else works -without it): - -```bash -# open http://localhost:5678/setup in a browser, create any owner account -# (8+ chars, 1 number, 1 capital letter — no email verification required) -make n8n-setup -``` - -Verify: - -```bash -curl http://localhost:8128/health # {"status":"ok",...} -curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200 -make test # 66 backend tests -make lint # ruff + mypy, zero errors -make e2e # 12 Playwright tests (stack must be running) -``` - -Full detail, recovery expectations, and required operational checks: `docs/17-runbook.md`. - -## Five-minute demonstration flow - -1. Open `http://localhost:1228` → **Open as Operations Manager**. -2. **Dashboard**: point out the metrics are live counts (available/rented/cleaning/ - maintenance/blocked vehicles, open quality issues, pending/failed workflows), and the - Attention Required list linking straight to the underlying issues. -3. **Vehicles → MO-024** → open the active booking `BK-DEMO-RETURN`, register a return - with an odometer reading below MO-024's canonical value → the result panel shows the - inspection, the derived vehicle status, the automatically-created data-quality issue, - and the queued automation event — canonical odometer is confirmed unchanged. -4. **Data Quality → DQ-DEMO-DUPLICATE**: the two-column CUS-0012/CUS-0178 comparison, - merge with the inline confirmation step, issue flips to `resolved`. -5. **Knowledge**: ask "What must I do when a vehicle returns with damage?" → grounded - answer citing both the return and damage-handling procedures with real excerpts. -6. **Automation**: filter to `failed`, retry the seeded delivery, watch it succeed within - a few seconds via the live n8n instance. -7. **Audit**: filter by `return_registered` or `customer_merged` to show every action from - this walkthrough is recorded with actor, timestamp, and correlation ID. -8. Resize the browser to 360px width to show the responsive layout (nav wraps, tables - become cards) — or run `make e2e` and point at the passing responsive assertion. +Fleet Ops remains a synthetic single-tenant PoC. It is not a production identity provider, +payment system, accounting package or public reservation platform. External RAGcore, MCP +Hub and n8n services remain independently operated dependencies and are accessed only +through their documented adapters. diff --git a/backend/Dockerfile b/backend/Dockerfile index 621680f..d4ede03 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,10 @@ FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base +ARG VCS_REF=development +ARG BUILD_DATE=unknown +LABEL org.opencontainers.image.title="Fleet Ops API" \ + org.opencontainers.image.revision="$VCS_REF" \ + org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.source="https://fleetops.itworx.tech" ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app COPY backend/requirements-prod.lock ./ diff --git a/backend/alembic.ini b/backend/alembic.ini index 642a96b..5aabe3a 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -1,7 +1,7 @@ [alembic] script_location = alembic prepend_sys_path = . -version_path_separator = os +path_separator = os [loggers] keys = root,sqlalchemy,alembic diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7135be6..c11e462 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): ragcore_api_token: str = "" ragcore_space_id: str = "" ragcore_http_timeout_seconds: float = 5.0 + ragcore_answers_circuit_breaker_seconds: float = 60.0 # Search fallback is only labelled grounded above this explicit retrieval threshold. # RAGcore's fused score is reciprocal-rank based (top ranks are ~1/61), so this # accepts only leading results while still rejecting absent and low-ranked evidence. diff --git a/backend/app/core/observability.py b/backend/app/core/observability.py index df05e44..eddafa7 100644 --- a/backend/app/core/observability.py +++ b/backend/app/core/observability.py @@ -36,6 +36,16 @@ DATABASE_READY = Gauge( "mobilityops_database_ready", "Whether the canonical PostgreSQL database answered the most recent readiness probe.", ) +KNOWLEDGE_PROVIDER_REQUESTS = Counter( + "mobilityops_knowledge_provider_requests_total", + "RAGcore adapter requests by stage and outcome.", + ("stage", "outcome"), +) +KNOWLEDGE_RETRIEVAL_SCORE = Histogram( + "mobilityops_knowledge_retrieval_score", + "Observed RAGcore fused/rerank retrieval scores.", + buckets=(0.005, 0.01, 0.015, 0.016, 0.0162, 0.0164, 0.02, 0.05, 0.1, 0.5, 1.0), +) class JsonFormatter(logging.Formatter): diff --git a/backend/app/main.py b/backend/app/main.py index ef2260d..2ae98b4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -59,6 +59,11 @@ production = settings.mobilityops_env.lower() == "production" app = FastAPI( title=f"{PRODUCT_NAME} API", version="0.1.0", + description=( + "Generated contract for Fleet Ops. The visible product name is Fleet Ops; " + "MobilityOps remains the technical repository and service identifier." + ), + servers=[{"url": "http://localhost:8128"}], lifespan=lifespan, docs_url=None if production else "/docs", redoc_url=None if production else "/redoc", diff --git a/backend/app/services/knowledge/ragcore.py b/backend/app/services/knowledge/ragcore.py index f1193c8..23dcc97 100644 --- a/backend/app/services/knowledge/ragcore.py +++ b/backend/app/services/knowledge/ragcore.py @@ -8,6 +8,7 @@ from time import monotonic import httpx from app.core.config import get_settings +from app.core.observability import KNOWLEDGE_PROVIDER_REQUESTS, KNOWLEDGE_RETRIEVAL_SCORE from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents @@ -137,6 +138,22 @@ class RAGcoreKnowledgeProvider: self._settings = get_settings() self._verification_cache: dict[str, tuple[float, int]] = {} self._verification_lock = Lock() + self._answers_circuit_lock = Lock() + self._answers_circuit_open_until = 0.0 + + def _answers_circuit_is_open(self) -> bool: + with self._answers_circuit_lock: + return monotonic() < self._answers_circuit_open_until + + def _open_answers_circuit(self) -> None: + with self._answers_circuit_lock: + self._answers_circuit_open_until = monotonic() + max( + 0.0, self._settings.ragcore_answers_circuit_breaker_seconds + ) + + def _close_answers_circuit(self) -> None: + with self._answers_circuit_lock: + self._answers_circuit_open_until = 0.0 def _client(self) -> httpx.Client: headers = {} @@ -264,9 +281,12 @@ class RAGcoreKnowledgeProvider: if not self._settings.ragcore_space_id: return unavailable - answered = self._ask_via_answers(question, correlation_id) - if answered is not None: - return answered + if self._answers_circuit_is_open(): + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc() + else: + answered = self._ask_via_answers(question, correlation_id) + if answered is not None: + return answered # /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to # real retrieval rather than degrading straight to "unavailable". This never # fabricates an answer to the question: it only ever shows an actually-cited @@ -290,9 +310,13 @@ class RAGcoreKnowledgeProvider: }, ) if response.status_code != 200: + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc() + self._open_answers_circuit() return None body = response.json() except (httpx.HTTPError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc() + self._open_answers_circuit() return None try: @@ -311,6 +335,8 @@ class RAGcoreKnowledgeProvider: answerability = body.get("answerability", "not_answerable") is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient" + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc() + self._close_answers_circuit() return GroundedAnswer( answer=body.get("answer", "") if evidence_state == "grounded" else "", evidence_state=evidence_state, @@ -319,6 +345,8 @@ class RAGcoreKnowledgeProvider: correlation_id=correlation_id, ) except (TypeError, KeyError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc() + self._open_answers_circuit() return None def _ask_via_search_fallback( @@ -342,9 +370,11 @@ class RAGcoreKnowledgeProvider: }, ) if response.status_code != 200: + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc() return unavailable body = response.json() except (httpx.HTTPError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc() return unavailable try: @@ -362,8 +392,14 @@ class RAGcoreKnowledgeProvider: for result in results ] except (TypeError, KeyError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc() return unavailable + for result in results: + score = _retrieval_score(result) + if score is not None: + KNOWLEDGE_RETRIEVAL_SCORE.observe(score) + all_sources = _deduplicate_sources(sources) concepts = _question_concepts(question) qualified_sources = [ @@ -379,6 +415,7 @@ class RAGcoreKnowledgeProvider: ] sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts) if not all_sources: + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc() return GroundedAnswer( answer="", evidence_state="insufficient", @@ -396,6 +433,7 @@ class RAGcoreKnowledgeProvider: for term in _DOMAIN_CONCEPTS["damage"] ) if not concepts or not sources or ("damage" in concepts and not damage_evidence): + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc() return GroundedAnswer( answer="", evidence_state="insufficient", @@ -407,6 +445,7 @@ class RAGcoreKnowledgeProvider: template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE]) lead = sources[0] answer = template.format(title=lead.title, excerpt=lead.excerpt) + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc() return GroundedAnswer( answer=answer, evidence_state="grounded", diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index 60f5246..2a5503a 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -616,6 +616,35 @@ def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypa ) +def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr(provider._settings, "ragcore_answers_circuit_breaker_seconds", 60.0) + search_response = _FakeResponse(200, _search_body()) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": search_response, + } + ), + ) + assert ( + provider.ask("What is the vehicle return procedure?", "first").evidence_state + == "grounded" + ) + + monkeypatch.setattr( + provider, + "_ask_via_answers", + lambda *_args: (_ for _ in ()).throw(AssertionError("open circuit called answers")), + ) + second = provider.ask("What is the vehicle return procedure?", "second") + assert second.evidence_state == "grounded" + + def test_ragcore_provider_fallback_answer_is_localized(monkeypatch): provider = RAGcoreKnowledgeProvider() monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") diff --git a/backend/tests/test_observability.py b/backend/tests/test_observability.py index 04a5f37..e3519f6 100644 --- a/backend/tests/test_observability.py +++ b/backend/tests/test_observability.py @@ -29,6 +29,8 @@ def test_metrics_expose_http_database_and_outbox_state(client): assert "mobilityops_http_requests_total" in response.text assert "mobilityops_database_ready 1.0" in response.text assert 'mobilityops_outbox_events{scenario="synthetic",status="failed"}' in response.text + assert "mobilityops_knowledge_provider_requests_total" in response.text + assert "mobilityops_knowledge_retrieval_score" in response.text def test_metrics_token_is_enforced_when_configured(client, monkeypatch): diff --git a/compose.observability.yaml b/compose.observability.yaml index e8906f7..e61799a 100644 --- a/compose.observability.yaml +++ b/compose.observability.yaml @@ -15,6 +15,33 @@ services: restart: unless-stopped networks: [mobilityops] + alertmanager: + image: prom/alertmanager:v0.33.1@sha256:9e082985f56f4c8c9f724e18f2288c6708f472e56a5286b8863d080434ea065d + profiles: ["observability"] + entrypoint: ["/bin/sh", "-ec"] + command: + - >- + sed -e "s|__WEBHOOK_URL__|$${ALERTMANAGER_WEBHOOK_URL}|g" + -e "s|__WEBHOOK_TOKEN__|$${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN}|g" + /etc/alertmanager/template.yml > /tmp/alertmanager.yml + && exec /bin/alertmanager --config.file=/tmp/alertmanager.yml + --storage.path=/alertmanager + environment: + ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-https://n8n.itworx.tech/webhook/mobilityops-alerts} + MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:?Set the n8n webhook trigger token} + volumes: + - ./deploy/observability/alertmanager.yml:/etc/alertmanager/template.yml:ro + - mobilityops-alertmanager:/alertmanager + ports: + - "127.0.0.1:19093:9093" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9093/-/ready"] + interval: 15s + timeout: 5s + retries: 10 + restart: unless-stopped + networks: [mobilityops] + grafana: image: grafana/grafana:12.2.0@sha256:74144189b38447facf737dfd0f3906e42e0776212bf575dc3334c3609183adf7 profiles: ["observability"] @@ -29,10 +56,11 @@ services: - mobilityops-grafana:/var/lib/grafana ports: - "127.0.0.1:13000:3000" - depends_on: [prometheus] + depends_on: [prometheus, alertmanager] restart: unless-stopped networks: [mobilityops] volumes: mobilityops-prometheus: mobilityops-grafana: + mobilityops-alertmanager: diff --git a/compose.release.yaml b/compose.release.yaml new file mode 100644 index 0000000..755ea10 --- /dev/null +++ b/compose.release.yaml @@ -0,0 +1,14 @@ +services: + api: + image: ${MOBILITYOPS_API_IMAGE:?Set MOBILITYOPS_API_IMAGE to an immutable release image} + build: !reset null + + web: + image: ${MOBILITYOPS_WEB_IMAGE:?Set MOBILITYOPS_WEB_IMAGE to an immutable release image} + build: !reset null + + backup: + volumes: !override + - ${MOBILITYOPS_BACKUP_DIR:?Set MOBILITYOPS_BACKUP_DIR to the persistent backup directory}:/backups + - ${MOBILITYOPS_BACKUP_SECONDARY_DIR:?Set MOBILITYOPS_BACKUP_SECONDARY_DIR to the independent secondary directory}:/offsite + - ./deploy/unraid:/opt/mobilityops:ro diff --git a/compose.unraid.yaml b/compose.unraid.yaml index beb67b5..27492ae 100644 --- a/compose.unraid.yaml +++ b/compose.unraid.yaml @@ -47,6 +47,7 @@ services: BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30} BACKUP_MINIMUM_COPIES: ${BACKUP_MINIMUM_COPIES:-7} + BACKUP_RESTORE_DRILL_INTERVAL_SECONDS: ${BACKUP_RESTORE_DRILL_INTERVAL_SECONDS:-604800} command: ["/opt/mobilityops/scheduled-backup.sh"] volumes: - ./backups/postgres:/backups @@ -56,7 +57,11 @@ services: db: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "find /backups/latest-success -mmin -1560 -print -quit | grep -q ."] + test: + [ + "CMD-SHELL", + "find /backups/latest-success -mmin -1560 -print -quit | grep -q . && find /backups/latest-restore-drill -mmin -11520 -print -quit | grep -q .", + ] interval: 30m timeout: 5s retries: 3 diff --git a/compose.yaml b/compose.yaml index 43b9423..e7c2cc5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,6 +18,9 @@ services: build: context: . dockerfile: backend/Dockerfile + args: + VCS_REF: ${VCS_REF:-development} + BUILD_DATE: ${BUILD_DATE:-unknown} environment: MOBILITYOPS_ENV: ${MOBILITYOPS_ENV:-development} MOBILITYOPS_DEMO_MODE: ${MOBILITYOPS_DEMO_MODE:-true} @@ -32,6 +35,7 @@ services: RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures} RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-} RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-} + RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS: ${RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS:-60} N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return} N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token} N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token} @@ -82,6 +86,8 @@ services: # Empty by default: the SPA calls its own origin and nginx proxies /api to the API, # which is what the CSP (connect-src 'self') expects. Only set this for split hosting. VITE_API_BASE_URL: ${VITE_API_BASE_URL:-} + VCS_REF: ${VCS_REF:-development} + BUILD_DATE: ${BUILD_DATE:-unknown} ports: - "1228:80" depends_on: diff --git a/contracts/mcp-tools.json b/contracts/mcp-tools.json index b952e1b..2d2ffe7 100644 --- a/contracts/mcp-tools.json +++ b/contracts/mcp-tools.json @@ -67,6 +67,7 @@ }, "additionalProperties": false }, + "endpoint": {"method": "POST", "path": "/api/v1/integrations/mcp/search-knowledge"}, "routing": { "preferred": "ragcore", "tenant": "northstar-mobility-demo", diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index d5269a4..b5e136a 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -1,9 +1,11 @@ openapi: 3.1.0 info: title: Fleet Ops API + description: Generated contract for Fleet Ops. The visible product name is Fleet + Ops; MobilityOps remains the technical repository and service identifier. version: 0.1.0 - description: Generated contract for Fleet Ops. The visible product name is Fleet Ops; MobilityOps remains - the technical repository and service identifier. +servers: +- url: http://localhost:8128 paths: /health: get: @@ -22,7 +24,8 @@ paths: /health/live: get: summary: Liveness - description: Process liveness only; external dependencies deliberately do not affect it. + description: Process liveness only; external dependencies deliberately do not + affect it. operationId: liveness_health_live_get responses: '200': @@ -37,7 +40,8 @@ paths: /health/ready: get: summary: Readiness - description: 'Traffic readiness: the API is useful only while its canonical database responds.' + description: 'Traffic readiness: the API is useful only while its canonical + database responds.' operationId: readiness_health_ready_get responses: '200': @@ -622,7 +626,8 @@ paths: type: array items: $ref: '#/components/schemas/AvailableVehicleOut' - title: Response List Available Vehicles Api V1 Bookings Availability Get + title: Response List Available Vehicles Api V1 Bookings Availability + Get '422': description: Validation Error content: @@ -1331,7 +1336,8 @@ paths: tags: - data-quality summary: Status Recommendation - description: 'Non-mutating preview: computes the recommendation without changing anything, + description: 'Non-mutating preview: computes the recommendation without changing + anything, resolving no issue and writing no audit event. Safe to call repeatedly.' operationId: status_recommendation_api_v1_data_quality_issues__public_ref__status_recommendation_post @@ -1462,7 +1468,8 @@ paths: tags: - integrations summary: Workflow Heartbeat - description: Authenticated, idempotent execution evidence from a canonical n8n workflow. + description: Authenticated, idempotent execution evidence from a canonical n8n + workflow. operationId: workflow_heartbeat_api_v1_integrations_n8n_heartbeat_post parameters: - name: X-Service-Token @@ -1523,7 +1530,8 @@ paths: schema: type: object additionalProperties: true - title: Response Return Callback Api V1 Integrations N8N Return Callback Post + title: Response Return Callback Api V1 Integrations N8N Return Callback + Post '422': description: Validation Error content: @@ -1535,13 +1543,17 @@ paths: tags: - integrations summary: Scheduled Scan - description: 'Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and + description: 'Triggered by the scheduled n8n quality-scan workflow. Narrow, + read-mostly, and - safe to call repeatedly: run_scan() only ever creates an issue for a condition that + safe to call repeatedly: run_scan() only ever creates an issue for a condition + that - doesn''t already have one open, so a duplicate or overlapping trigger does no + doesn''t already have one open, so a duplicate or overlapping trigger does + no - duplicate domain work -- it just reports zero new issues for anything already known.' + duplicate domain work -- it just reports zero new issues for anything already + known.' operationId: scheduled_scan_api_v1_integrations_n8n_scheduled_scan_post parameters: - name: X-Service-Token @@ -1568,11 +1580,14 @@ paths: tags: - integrations summary: Workflow Error - description: 'Receives a bounded, secret-free failure report from the central n8n "Fleet Ops -- + description: 'Receives a bounded, secret-free failure report from the central + n8n "Fleet Ops -- - Workflow Error Handler" workflow, which is attached as the Error Workflow on every + Workflow Error Handler" workflow, which is attached as the Error Workflow + on every - other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same + other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver + the same error report (e.g. after a timed-out response), so this must not double-record.' operationId: workflow_error_api_v1_integrations_n8n_workflow_error_post @@ -1607,7 +1622,8 @@ paths: tags: - integrations summary: List Procedures - description: 'Read-only source list for the RAGcore Procedure Sync workflow: every procedure + description: 'Read-only source list for the RAGcore Procedure Sync workflow: + every procedure Markdown file Fleet Ops ships, across every supported language, with a stable @@ -1640,11 +1656,14 @@ paths: tags: - integrations summary: Procedures Sync Result - description: 'Receives a summary (counts only, no document content) from the n8n "Fleet Ops -- + description: 'Receives a summary (counts only, no document content) from the + n8n "Fleet Ops -- - RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore. + RAGcore Procedure Sync" workflow once it finishes uploading procedures to + RAGcore. - Idempotent on execution_id, matching the workflow-error and return-callback pattern.' + Idempotent on execution_id, matching the workflow-error and return-callback + pattern.' operationId: procedures_sync_result_api_v1_integrations_n8n_procedures_sync_result_post parameters: - name: X-Service-Token @@ -1880,7 +1899,8 @@ paths: type: array items: $ref: '#/components/schemas/AttentionVehicleOut' - title: Response Attention Vehicles Api V1 Integrations Mcp Attention Vehicles Get + title: Response Attention Vehicles Api V1 Integrations Mcp Attention + Vehicles Get '422': description: Validation Error content: @@ -4192,7 +4212,8 @@ components: description: 'Body of the n8n return follow-up callback. - n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are ignored; + n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are + ignored; only the fields we persist are validated and bounded.' ReturnPreviewResult: @@ -4800,5 +4821,3 @@ components: - execution_id - occurred_at title: WorkflowErrorReportResult -servers: -- url: http://localhost:8128 diff --git a/deploy/observability/alertmanager.yml b/deploy/observability/alertmanager.yml new file mode 100644 index 0000000..8fe4192 --- /dev/null +++ b/deploy/observability/alertmanager.yml @@ -0,0 +1,21 @@ +route: + receiver: fleetops-owner + group_by: [alertname, severity] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + +receivers: + - name: fleetops-owner + webhook_configs: + - url: __WEBHOOK_URL__ + send_resolved: true + http_config: + http_headers: + X-Fleet-Ops-Trigger-Token: + secrets: [__WEBHOOK_TOKEN__] + +inhibit_rules: + - source_matchers: ['alertname="MobilityOpsApiDown"'] + target_matchers: ['severity="warning"'] + equal: [alertname] diff --git a/deploy/observability/alerts.yml b/deploy/observability/alerts.yml index 8bace51..c74dd95 100644 --- a/deploy/observability/alerts.yml +++ b/deploy/observability/alerts.yml @@ -1,6 +1,12 @@ groups: - name: mobilityops rules: + - alert: FleetOpsWatchdog + expr: vector(1) + labels: {severity: none} + annotations: + summary: Fleet Ops alert delivery watchdog + description: This alert must always be visible at the configured receiver. - alert: MobilityOpsApiDown expr: up{job="mobilityops-api"} == 0 for: 2m diff --git a/deploy/observability/prometheus.yml b/deploy/observability/prometheus.yml index 891e406..75e2894 100644 --- a/deploy/observability/prometheus.yml +++ b/deploy/observability/prometheus.yml @@ -5,6 +5,11 @@ global: rule_files: - /etc/prometheus/alerts.yml +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + scrape_configs: - job_name: mobilityops-api metrics_path: /metrics diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index e387238..b88a22c 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -17,8 +17,9 @@ existing shared n8n remains available on its established port 5678. Create `.env` from `.env.example`, replace every placeholder secret, set `MOBILITYOPS_ENV=production`, set both public URLs to -`https://fleetops.itworx.tech`, set `SESSION_COOKIE_SECURE=true`, and retain -`KNOWLEDGE_PROVIDER=demo` while RAGcore is not available. The internal `1236` listener is +`https://fleetops.itworx.tech`, set `SESSION_COOKIE_SECURE=true`, and configure +`KNOWLEDGE_PROVIDER=ragcore` only after the RAGcore health and source inventory checks pass. +The internal `1236` listener is an upstream for the TLS proxy, not a user-facing URL. ```bash @@ -26,11 +27,16 @@ cd /mnt/user/appdata/mobilityops ./deploy/unraid/configure-env.sh \ https://fleetops.itworx.tech \ https://n8n.itworx.tech/webhook/mobilityops-return -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web backup -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \ - python -m app.cli seed --reset +git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD +sha256sum /tmp/mobilityops-source.tar.gz +# Copy the archive and run deploy-release.sh with its SHA-256 and full Git SHA. ``` +`deploy-release.sh` stages a clean, commit-named release, builds OCI-labelled immutable +API/web images, promotes without a seed/reset, and verifies migrations, readiness, +backups and observability. Run `python -m app.cli seed --reset` only for initial setup or +an explicit synthetic-demo reset; it is never part of a routine deployment. + Migrations run automatically in the API entrypoint. Import and publish the MobilityOps workflow into the existing n8n container: @@ -57,17 +63,21 @@ docker logs --tail=200 n8n ## Backup and restore The `backup` service creates a backup immediately and then every 24 hours. Every dump is -validated with `pg_restore --list`, receives a SHA-256 sidecar and is retained for 30 days -with at least seven copies protected from pruning. Its healthcheck becomes unhealthy when -no successful backup has been recorded for 26 hours. Configure +checked by SHA-256 and `pg_restore --list`; at least weekly the newest dump is also restored +into a disposable database and its migration revision and core table counts are verified. +Backups are retained for 30 days with at least seven copies protected from pruning. Its +healthcheck becomes unhealthy when the daily backup or eight-day restore-drill SLA is missed. Configure `BACKUP_SECONDARY_DESTINATION=/offsite` plus an independently mounted `MOBILITYOPS_BACKUP_SECONDARY_DIR` for a second copy. -Create an additional on-demand backup or verify the newest scheduled backup: +Create an additional on-demand backup, verify the newest backup, or execute the isolated +restore drill: ```bash ./deploy/unraid/backup-postgres.sh ./deploy/unraid/verify-postgres-backups.sh +docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T backup \ + /opt/mobilityops/restore-drill-postgres.sh /backups/.dump ``` A restore is deliberately guarded and creates an additional safety backup before diff --git a/deploy/unraid/deploy-release.sh b/deploy/unraid/deploy-release.sh new file mode 100755 index 0000000..8a2ec7c --- /dev/null +++ b/deploy/unraid/deploy-release.sh @@ -0,0 +1,88 @@ +#!/bin/sh +set -eu + +# Stage a committed source archive, build commit-labelled immutable images and promote +# them without overlaying source files or reseeding persisted data. +archive="${1:-}" +expected_checksum="${2:-}" +revision="${3:-}" +root="${MOBILITYOPS_DEPLOY_ROOT:-/mnt/user/appdata/mobilityops}" +project="${COMPOSE_PROJECT_NAME:-mobilityops}" + +[ "${#revision}" -eq 40 ] || { echo "Revision must be a full Git SHA" >&2; exit 1; } +case "$revision" in *[!0-9a-f]*) echo "Revision must be lowercase hexadecimal" >&2; exit 1;; esac +[ -f "$archive" ] || { echo "Archive not found: $archive" >&2; exit 1; } +[ -n "$expected_checksum" ] || { echo "Expected SHA-256 is required" >&2; exit 1; } +[ -f "$root/.env" ] || { echo "Production .env is missing" >&2; exit 1; } + +actual_checksum="$(sha256sum "$archive" | awk '{print $1}')" +[ "$actual_checksum" = "$expected_checksum" ] || { + echo "Archive checksum mismatch" >&2 + exit 1 +} + +release_root="$root/.deploy/releases" +release_dir="$release_root/$revision" +mkdir -p "$release_root" +if [ -e "$release_dir" ]; then + echo "Release directory already exists; refusing to overwrite: $release_dir" >&2 + exit 1 +fi +mkdir "$release_dir" +tar -xzf "$archive" -C "$release_dir" + +short_revision="$(printf '%s' "$revision" | cut -c1-12)" +build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +api_image="mobilityops-api:$short_revision" +web_image="mobilityops-web:$short_revision" + +docker build --target runtime --build-arg "VCS_REF=$revision" --build-arg "BUILD_DATE=$build_date" \ + --tag "$api_image" --file "$release_dir/backend/Dockerfile" "$release_dir" +docker build --build-arg "VCS_REF=$revision" --build-arg "BUILD_DATE=$build_date" \ + --tag "$web_image" "$release_dir/frontend" + +for image in "$api_image" "$web_image"; do + labelled_revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" + [ "$labelled_revision" = "$revision" ] || { + echo "Image revision label mismatch for $image" >&2 + exit 1 + } +done + +compose="docker compose --env-file $root/.env -p $project -f $release_dir/compose.yaml -f $release_dir/compose.unraid.yaml -f $release_dir/compose.observability.yaml -f $release_dir/compose.release.yaml --profile observability" +old_api_id="$(docker inspect --format '{{.Image}}' "$project-api-1" 2>/dev/null || true)" +old_web_id="$(docker inspect --format '{{.Image}}' "$project-web-1" 2>/dev/null || true)" +export MOBILITYOPS_API_IMAGE="$api_image" MOBILITYOPS_WEB_IMAGE="$web_image" +$compose up --no-build -d api web backup prometheus alertmanager grafana + +attempt=0 +until curl -fsS http://127.0.0.1:1236/health/ready > /dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + if [ -n "$old_api_id" ] && [ -n "$old_web_id" ]; then + rollback_api="mobilityops-api:rollback-$short_revision" + rollback_web="mobilityops-web:rollback-$short_revision" + docker tag "$old_api_id" "$rollback_api" + docker tag "$old_web_id" "$rollback_web" + export MOBILITYOPS_API_IMAGE="$rollback_api" MOBILITYOPS_WEB_IMAGE="$rollback_web" + $compose up --no-build -d api web || true + fi + echo "Release failed readiness; source revision was not promoted" >&2 + exit 1 + fi + sleep 2 +done +$compose exec -T api alembic current +for service in backup prometheus alertmanager grafana; do + attempt=0 + until status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$project-$service-1" 2>/dev/null)" \ + && { [ "$status" = "healthy" ] || [ "$status" = "running" ]; }; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 60 ] || { echo "$service did not become healthy" >&2; exit 1; } + sleep 5 + done +done +printf '%s\n' "$revision" > "$root/.deploy/source-revision" +printf '%s %s %s %s\n' "$revision" "$api_image" "$web_image" "$expected_checksum" \ + >> "$root/.deploy/release-history.log" +echo "Promoted Fleet Ops release $revision" diff --git a/deploy/unraid/restore-drill-postgres.sh b/deploy/unraid/restore-drill-postgres.sh new file mode 100755 index 0000000..bfe3ae9 --- /dev/null +++ b/deploy/unraid/restore-drill-postgres.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +backup_file="${1:-}" +[ -f "$backup_file" ] || { echo "Restore-drill backup not found: $backup_file" >&2; exit 1; } + +timestamp="$(date -u +%Y%m%d%H%M%S)" +drill_database="mobilityops_restore_drill_$timestamp" +source_database="${POSTGRES_DB:-mobilityops}" +database_host="${POSTGRES_HOST:-db}" + +cleanup() { + dropdb --if-exists --force --host="$database_host" --username="$POSTGRES_USER" \ + "$drill_database" > /dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +source_revision="$(psql --host="$database_host" --username="$POSTGRES_USER" \ + --dbname="$source_database" --tuples-only --no-align \ + --command='SELECT version_num FROM alembic_version')" +[ -n "$source_revision" ] || { echo "Source Alembic revision is unavailable" >&2; exit 1; } + +createdb --host="$database_host" --username="$POSTGRES_USER" "$drill_database" +pg_restore --no-owner --no-acl --host="$database_host" --username="$POSTGRES_USER" \ + --dbname="$drill_database" "$backup_file" + +restored_revision="$(psql --host="$database_host" --username="$POSTGRES_USER" \ + --dbname="$drill_database" --tuples-only --no-align \ + --command='SELECT version_num FROM alembic_version')" +[ "$restored_revision" = "$source_revision" ] || { + echo "Restored Alembic revision does not match production" >&2 + exit 1 +} + +counts="$(psql --host="$database_host" --username="$POSTGRES_USER" \ + --dbname="$drill_database" --tuples-only --no-align --field-separator=, \ + --command='SELECT (SELECT count(*) FROM users), (SELECT count(*) FROM vehicles), (SELECT count(*) FROM bookings), (SELECT count(*) FROM audit_events)')" +case "$counts" in + 0,*|*,0,*|*,*,0,*|*,*,*,0) echo "Restored database is missing canonical records: $counts" >&2; exit 1 ;; +esac + +cleanup +trap - EXIT INT TERM +echo "Restore drill passed: revision=$restored_revision counts=$counts" diff --git a/deploy/unraid/scheduled-backup.sh b/deploy/unraid/scheduled-backup.sh index 9c0b8f4..205fd1b 100755 --- a/deploy/unraid/scheduled-backup.sh +++ b/deploy/unraid/scheduled-backup.sh @@ -6,9 +6,10 @@ secondary="${BACKUP_SECONDARY_DESTINATION:-}" interval="${BACKUP_INTERVAL_SECONDS:-86400}" retention_days="${BACKUP_RETENTION_DAYS:-30}" minimum_copies="${BACKUP_MINIMUM_COPIES:-7}" +restore_drill_interval="${BACKUP_RESTORE_DRILL_INTERVAL_SECONDS:-604800}" case "$destination" in ""|"/"|".") echo "Unsafe backup destination: $destination" >&2; exit 1;; esac -case "$interval:$retention_days:$minimum_copies" in *[!0-9:]*|:*|*:) echo "Backup settings must be integers" >&2; exit 1;; esac +case "$interval:$retention_days:$minimum_copies:$restore_drill_interval" in *[!0-9:]*|:*|*:) echo "Backup settings must be integers" >&2; exit 1;; esac mkdir -p "$destination" [ -z "$secondary" ] || mkdir -p "$secondary" @@ -24,6 +25,7 @@ while true; do (cd "$destination" && sha256sum "$(basename "$target")" > "$(basename "$target").sha256") if [ -n "$secondary" ]; then cp "$target" "$target.sha256" "$secondary/" + (cd "$secondary" && sha256sum -c "$(basename "$target.sha256")") fi date -u +%Y-%m-%dT%H:%M:%SZ > "$destination/latest-success" BACKUP_RETENTION_DAYS="$retention_days" BACKUP_MINIMUM_COPIES="$minimum_copies" \ @@ -32,6 +34,15 @@ while true; do BACKUP_RETENTION_DAYS="$retention_days" BACKUP_MINIMUM_COPIES="$minimum_copies" \ /opt/mobilityops/prune-postgres-backups.sh "$secondary" fi + drill_minutes=$((restore_drill_interval / 60)) + if [ ! -f "$destination/latest-restore-drill" ] \ + || ! find "$destination/latest-restore-drill" -mmin "-$drill_minutes" -print -quit | grep -q .; then + if /opt/mobilityops/restore-drill-postgres.sh "$target"; then + date -u +%Y-%m-%dT%H:%M:%SZ > "$destination/latest-restore-drill" + else + echo "Restore drill failed for $target" >&2 + fi + fi echo "Verified database backup: $target" else rm -f "$temporary" diff --git a/deploy/unraid/setup-alert-receiver.sh b/deploy/unraid/setup-alert-receiver.sh new file mode 100755 index 0000000..9b7362d --- /dev/null +++ b/deploy/unraid/setup-alert-receiver.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +container_name="${1:-n8n}" +source_workflow="${2:-n8n/workflows/fleet-ops-alert-receiver.json}" +[ -f "$source_workflow" ] || { echo "Missing workflow export: $source_workflow" >&2; exit 1; } +docker inspect "$container_name" >/dev/null 2>&1 || { echo "n8n container not found" >&2; exit 1; } + +container_workflow="/tmp/fleet-ops-alert-receiver.json" +trap 'docker exec "$container_name" rm -f "$container_workflow" >/dev/null 2>&1 || true' EXIT +docker cp "$source_workflow" "$container_name:$container_workflow" >/dev/null +docker exec "$container_name" n8n import:workflow --input="$container_workflow" +echo "Imported Fleet Ops alert receiver. Resolve the named Header Auth credential and publish the workflow." diff --git a/deploy/unraid/verify-postgres-backups.sh b/deploy/unraid/verify-postgres-backups.sh index 38d9032..75da09d 100755 --- a/deploy/unraid/verify-postgres-backups.sh +++ b/deploy/unraid/verify-postgres-backups.sh @@ -9,4 +9,7 @@ latest="$(find "$destination" -maxdepth 1 -type f -name 'mobilityops-*.dump' | s (cd "$destination" && sha256sum -c "$(basename "$latest.sha256")") docker compose -p "${COMPOSE_PROJECT_NAME:-mobilityops}" \ -f compose.yaml -f compose.unraid.yaml exec -T db pg_restore --list < "$latest" > /dev/null -printf 'Verified: %s\n' "$latest" +docker compose -p "${COMPOSE_PROJECT_NAME:-mobilityops}" \ + -f compose.yaml -f compose.unraid.yaml exec -T backup \ + /opt/mobilityops/restore-drill-postgres.sh "/backups/$(basename "$latest")" +printf 'Verified and restore-drilled: %s\n' "$latest" diff --git a/docs/19-visual-product-roadmap.md b/docs/19-visual-product-roadmap.md index ca1d24e..386d480 100644 --- a/docs/19-visual-product-roadmap.md +++ b/docs/19-visual-product-roadmap.md @@ -1,5 +1,9 @@ # MobilityOps visual product roadmap +> Status (2026-08-21): R0–R6 have been implemented. The observations below are the +> historical baseline that drove those changes, not a description of the current UI. +> Current release evidence lives in `artifacts/final-acceptance/summary.md`. + ## Purpose This roadmap turns the visual audit of the deployed MobilityOps PoC into an diff --git a/docs/demo-release/demo-runbook.md b/docs/demo-release/demo-runbook.md index deff09a..ec3f82b 100644 --- a/docs/demo-release/demo-runbook.md +++ b/docs/demo-release/demo-runbook.md @@ -48,34 +48,25 @@ disabled entirely via `DEMO_ALLOW_RESET=false` if an environment must not be reb ## Redeploying to Unraid ```bash -# From a clean local checkout on the target branch/commit: +# From a clean local checkout on the exact commit: git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD -scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source-.tar.gz -ssh unraid "cd /mnt/user/appdata/mobilityops \ - && tar -xzf .deploy/source-.tar.gz \ - && echo > .deploy/source-revision" - -# Rebuild only what changed (api and/or web); db is never rebuilt: -ssh unraid "cd /mnt/user/appdata/mobilityops \ - && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web" - -# Confirm migrations and reseed: -ssh unraid "cd /mnt/user/appdata/mobilityops \ - && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m alembic current \ - && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset" +sha256sum /tmp/mobilityops-source.tar.gz +scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/ +ssh unraid "/mnt/user/appdata/mobilityops/deploy/unraid/deploy-release.sh \ + /mnt/user/appdata/mobilityops/.deploy/mobilityops-source.tar.gz \ + " ``` -Extraction preserves the server's existing `.env` and named volumes (Postgres data, n8n -data) — the tarball never contains `.env` since it's gitignored. Never edit source -directly on the server; never deploy uncommitted changes. +The deployer verifies the archive checksum, extracts into a new commit-named release +directory, builds revision-labelled immutable images, promotes without reseeding, and +checks application, backup and observability health. Reset demo data only as a separate, +deliberate demo-preparation action. ## Rollback -`.deploy/source-revision` on the server records exactly which commit is live. Prior -source tarballs remain in `.deploy/` for rollback: extract an earlier -`source-.tar.gz`, update `source-revision` to match, and re-run the rebuild -step above. Database rollback is out of scope for this demo (migrations are additive; -there has been no destructive migration on this branch). +`.deploy/source-revision` and `.deploy/release-history.log` record the live commit and +image tags. A failed readiness check automatically restores the previous image IDs. +Database rollback remains a separate, explicitly guarded restore operation. ## Server safety (Unraid) @@ -89,9 +80,9 @@ guessed RAGcore/MCP URLs. PostgreSQL is never exposed externally. - **A scenario shows "Niet beschikbaar" on `/scenarios`**: it has already been resolved since the last reset (expected once you've worked through it) — reset to restore it. -- **Knowledge question returns "insufficient evidence"**: check the question is in - English and close to one of the suggested phrasings — the demo knowledge base is - English-only (see `demo-guide.md`). +- **Knowledge question returns "insufficient evidence"**: use a suggested question in + NL, EN or FR and inspect provider health/citations; the adapter refuses ungrounded + answers when RAGcore is slow, unavailable or lacks evidence. - **n8n shows a failed delivery that isn't the seeded demo one**: check `/automation`'s filter and retry — bounded retries mean it should self-heal within `n8n_max_attempts` attempts, or can be retried manually by an Operations Manager. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b0e8016..796bd3b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,6 +11,12 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN npm run build FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 +ARG VCS_REF=development +ARG BUILD_DATE=unknown +LABEL org.opencontainers.image.title="Fleet Ops Web" \ + org.opencontainers.image.revision="$VCS_REF" \ + org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.source="https://fleetops.itworx.tech" COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 diff --git a/frontend/e2e-live/smoke.spec.ts b/frontend/e2e-live/smoke.spec.ts new file mode 100644 index 0000000..ecc28b5 --- /dev/null +++ b/frontend/e2e-live/smoke.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +test("public health and HTTPS-facing shell are available", async ({ page, request, baseURL }) => { + const readiness = await request.get("/health/ready"); + expect(readiness.ok()).toBeTruthy(); + expect(await readiness.json()).toMatchObject({ status: "ready", database: "up" }); + + await page.goto("/login"); + await expect(page).toHaveTitle(/Fleet Ops/); + await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible(); + if (baseURL?.startsWith("https://")) { + expect(page.url()).toMatch(/^https:\/\//); + } +}); + +test("non-destructive operator canary covers routes and grounded knowledge", async ({ page }) => { + const pageErrors: string[] = []; + const serverErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("response", (response) => { + if (response.status() >= 500) serverErrors.push(`${response.status()} ${response.url()}`); + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + + for (const [path, heading] of [ + ["/vehicles", "Wagenpark"], + ["/bookings", "Boekingen"], + ["/data-quality", "Datakwaliteit"], + ["/automation", "Integratiebeheer"], + ["/audit", "Auditgeschiedenis"], + ] as const) { + await page.goto(path); + await expect(page.getByRole("heading", { name: heading }).first()).toBeVisible(); + } + + await page.goto("/knowledge"); + await page + .getByRole("textbox", { name: "Vraag" }) + .fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?"); + await page.getByRole("button", { name: "Vraag stellen" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ + timeout: 20_000, + }); + + expect(pageErrors).toEqual([]); + expect(serverErrors).toEqual([]); +}); diff --git a/frontend/e2e/__screenshots__/highlights-main.png b/frontend/e2e/__screenshots__/highlights-main.png new file mode 100644 index 0000000000000000000000000000000000000000..68941e82493e37cc0de1dee26b9ac319f54fef86 GIT binary patch literal 83029 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≫nvE!saZ(y7#W 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 MN=dRv-0<7~1&8lm3jhEB literal 0 HcmV?d00001 diff --git a/frontend/e2e/__screenshots__/login-access.png b/frontend/e2e/__screenshots__/login-access.png new file mode 100644 index 0000000000000000000000000000000000000000..639975e051ad283bc1a14adfecd9338cb5145aab GIT binary patch literal 29827 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%0AU^B_3RIR>9M{64`0iCdtN_qYhK-62q;YA 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 literal 0 HcmV?d00001 diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts new file mode 100644 index 0000000..90b08d5 --- /dev/null +++ b/frontend/e2e/accessibility.spec.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +import { expect, test } from "@playwright/test"; + +test.use({ bypassCSP: true, reducedMotion: "reduce" }); + +const require = createRequire(import.meta.url); +const axeSource = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8"); + +type AxeViolation = { + id: string; + impact: string | null; + help: string; + nodes: Array<{ target: string[] }>; +}; + +async function seriousViolations(page: import("@playwright/test").Page): Promise { + await page.addStyleTag({ + content: "*,*::before,*::after{animation:none!important;transition:none!important}", + }); + await page.addScriptTag({ content: axeSource }); + return page.evaluate(async () => { + const axe = (window as Window & { + axe: { + run: ( + context: Document, + options: object, + ) => Promise<{ violations: AxeViolation[] }>; + }; + }).axe; + const result = await axe.run(document, { + runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] }, + }); + return result.violations.filter( + (violation) => violation.impact === "critical" || violation.impact === "serious", + ); + }); +} + +test("principal routes have no serious automated accessibility violations", async ({ page }) => { + await page.goto("/login"); + expect(await seriousViolations(page)).toEqual([]); + + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + for (const route of ["/dashboard", "/data-quality", "/knowledge", "/automation", "/audit"] as const) { + await page.goto(route); + const violations = await seriousViolations(page); + expect(violations, `${route}: ${JSON.stringify(violations)}`).toEqual([]); + } +}); diff --git a/frontend/e2e/visual-regression.spec.ts b/frontend/e2e/visual-regression.spec.ts new file mode 100644 index 0000000..a82875f --- /dev/null +++ b/frontend/e2e/visual-regression.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test("public entry and engineering highlights retain their visual hierarchy", async ({ page }) => { + await page.goto("/login"); + await expect(page.locator(".login-access")).toHaveScreenshot("login-access.png", { + animations: "disabled", + maxDiffPixelRatio: 0.02, + }); + + await page.goto("/highlights"); + await expect(page.locator("main")).toHaveScreenshot("highlights-main.png", { + animations: "disabled", + maxDiffPixelRatio: 0.02, + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4927b58..26dc768 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "6.0.5", + "axe-core": "4.13.0", "eslint": "9.39.5", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react-hooks": "7.1.1", diff --git a/frontend/package.json b/frontend/package.json index d71e78b..d5ac201 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc -b && vite build", + "budget": "node ../scripts/check-frontend-budget.mjs", "preview": "vite preview --host 0.0.0.0", "lint": "tsc -b --noEmit && eslint .", "test:e2e": "playwright test" @@ -23,6 +24,7 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "6.0.5", + "axe-core": "4.13.0", "eslint": "9.39.5", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react-hooks": "7.1.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 9e2359a..108705a 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ timeout: 30_000, fullyParallel: false, workers: 1, + snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}", reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], use: { baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228", diff --git a/frontend/playwright.live.config.ts b/frontend/playwright.live.config.ts new file mode 100644 index 0000000..8a704a3 --- /dev/null +++ b/frontend/playwright.live.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e-live", + timeout: 30_000, + fullyParallel: false, + workers: 1, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-live-report" }]], + use: { + baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + ], +}); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4957919..344f31c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -5,7 +5,7 @@ font-synthesis: none; --ink: #0f172a; --ink-soft: #344256; - --muted: #657386; + --muted: #5d6a7c; --muted-light: #94a0af; --canvas: #f5f7fa; --surface: #ffffff; @@ -140,7 +140,7 @@ a:hover { color: var(--teal); } .demo-badge-close { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; } .demo-badge-close svg { width: 14px; height: 14px; } #main-content { width: min(1320px, calc(100% - 56px)); margin: 0 auto; padding: 38px 0 64px; flex: 1; } -.app-footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 28px; color: var(--muted); border-top: 1px solid var(--line); font-size: .68rem; } +.app-footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 28px; color: #5f6d80; border-top: 1px solid var(--line); font-size: .68rem; } .mobile-nav, .nav-scrim { display: none; } .page { animation: page-in .28s ease both; } @@ -231,7 +231,7 @@ a:hover { color: var(--teal); } .integration-list li:last-child, .recent-list li:last-child { border-bottom: 0; }.integration-list li > div, .recent-list li > div { flex: 1; display: grid; gap: 3px; } .integration-list strong, .recent-list strong { font-size: .73rem; }.integration-list div span, .recent-list div span { color: var(--muted); font-size: .65rem; } .integration-mark { width: 31px; height: 31px; display: grid; place-items: center; border-radius: var(--radius); color: white; font-size: .58rem; font-weight: 800; letter-spacing: -.04em; } -.integration-n8n { background: #e85d36; }.integration-rag { background: #4a4b95; }.integration-mcp { background: #314358; } +.integration-n8n { background: #b84424; }.integration-rag { background: #4a4b95; }.integration-mcp { background: #314358; } .activity-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.activity-icon svg { width: 15px; } .recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; } diff --git a/n8n/workflows/MANIFEST.md b/n8n/workflows/MANIFEST.md index 5534d12..e37e39c 100644 --- a/n8n/workflows/MANIFEST.md +++ b/n8n/workflows/MANIFEST.md @@ -1,6 +1,6 @@ # n8n workflow manifest -Source of truth for the four canonical Fleet Ops n8n workflows. Definitions in this +Source of truth for the five canonical Fleet Ops n8n workflows. Definitions in this directory are cleaned exports of the live workflows on `https://n8n.itworx.tech` — credential values are never embedded; nodes reference named n8n credentials instead. Run `n8n/workflows/check_drift.py` to compare a live workflow against its repo definition. @@ -108,3 +108,17 @@ runs from the editor. This is a known limitation of the live-validation evidence round: the mock-data path exercises the same nodes/logic and the real Fleet Ops endpoint, but a fully automatic (schedule- or webhook-triggered) failure cascading into this handler was not observed live. + +## 5. Fleet Ops — Alert Receiver + +| Field | Value | +|---|---| +| File | `fleet-ops-alert-receiver.json` | +| Purpose | Receive validated Alertmanager firing/resolved notifications and route them to the watched owner mailbox. | +| Trigger | Production webhook, `POST /webhook/mobilityops-alerts`, Header Auth (`Fleet Ops Webhook Trigger Token`) | +| Required credentials | `Fleet Ops Webhook Trigger Token` (Header Auth); `M365 n8n Shared Mailbox` (Microsoft Outlook OAuth2) | +| Live workflow ID | `mobilityops-alert-receiver` | +| Active status | Imported inactive by default; production deployment must publish it before enabling Alertmanager. | +| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) | +| Safety | Strict Alertmanager payload shape, bounded to 25 alerts and bounded text fields. | +| Checksum (sha256) | `3672ad3d14b65c603c8c3f2067197cc50372e9d4c2fc314d1c9b6aaf3ecef444` | diff --git a/n8n/workflows/check_drift.py b/n8n/workflows/check_drift.py index 90e2ac0..fe3f303 100644 --- a/n8n/workflows/check_drift.py +++ b/n8n/workflows/check_drift.py @@ -34,6 +34,7 @@ KNOWN_WORKFLOWS = [ ("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"), ("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"), ("fleet-ops-ragcore-procedure-sync.json", "6wbkc4d1AouGpmWT"), + ("fleet-ops-alert-receiver.json", "mobilityops-alert-receiver"), ] # Fields that legitimately differ between a committed definition and the live instance diff --git a/n8n/workflows/fleet-ops-alert-receiver.json b/n8n/workflows/fleet-ops-alert-receiver.json new file mode 100644 index 0000000..0da470e --- /dev/null +++ b/n8n/workflows/fleet-ops-alert-receiver.json @@ -0,0 +1,36 @@ +{ + "id": "mobilityops-alert-receiver", + "name": "Fleet Ops — Alert Receiver", + "active": false, + "nodes": [ + { + "parameters": {"httpMethod": "POST", "path": "mobilityops-alerts", "authentication": "headerAuth", "responseMode": "responseNode", "options": {}}, + "id": "alert-webhook", "name": "Alertmanager webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], "webhookId": "mobilityops-alerts", + "credentials": {"httpHeaderAuth": {"name": "Fleet Ops Webhook Trigger Token"}} + }, + { + "parameters": {"jsCode": "const body = $json.body ?? $json;\nif (!['firing', 'resolved'].includes(body.status) || !Array.isArray(body.alerts)) throw new Error('Invalid Alertmanager payload');\nconst alerts = body.alerts.slice(0, 25).map(a => ({name: String(a.labels?.alertname || 'Unknown alert').slice(0, 120), severity: String(a.labels?.severity || 'unknown').slice(0, 32), summary: String(a.annotations?.summary || '').slice(0, 500)}));\nconst esc = v => String(v).replace(/[&<>\\\"']/g, c => ({'&':'&','<':'<','>':'>','\\\"':'"',\"'\":'''}[c]));\nconst resolved = body.status === 'resolved';\nreturn [{json:{subject:`[Fleet Ops] ${resolved ? 'Hersteld' : 'Alert'} — ${alerts.length} signaal/signalen`,html:`

Fleet Ops ${resolved ? 'hersteld' : 'monitoringalert'}

    ${alerts.map(a => `
  • ${esc(a.severity)} · ${esc(a.name)}
    ${esc(a.summary)}
  • `).join('')}

Ontvangen ${esc($now.toISO())}

`}}];"}, + "id": "validate-format", "name": "Validate and format", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [500, 300] + }, + { + "parameters": {"respondWith": "json", "responseBody": "={{ { accepted: true } }}", "options": {}}, + "id": "accept", "name": "Accept alert", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [740, 300] + }, + { + "parameters": {"toRecipients": "jens@itworx.tech", "subject": "={{ $('Validate and format').item.json.subject }}", "bodyContent": "={{ $('Validate and format').item.json.html }}", "additionalFields": {"from": "n8n@itworx.tech", "bodyContentType": "html"}}, + "id": "email", "name": "Send owner email", "type": "n8n-nodes-base.microsoftOutlook", "typeVersion": 2, "position": [980, 300], + "credentials": {"microsoftOutlookOAuth2Api": {"id": "EDTj3sOsganaoDVL", "name": "M365 n8n Shared Mailbox"}}, + "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000 + } + ], + "connections": { + "Alertmanager webhook": {"main": [[{"node": "Validate and format", "type": "main", "index": 0}]]}, + "Validate and format": {"main": [[{"node": "Accept alert", "type": "main", "index": 0}]]}, + "Accept alert": {"main": [[{"node": "Send owner email", "type": "main", "index": 0}]]} + }, + "settings": {"executionOrder": "v1", "timezone": "Europe/Brussels", "errorWorkflow": "Xppn2rAEqUuyiCJF", "executionTimeout": 120, "saveDataSuccessExecution": "none", "saveDataErrorExecution": "all"}, + "staticData": null, + "meta": {"templateCredsSetupCompleted": false}, + "pinData": {}, + "tags": [] +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7e89204 --- /dev/null +++ b/renovate.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", "docker:pinDigests", "helpers:pinGitHubActionDigests"], + "dependencyDashboard": true, + "minimumReleaseAge": "7 days", + "schedule": ["before 5am on monday"], + "packageRules": [ + { + "matchManagers": ["dockerfile", "docker-compose", "github-actions"], + "groupName": "pinned runtime and CI dependencies" + }, + { + "matchUpdateTypes": ["major"], + "dependencyDashboardApproval": true + } + ] +} diff --git a/scripts/check-contracts.py b/scripts/check-contracts.py new file mode 100755 index 0000000..1519f4f --- /dev/null +++ b/scripts/check-contracts.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Fail when checked-in API, event, MCP or n8n contracts drift from the code.""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +from pathlib import Path + +import yaml + +from app.main import app + +ROOT = Path(__file__).resolve().parents[1] + + +def fail(message: str, failures: list[str]) -> None: + failures.append(message) + + +def check_openapi(failures: list[str]) -> None: + committed = yaml.safe_load((ROOT / "contracts/openapi.yaml").read_text(encoding="utf-8")) + generated = app.openapi() + if committed != generated: + fail( + "contracts/openapi.yaml differs from app.openapi(); regenerate it with " + "scripts/generate-openapi.py", + failures, + ) + + +def check_mcp(failures: list[str]) -> None: + contract = json.loads((ROOT / "contracts/mcp-tools.json").read_text(encoding="utf-8")) + contracted = { + (tool["endpoint"]["method"].upper(), tool["endpoint"]["path"]) + for tool in contract["tools"] + if tool.get("read_only") is True and isinstance(tool.get("endpoint"), dict) + } + generated = app.openapi() + implemented = { + (method.upper(), path) + for path, operations in generated["paths"].items() + if path.startswith("/api/v1/integrations/mcp/") + for method in operations + if method.lower() in {"get", "post", "put", "patch", "delete"} + } + if len(contracted) != len(contract["tools"]): + fail("Every MCP tool must be read-only and declare its provider endpoint", failures) + if contracted != implemented: + fail( + f"MCP endpoint drift: contract={sorted(contracted)!r}, code={sorted(implemented)!r}", + failures, + ) + if any(method not in {"GET", "POST"} for method, _path in contracted): + fail("MCP contract exposes an unsupported mutation method", failures) + + +def check_event_schema(failures: list[str]) -> None: + schema = json.loads((ROOT / "contracts/events.schema.json").read_text(encoding="utf-8")) + expected_envelope = { + "event_id", + "event_type", + "occurred_at", + "correlation_id", + "aggregate", + "data", + } + expected_data = { + "vehicle_ref", + "inspection_ref", + "resulting_vehicle_status", + "attention_reasons", + } + if set(schema.get("required", [])) != expected_envelope: + fail("Event envelope required fields drifted", failures) + data_schema = schema.get("properties", {}).get("data", {}) + if set(data_schema.get("required", [])) != expected_data: + fail("vehicle.returned.v1 data fields drifted", failures) + event_type = schema.get("properties", {}).get("event_type", {}).get("const") + if event_type != "vehicle.returned.v1": + fail("Unexpected event_type contract", failures) + + +def check_workflows(failures: list[str]) -> None: + workflows_dir = ROOT / "n8n/workflows" + manifest = (workflows_dir / "MANIFEST.md").read_text(encoding="utf-8") + expected = { + "fleet-ops-vehicle-return.json": "mobilityops-return-processing", + "fleet-ops-data-quality-scan.json": "mobilityops-scheduled-quality-scan", + "fleet-ops-ragcore-procedure-sync.json": "6wbkc4d1AouGpmWT", + "fleet-ops-error-handler.json": "Xppn2rAEqUuyiCJF", + "fleet-ops-alert-receiver.json": "mobilityops-alert-receiver", + } + declared_hashes = set(re.findall(r"`([0-9a-f]{64})`", manifest)) + for filename, workflow_id in expected.items(): + path = workflows_dir / filename + raw = path.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + if digest not in declared_hashes: + fail(f"{filename} checksum is missing or stale in MANIFEST.md", failures) + definition = json.loads(raw) + if definition.get("id") != workflow_id: + fail(f"{filename} has unexpected workflow id", failures) + serialized = raw.decode("utf-8") + if "http://fleetops.itworx.tech" in serialized: + fail(f"{filename} contains a cleartext Fleet Ops callback", failures) + + +def main() -> int: + failures: list[str] = [] + check_openapi(failures) + check_mcp(failures) + check_event_schema(failures) + check_workflows(failures) + if failures: + for failure in failures: + print(f"ERROR: {failure}", file=sys.stderr) + return 1 + print("OpenAPI, event, MCP and n8n contracts are synchronized.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-frontend-budget.mjs b/scripts/check-frontend-budget.mjs new file mode 100644 index 0000000..fcb408f --- /dev/null +++ b/scripts/check-frontend-budget.mjs @@ -0,0 +1,18 @@ +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; + +const limits = { ".js": 400 * 1024, ".css": 180 * 1024 }; +const assets = join(import.meta.dirname, "..", "frontend", "dist", "assets"); +const failures = []; + +for (const name of await readdir(assets)) { + const extension = Object.keys(limits).find((candidate) => name.endsWith(candidate)); + if (!extension) continue; + const bytes = (await stat(join(assets, name))).size; + console.log(`${name}: ${(bytes / 1024).toFixed(1)} KiB / ${limits[extension] / 1024} KiB`); + if (bytes > limits[extension]) failures.push(name); +} + +if (failures.length) { + throw new Error(`Frontend asset budget exceeded: ${failures.join(", ")}`); +} diff --git a/scripts/check-source-budgets.py b/scripts/check-source-budgets.py new file mode 100755 index 0000000..aebca2e --- /dev/null +++ b/scripts/check-source-budgets.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Prevent known large modules from growing while they are incrementally decomposed.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LINE_LIMITS = { + "backend/app/services/data_quality.py": 950, + "frontend/src/pages/DataQualityIssueDetail.tsx": 850, +} +BYTE_LIMITS = {"frontend/src/styles.css": 84_000} + +failures: list[str] = [] +for relative, limit in LINE_LIMITS.items(): + count = len((ROOT / relative).read_text(encoding="utf-8").splitlines()) + print(f"{relative}: {count}/{limit} lines") + if count > limit: + failures.append(relative) +for relative, limit in BYTE_LIMITS.items(): + count = (ROOT / relative).stat().st_size + print(f"{relative}: {count}/{limit} bytes") + if count > limit: + failures.append(relative) + +if failures: + raise SystemExit("Source budget exceeded; extract a focused module: " + ", ".join(failures)) diff --git a/scripts/generate-openapi.py b/scripts/generate-openapi.py new file mode 100755 index 0000000..7353e0a --- /dev/null +++ b/scripts/generate-openapi.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Regenerate the deterministic checked-in FastAPI OpenAPI contract.""" + +from pathlib import Path + +import yaml + +from app.main import app + +target = Path(__file__).resolve().parents[1] / "contracts/openapi.yaml" +target.write_text( + yaml.safe_dump(app.openapi(), sort_keys=False, allow_unicode=True), + encoding="utf-8", +) +print(target)