M44: harden release integrity and assurance
This commit is contained in:
@@ -34,13 +34,20 @@ LOG_LEVEL=INFO
|
|||||||
METRICS_BEARER_TOKEN=
|
METRICS_BEARER_TOKEN=
|
||||||
GRAFANA_ADMIN_USER=admin
|
GRAFANA_ADMIN_USER=admin
|
||||||
GRAFANA_ADMIN_PASSWORD=change-me-before-start
|
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).
|
# Verified scheduled PostgreSQL backups (Unraid override).
|
||||||
BACKUP_INTERVAL_SECONDS=86400
|
BACKUP_INTERVAL_SECONDS=86400
|
||||||
BACKUP_RETENTION_DAYS=30
|
BACKUP_RETENTION_DAYS=30
|
||||||
BACKUP_MINIMUM_COPIES=7
|
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.
|
# Set both values to copy every verified backup to an independently mounted path.
|
||||||
BACKUP_SECONDARY_DESTINATION=
|
BACKUP_SECONDARY_DESTINATION=
|
||||||
|
MOBILITYOPS_BACKUP_DIR=./backups/postgres
|
||||||
MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite
|
MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite
|
||||||
|
|
||||||
# Privacy governance defaults.
|
# Privacy governance defaults.
|
||||||
@@ -87,6 +94,9 @@ RAGCORE_COLLECTION=internal-procedures
|
|||||||
RAGCORE_API_TOKEN=
|
RAGCORE_API_TOKEN=
|
||||||
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
||||||
RAGCORE_SPACE_ID=
|
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
|
# 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
|
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
|
||||||
|
|||||||
+34
-5
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * 1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend:
|
backend:
|
||||||
@@ -21,8 +23,15 @@ jobs:
|
|||||||
run: |
|
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 --build --rm api ruff check app tests
|
||||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app
|
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
|
- 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)
|
- name: Production API image vulnerability scan (HIGH/CRITICAL)
|
||||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||||
with:
|
with:
|
||||||
@@ -32,6 +41,19 @@ jobs:
|
|||||||
severity: HIGH,CRITICAL
|
severity: HIGH,CRITICAL
|
||||||
exit-code: "1"
|
exit-code: "1"
|
||||||
ignore-unfixed: true
|
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
|
- name: Remove CI stack
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans
|
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
|
run: npm run lint
|
||||||
- name: Typecheck and production build
|
- name: Typecheck and production build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run build
|
run: npm run build && npm run budget
|
||||||
- name: Dependency audit
|
- name: Dependency audit
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm audit --audit-level=high
|
run: npm audit --audit-level=high
|
||||||
@@ -74,7 +96,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose -p mobilityops-e2e up --build -d db api web
|
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
|
if curl -fsS http://localhost:1228/health/ready >/dev/null 2>&1; then break; fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
@@ -84,18 +106,25 @@ jobs:
|
|||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: |
|
run: |
|
||||||
npm ci --no-audit --no-fund
|
npm ci --no-audit --no-fund
|
||||||
npx playwright install --with-deps chromium
|
npx playwright install --with-deps chromium firefox
|
||||||
- name: Run browser acceptance suite
|
- name: Run browser acceptance suite
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
env:
|
env:
|
||||||
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
|
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
|
||||||
run: npx playwright test
|
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
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: frontend/playwright-report
|
path: |
|
||||||
|
frontend/playwright-report
|
||||||
|
frontend/playwright-live-report
|
||||||
- name: Stack logs on failure
|
- name: Stack logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -p mobilityops-e2e logs --tail=200 api web
|
run: docker compose -p mobilityops-e2e logs --tail=200 api web
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,6 +8,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
playwright-live-report/
|
||||||
test-results/
|
test-results/
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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:
|
up:
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
@@ -17,6 +17,11 @@ lint:
|
|||||||
docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app
|
docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app
|
||||||
cd frontend && npm run lint
|
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:
|
seed:
|
||||||
docker compose exec api python -m app.cli seed --reset
|
docker compose exec api python -m app.cli seed --reset
|
||||||
|
|
||||||
@@ -49,3 +54,6 @@ demo: up
|
|||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
cd frontend && npx playwright test
|
cd frontend && npx playwright test
|
||||||
|
|
||||||
|
live-smoke:
|
||||||
|
cd frontend && npx playwright test --config=playwright.live.config.ts
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# Project state
|
# 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)
|
## M43 — publish and redeploy review remediation (2026-08-21)
|
||||||
|
|
||||||
- Published M41 hardening commit `24dcb3494c522fadf536fa9d6826450227aeff4e`
|
- Published M41 hardening commit `24dcb3494c522fadf536fa9d6826450227aeff4e`
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ make e2e # complete Playwright browser acceptance
|
|||||||
cd frontend && npm run build
|
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
|
## 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
|
- `n8n/` — importable workflow definitions for the existing server
|
||||||
- `seed/` — deterministic synthetic dataset
|
- `seed/` — deterministic synthetic dataset
|
||||||
- `docs/` — architecture, security, UX, testing and runbooks
|
- `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.
|
“MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users.
|
||||||
|
|||||||
+56
@@ -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.
|
||||||
@@ -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
|
This file is release-scoped evidence, not a timeless claim. Older evidence under
|
||||||
to independently re-verify the finished system end to end rather than trust the
|
`artifacts/evidence/` is historical. Exact commands and production revisions are recorded
|
||||||
milestone-by-milestone build log. It found and fixed one real category of defect
|
in `PROJECT_STATE.md`.
|
||||||
(`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.
|
|
||||||
|
|
||||||
## Final commit
|
## 2026-08-21 release candidate
|
||||||
|
|
||||||
This audit's fixes are committed as the commit immediately following
|
- Backend: **271/271** tests passed against an isolated clean PostgreSQL database.
|
||||||
`108b5d04fc6f7c5ff9c47009032d6469df29cf3c` ("M7: portfolio polish and final acceptance").
|
- Browser acceptance: **155/155** Chromium tests passed in 4.6 minutes.
|
||||||
Run `git log -1 --format="%H %s"` for the exact hash.
|
- 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
|
The complete local suite uses the deterministic provider and an isolated database so it is
|
||||||
Docker volumes):
|
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
|
## Remaining product boundary
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
n8n one-time setup (owner account via browser at `http://localhost:5678/setup`, then):
|
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
|
||||||
```bash
|
Hub and n8n services remain independently operated dependencies and are accessed only
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
through their documented adapters.
|
||||||
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 <repo> && 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.
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base
|
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
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY backend/requirements-prod.lock ./
|
COPY backend/requirements-prod.lock ./
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[alembic]
|
[alembic]
|
||||||
script_location = alembic
|
script_location = alembic
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = os
|
path_separator = os
|
||||||
|
|
||||||
[loggers]
|
[loggers]
|
||||||
keys = root,sqlalchemy,alembic
|
keys = root,sqlalchemy,alembic
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
|||||||
ragcore_api_token: str = ""
|
ragcore_api_token: str = ""
|
||||||
ragcore_space_id: str = ""
|
ragcore_space_id: str = ""
|
||||||
ragcore_http_timeout_seconds: float = 5.0
|
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.
|
# 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
|
# 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.
|
# accepts only leading results while still rejecting absent and low-ranked evidence.
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ DATABASE_READY = Gauge(
|
|||||||
"mobilityops_database_ready",
|
"mobilityops_database_ready",
|
||||||
"Whether the canonical PostgreSQL database answered the most recent readiness probe.",
|
"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):
|
class JsonFormatter(logging.Formatter):
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ production = settings.mobilityops_env.lower() == "production"
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=f"{PRODUCT_NAME} API",
|
title=f"{PRODUCT_NAME} API",
|
||||||
version="0.1.0",
|
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,
|
lifespan=lifespan,
|
||||||
docs_url=None if production else "/docs",
|
docs_url=None if production else "/docs",
|
||||||
redoc_url=None if production else "/redoc",
|
redoc_url=None if production else "/redoc",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from time import monotonic
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import get_settings
|
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 import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
|
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
|
||||||
|
|
||||||
@@ -137,6 +138,22 @@ class RAGcoreKnowledgeProvider:
|
|||||||
self._settings = get_settings()
|
self._settings = get_settings()
|
||||||
self._verification_cache: dict[str, tuple[float, int]] = {}
|
self._verification_cache: dict[str, tuple[float, int]] = {}
|
||||||
self._verification_lock = Lock()
|
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:
|
def _client(self) -> httpx.Client:
|
||||||
headers = {}
|
headers = {}
|
||||||
@@ -264,9 +281,12 @@ class RAGcoreKnowledgeProvider:
|
|||||||
if not self._settings.ragcore_space_id:
|
if not self._settings.ragcore_space_id:
|
||||||
return unavailable
|
return unavailable
|
||||||
|
|
||||||
answered = self._ask_via_answers(question, correlation_id)
|
if self._answers_circuit_is_open():
|
||||||
if answered is not None:
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
|
||||||
return answered
|
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
|
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
|
||||||
# real retrieval rather than degrading straight to "unavailable". This never
|
# real retrieval rather than degrading straight to "unavailable". This never
|
||||||
# fabricates an answer to the question: it only ever shows an actually-cited
|
# fabricates an answer to the question: it only ever shows an actually-cited
|
||||||
@@ -290,9 +310,13 @@ class RAGcoreKnowledgeProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc()
|
||||||
|
self._open_answers_circuit()
|
||||||
return None
|
return None
|
||||||
body = response.json()
|
body = response.json()
|
||||||
except (httpx.HTTPError, ValueError):
|
except (httpx.HTTPError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc()
|
||||||
|
self._open_answers_circuit()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -311,6 +335,8 @@ class RAGcoreKnowledgeProvider:
|
|||||||
answerability = body.get("answerability", "not_answerable")
|
answerability = body.get("answerability", "not_answerable")
|
||||||
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
|
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
|
||||||
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
|
||||||
|
self._close_answers_circuit()
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||||
evidence_state=evidence_state,
|
evidence_state=evidence_state,
|
||||||
@@ -319,6 +345,8 @@ class RAGcoreKnowledgeProvider:
|
|||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
except (TypeError, KeyError, ValueError):
|
except (TypeError, KeyError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc()
|
||||||
|
self._open_answers_circuit()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _ask_via_search_fallback(
|
def _ask_via_search_fallback(
|
||||||
@@ -342,9 +370,11 @@ class RAGcoreKnowledgeProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc()
|
||||||
return unavailable
|
return unavailable
|
||||||
body = response.json()
|
body = response.json()
|
||||||
except (httpx.HTTPError, ValueError):
|
except (httpx.HTTPError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc()
|
||||||
return unavailable
|
return unavailable
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -362,8 +392,14 @@ class RAGcoreKnowledgeProvider:
|
|||||||
for result in results
|
for result in results
|
||||||
]
|
]
|
||||||
except (TypeError, KeyError, ValueError):
|
except (TypeError, KeyError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
|
||||||
return unavailable
|
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)
|
all_sources = _deduplicate_sources(sources)
|
||||||
concepts = _question_concepts(question)
|
concepts = _question_concepts(question)
|
||||||
qualified_sources = [
|
qualified_sources = [
|
||||||
@@ -379,6 +415,7 @@ class RAGcoreKnowledgeProvider:
|
|||||||
]
|
]
|
||||||
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
|
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
|
||||||
if not all_sources:
|
if not all_sources:
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer="",
|
answer="",
|
||||||
evidence_state="insufficient",
|
evidence_state="insufficient",
|
||||||
@@ -396,6 +433,7 @@ class RAGcoreKnowledgeProvider:
|
|||||||
for term in _DOMAIN_CONCEPTS["damage"]
|
for term in _DOMAIN_CONCEPTS["damage"]
|
||||||
)
|
)
|
||||||
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
|
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer="",
|
answer="",
|
||||||
evidence_state="insufficient",
|
evidence_state="insufficient",
|
||||||
@@ -407,6 +445,7 @@ class RAGcoreKnowledgeProvider:
|
|||||||
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
|
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
|
||||||
lead = sources[0]
|
lead = sources[0]
|
||||||
answer = template.format(title=lead.title, excerpt=lead.excerpt)
|
answer = template.format(title=lead.title, excerpt=lead.excerpt)
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc()
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer=answer,
|
answer=answer,
|
||||||
evidence_state="grounded",
|
evidence_state="grounded",
|
||||||
|
|||||||
@@ -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):
|
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
|
||||||
provider = RAGcoreKnowledgeProvider()
|
provider = RAGcoreKnowledgeProvider()
|
||||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ def test_metrics_expose_http_database_and_outbox_state(client):
|
|||||||
assert "mobilityops_http_requests_total" in response.text
|
assert "mobilityops_http_requests_total" in response.text
|
||||||
assert "mobilityops_database_ready 1.0" 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_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):
|
def test_metrics_token_is_enforced_when_configured(client, monkeypatch):
|
||||||
|
|||||||
@@ -15,6 +15,33 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [mobilityops]
|
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:
|
grafana:
|
||||||
image: grafana/grafana:12.2.0@sha256:74144189b38447facf737dfd0f3906e42e0776212bf575dc3334c3609183adf7
|
image: grafana/grafana:12.2.0@sha256:74144189b38447facf737dfd0f3906e42e0776212bf575dc3334c3609183adf7
|
||||||
profiles: ["observability"]
|
profiles: ["observability"]
|
||||||
@@ -29,10 +56,11 @@ services:
|
|||||||
- mobilityops-grafana:/var/lib/grafana
|
- mobilityops-grafana:/var/lib/grafana
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:13000:3000"
|
- "127.0.0.1:13000:3000"
|
||||||
depends_on: [prometheus]
|
depends_on: [prometheus, alertmanager]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [mobilityops]
|
networks: [mobilityops]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mobilityops-prometheus:
|
mobilityops-prometheus:
|
||||||
mobilityops-grafana:
|
mobilityops-grafana:
|
||||||
|
mobilityops-alertmanager:
|
||||||
|
|||||||
@@ -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
|
||||||
+6
-1
@@ -47,6 +47,7 @@ services:
|
|||||||
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
||||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
|
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
|
||||||
BACKUP_MINIMUM_COPIES: ${BACKUP_MINIMUM_COPIES:-7}
|
BACKUP_MINIMUM_COPIES: ${BACKUP_MINIMUM_COPIES:-7}
|
||||||
|
BACKUP_RESTORE_DRILL_INTERVAL_SECONDS: ${BACKUP_RESTORE_DRILL_INTERVAL_SECONDS:-604800}
|
||||||
command: ["/opt/mobilityops/scheduled-backup.sh"]
|
command: ["/opt/mobilityops/scheduled-backup.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./backups/postgres:/backups
|
- ./backups/postgres:/backups
|
||||||
@@ -56,7 +57,11 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
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
|
interval: 30m
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
|
args:
|
||||||
|
VCS_REF: ${VCS_REF:-development}
|
||||||
|
BUILD_DATE: ${BUILD_DATE:-unknown}
|
||||||
environment:
|
environment:
|
||||||
MOBILITYOPS_ENV: ${MOBILITYOPS_ENV:-development}
|
MOBILITYOPS_ENV: ${MOBILITYOPS_ENV:-development}
|
||||||
MOBILITYOPS_DEMO_MODE: ${MOBILITYOPS_DEMO_MODE:-true}
|
MOBILITYOPS_DEMO_MODE: ${MOBILITYOPS_DEMO_MODE:-true}
|
||||||
@@ -32,6 +35,7 @@ services:
|
|||||||
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
||||||
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
||||||
RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-}
|
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_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_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token}
|
||||||
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-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,
|
# 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.
|
# which is what the CSP (connect-src 'self') expects. Only set this for split hosting.
|
||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-}
|
||||||
|
VCS_REF: ${VCS_REF:-development}
|
||||||
|
BUILD_DATE: ${BUILD_DATE:-unknown}
|
||||||
ports:
|
ports:
|
||||||
- "1228:80"
|
- "1228:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"endpoint": {"method": "POST", "path": "/api/v1/integrations/mcp/search-knowledge"},
|
||||||
"routing": {
|
"routing": {
|
||||||
"preferred": "ragcore",
|
"preferred": "ragcore",
|
||||||
"tenant": "northstar-mobility-demo",
|
"tenant": "northstar-mobility-demo",
|
||||||
|
|||||||
+42
-23
@@ -1,9 +1,11 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Fleet Ops API
|
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
|
version: 0.1.0
|
||||||
description: Generated contract for Fleet Ops. The visible product name is Fleet Ops; MobilityOps remains
|
servers:
|
||||||
the technical repository and service identifier.
|
- url: http://localhost:8128
|
||||||
paths:
|
paths:
|
||||||
/health:
|
/health:
|
||||||
get:
|
get:
|
||||||
@@ -22,7 +24,8 @@ paths:
|
|||||||
/health/live:
|
/health/live:
|
||||||
get:
|
get:
|
||||||
summary: Liveness
|
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
|
operationId: liveness_health_live_get
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -37,7 +40,8 @@ paths:
|
|||||||
/health/ready:
|
/health/ready:
|
||||||
get:
|
get:
|
||||||
summary: Readiness
|
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
|
operationId: readiness_health_ready_get
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -622,7 +626,8 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/AvailableVehicleOut'
|
$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':
|
'422':
|
||||||
description: Validation Error
|
description: Validation Error
|
||||||
content:
|
content:
|
||||||
@@ -1331,7 +1336,8 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- data-quality
|
- data-quality
|
||||||
summary: Status Recommendation
|
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.'
|
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
|
operationId: status_recommendation_api_v1_data_quality_issues__public_ref__status_recommendation_post
|
||||||
@@ -1462,7 +1468,8 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- integrations
|
- integrations
|
||||||
summary: Workflow Heartbeat
|
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
|
operationId: workflow_heartbeat_api_v1_integrations_n8n_heartbeat_post
|
||||||
parameters:
|
parameters:
|
||||||
- name: X-Service-Token
|
- name: X-Service-Token
|
||||||
@@ -1523,7 +1530,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
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':
|
'422':
|
||||||
description: Validation Error
|
description: Validation Error
|
||||||
content:
|
content:
|
||||||
@@ -1535,13 +1543,17 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- integrations
|
- integrations
|
||||||
summary: Scheduled Scan
|
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
|
operationId: scheduled_scan_api_v1_integrations_n8n_scheduled_scan_post
|
||||||
parameters:
|
parameters:
|
||||||
- name: X-Service-Token
|
- name: X-Service-Token
|
||||||
@@ -1568,11 +1580,14 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- integrations
|
- integrations
|
||||||
summary: Workflow Error
|
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.'
|
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
|
operationId: workflow_error_api_v1_integrations_n8n_workflow_error_post
|
||||||
@@ -1607,7 +1622,8 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- integrations
|
- integrations
|
||||||
summary: List Procedures
|
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
|
Markdown file Fleet Ops ships, across every supported language, with a stable
|
||||||
|
|
||||||
@@ -1640,11 +1656,14 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- integrations
|
- integrations
|
||||||
summary: Procedures Sync Result
|
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
|
operationId: procedures_sync_result_api_v1_integrations_n8n_procedures_sync_result_post
|
||||||
parameters:
|
parameters:
|
||||||
- name: X-Service-Token
|
- name: X-Service-Token
|
||||||
@@ -1880,7 +1899,8 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/AttentionVehicleOut'
|
$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':
|
'422':
|
||||||
description: Validation Error
|
description: Validation Error
|
||||||
content:
|
content:
|
||||||
@@ -4192,7 +4212,8 @@ components:
|
|||||||
description: 'Body of the n8n return follow-up callback.
|
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.'
|
only the fields we persist are validated and bounded.'
|
||||||
ReturnPreviewResult:
|
ReturnPreviewResult:
|
||||||
@@ -4800,5 +4821,3 @@ components:
|
|||||||
- execution_id
|
- execution_id
|
||||||
- occurred_at
|
- occurred_at
|
||||||
title: WorkflowErrorReportResult
|
title: WorkflowErrorReportResult
|
||||||
servers:
|
|
||||||
- url: http://localhost:8128
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
groups:
|
groups:
|
||||||
- name: mobilityops
|
- name: mobilityops
|
||||||
rules:
|
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
|
- alert: MobilityOpsApiDown
|
||||||
expr: up{job="mobilityops-api"} == 0
|
expr: up{job="mobilityops-api"} == 0
|
||||||
for: 2m
|
for: 2m
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ global:
|
|||||||
rule_files:
|
rule_files:
|
||||||
- /etc/prometheus/alerts.yml
|
- /etc/prometheus/alerts.yml
|
||||||
|
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: ["alertmanager:9093"]
|
||||||
|
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
- job_name: mobilityops-api
|
- job_name: mobilityops-api
|
||||||
metrics_path: /metrics
|
metrics_path: /metrics
|
||||||
|
|||||||
+19
-9
@@ -17,8 +17,9 @@ existing shared n8n remains available on its established port 5678.
|
|||||||
|
|
||||||
Create `.env` from `.env.example`, replace every placeholder secret, set
|
Create `.env` from `.env.example`, replace every placeholder secret, set
|
||||||
`MOBILITYOPS_ENV=production`, set both public URLs to
|
`MOBILITYOPS_ENV=production`, set both public URLs to
|
||||||
`https://fleetops.itworx.tech`, set `SESSION_COOKIE_SECURE=true`, and retain
|
`https://fleetops.itworx.tech`, set `SESSION_COOKIE_SECURE=true`, and configure
|
||||||
`KNOWLEDGE_PROVIDER=demo` while RAGcore is not available. The internal `1236` listener is
|
`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.
|
an upstream for the TLS proxy, not a user-facing URL.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -26,11 +27,16 @@ cd /mnt/user/appdata/mobilityops
|
|||||||
./deploy/unraid/configure-env.sh \
|
./deploy/unraid/configure-env.sh \
|
||||||
https://fleetops.itworx.tech \
|
https://fleetops.itworx.tech \
|
||||||
https://n8n.itworx.tech/webhook/mobilityops-return
|
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
|
git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \
|
sha256sum /tmp/mobilityops-source.tar.gz
|
||||||
python -m app.cli seed --reset
|
# 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
|
Migrations run automatically in the API entrypoint. Import and publish the MobilityOps
|
||||||
workflow into the existing n8n container:
|
workflow into the existing n8n container:
|
||||||
|
|
||||||
@@ -57,17 +63,21 @@ docker logs --tail=200 n8n
|
|||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
The `backup` service creates a backup immediately and then every 24 hours. Every dump is
|
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
|
checked by SHA-256 and `pg_restore --list`; at least weekly the newest dump is also restored
|
||||||
with at least seven copies protected from pruning. Its healthcheck becomes unhealthy when
|
into a disposable database and its migration revision and core table counts are verified.
|
||||||
no successful backup has been recorded for 26 hours. Configure
|
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
|
`BACKUP_SECONDARY_DESTINATION=/offsite` plus an independently mounted
|
||||||
`MOBILITYOPS_BACKUP_SECONDARY_DIR` for a second copy.
|
`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
|
```bash
|
||||||
./deploy/unraid/backup-postgres.sh
|
./deploy/unraid/backup-postgres.sh
|
||||||
./deploy/unraid/verify-postgres-backups.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/<backup>.dump
|
||||||
```
|
```
|
||||||
|
|
||||||
A restore is deliberately guarded and creates an additional safety backup before
|
A restore is deliberately guarded and creates an additional safety backup before
|
||||||
|
|||||||
Executable
+88
@@ -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"
|
||||||
Executable
+44
@@ -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"
|
||||||
@@ -6,9 +6,10 @@ secondary="${BACKUP_SECONDARY_DESTINATION:-}"
|
|||||||
interval="${BACKUP_INTERVAL_SECONDS:-86400}"
|
interval="${BACKUP_INTERVAL_SECONDS:-86400}"
|
||||||
retention_days="${BACKUP_RETENTION_DAYS:-30}"
|
retention_days="${BACKUP_RETENTION_DAYS:-30}"
|
||||||
minimum_copies="${BACKUP_MINIMUM_COPIES:-7}"
|
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 "$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"
|
mkdir -p "$destination"
|
||||||
[ -z "$secondary" ] || mkdir -p "$secondary"
|
[ -z "$secondary" ] || mkdir -p "$secondary"
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ while true; do
|
|||||||
(cd "$destination" && sha256sum "$(basename "$target")" > "$(basename "$target").sha256")
|
(cd "$destination" && sha256sum "$(basename "$target")" > "$(basename "$target").sha256")
|
||||||
if [ -n "$secondary" ]; then
|
if [ -n "$secondary" ]; then
|
||||||
cp "$target" "$target.sha256" "$secondary/"
|
cp "$target" "$target.sha256" "$secondary/"
|
||||||
|
(cd "$secondary" && sha256sum -c "$(basename "$target.sha256")")
|
||||||
fi
|
fi
|
||||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$destination/latest-success"
|
date -u +%Y-%m-%dT%H:%M:%SZ > "$destination/latest-success"
|
||||||
BACKUP_RETENTION_DAYS="$retention_days" BACKUP_MINIMUM_COPIES="$minimum_copies" \
|
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" \
|
BACKUP_RETENTION_DAYS="$retention_days" BACKUP_MINIMUM_COPIES="$minimum_copies" \
|
||||||
/opt/mobilityops/prune-postgres-backups.sh "$secondary"
|
/opt/mobilityops/prune-postgres-backups.sh "$secondary"
|
||||||
fi
|
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"
|
echo "Verified database backup: $target"
|
||||||
else
|
else
|
||||||
rm -f "$temporary"
|
rm -f "$temporary"
|
||||||
|
|||||||
Executable
+13
@@ -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."
|
||||||
@@ -9,4 +9,7 @@ latest="$(find "$destination" -maxdepth 1 -type f -name 'mobilityops-*.dump' | s
|
|||||||
(cd "$destination" && sha256sum -c "$(basename "$latest.sha256")")
|
(cd "$destination" && sha256sum -c "$(basename "$latest.sha256")")
|
||||||
docker compose -p "${COMPOSE_PROJECT_NAME:-mobilityops}" \
|
docker compose -p "${COMPOSE_PROJECT_NAME:-mobilityops}" \
|
||||||
-f compose.yaml -f compose.unraid.yaml exec -T db pg_restore --list < "$latest" > /dev/null
|
-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"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# MobilityOps visual product roadmap
|
# 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
|
## Purpose
|
||||||
|
|
||||||
This roadmap turns the visual audit of the deployed MobilityOps PoC into an
|
This roadmap turns the visual audit of the deployed MobilityOps PoC into an
|
||||||
|
|||||||
@@ -48,34 +48,25 @@ disabled entirely via `DEMO_ALLOW_RESET=false` if an environment must not be reb
|
|||||||
## Redeploying to Unraid
|
## Redeploying to Unraid
|
||||||
|
|
||||||
```bash
|
```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
|
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-<short-sha>.tar.gz
|
sha256sum /tmp/mobilityops-source.tar.gz
|
||||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/
|
||||||
&& tar -xzf .deploy/source-<short-sha>.tar.gz \
|
ssh unraid "/mnt/user/appdata/mobilityops/deploy/unraid/deploy-release.sh \
|
||||||
&& echo <full-sha> > .deploy/source-revision"
|
/mnt/user/appdata/mobilityops/.deploy/mobilityops-source.tar.gz \
|
||||||
|
<archive-sha256> <full-git-sha>"
|
||||||
# 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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Extraction preserves the server's existing `.env` and named volumes (Postgres data, n8n
|
The deployer verifies the archive checksum, extracts into a new commit-named release
|
||||||
data) — the tarball never contains `.env` since it's gitignored. Never edit source
|
directory, builds revision-labelled immutable images, promotes without reseeding, and
|
||||||
directly on the server; never deploy uncommitted changes.
|
checks application, backup and observability health. Reset demo data only as a separate,
|
||||||
|
deliberate demo-preparation action.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
`.deploy/source-revision` on the server records exactly which commit is live. Prior
|
`.deploy/source-revision` and `.deploy/release-history.log` record the live commit and
|
||||||
source tarballs remain in `.deploy/` for rollback: extract an earlier
|
image tags. A failed readiness check automatically restores the previous image IDs.
|
||||||
`source-<short-sha>.tar.gz`, update `source-revision` to match, and re-run the rebuild
|
Database rollback remains a separate, explicitly guarded restore operation.
|
||||||
step above. Database rollback is out of scope for this demo (migrations are additive;
|
|
||||||
there has been no destructive migration on this branch).
|
|
||||||
|
|
||||||
## Server safety (Unraid)
|
## 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
|
- **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.
|
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
|
- **Knowledge question returns "insufficient evidence"**: use a suggested question in
|
||||||
English and close to one of the suggested phrasings — the demo knowledge base is
|
NL, EN or FR and inspect provider health/citations; the adapter refuses ungrounded
|
||||||
English-only (see `demo-guide.md`).
|
answers when RAGcore is slow, unavailable or lacks evidence.
|
||||||
- **n8n shows a failed delivery that isn't the seeded demo one**: check `/automation`'s
|
- **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
|
filter and retry — bounded retries mean it should self-heal within
|
||||||
`n8n_max_attempts` attempts, or can be retried manually by an Operations Manager.
|
`n8n_max_attempts` attempts, or can be retried manually by an Operations Manager.
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
|
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 nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -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<AxeViolation[]> {
|
||||||
|
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([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+1
@@ -20,6 +20,7 @@
|
|||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.5",
|
"@vitejs/plugin-react": "6.0.5",
|
||||||
|
"axe-core": "4.13.0",
|
||||||
"eslint": "9.39.5",
|
"eslint": "9.39.5",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"budget": "node ../scripts/check-frontend-budget.mjs",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
"lint": "tsc -b --noEmit && eslint .",
|
"lint": "tsc -b --noEmit && eslint .",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.5",
|
"@vitejs/plugin-react": "6.0.5",
|
||||||
|
"axe-core": "4.13.0",
|
||||||
"eslint": "9.39.5",
|
"eslint": "9.39.5",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}",
|
||||||
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
|
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228",
|
baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228",
|
||||||
|
|||||||
@@ -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"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
--ink: #0f172a;
|
--ink: #0f172a;
|
||||||
--ink-soft: #344256;
|
--ink-soft: #344256;
|
||||||
--muted: #657386;
|
--muted: #5d6a7c;
|
||||||
--muted-light: #94a0af;
|
--muted-light: #94a0af;
|
||||||
--canvas: #f5f7fa;
|
--canvas: #f5f7fa;
|
||||||
--surface: #ffffff;
|
--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 { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; }
|
||||||
.demo-badge-close svg { width: 14px; height: 14px; }
|
.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; }
|
#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; }
|
.mobile-nav, .nav-scrim { display: none; }
|
||||||
|
|
||||||
.page { animation: page-in .28s ease both; }
|
.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 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-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-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; }
|
.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; }
|
.recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# n8n workflow manifest
|
# 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` —
|
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
|
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.
|
`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
|
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
|
endpoint, but a fully automatic (schedule- or webhook-triggered) failure cascading into
|
||||||
this handler was not observed live.
|
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` |
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ KNOWN_WORKFLOWS = [
|
|||||||
("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"),
|
("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"),
|
||||||
("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"),
|
("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"),
|
||||||
("fleet-ops-ragcore-procedure-sync.json", "6wbkc4d1AouGpmWT"),
|
("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
|
# Fields that legitimately differ between a committed definition and the live instance
|
||||||
|
|||||||
@@ -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:`<h2>Fleet Ops ${resolved ? 'hersteld' : 'monitoringalert'}</h2><ul>${alerts.map(a => `<li><strong>${esc(a.severity)} · ${esc(a.name)}</strong><br>${esc(a.summary)}</li>`).join('')}</ul><p>Ontvangen ${esc($now.toISO())}</p>`}}];"},
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+126
@@ -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())
|
||||||
@@ -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(", ")}`);
|
||||||
|
}
|
||||||
Executable
+26
@@ -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))
|
||||||
Executable
+15
@@ -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)
|
||||||
Reference in New Issue
Block a user