n8n: store cleaned workflow definitions as repo source of truth

Move the two live-validated workflows into n8n/workflows/ (credential-
based auth referenced by name only, no secret values), add a manifest
covering all 4 canonical workflows and a read-only drift-check script
against n8n's Public API. Retire the pre-integration root-level starter
files that still carried the literal-token pattern, and repoint the
Unraid deploy scripts, Makefile targets and runbook at the new files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-04 13:34:51 +02:00
co-authored by Claude Sonnet 5
parent 59cb4c062e
commit e0c107a94a
12 changed files with 410 additions and 165 deletions
+4 -1
View File
@@ -60,7 +60,10 @@
- `knowledge/procedures/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.md`
- `n8n/README.md`
- `n8n/mobilityops-return-processing.json`
- `n8n/workflows/MANIFEST.md`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/check_drift.py`
- `seed/README.md`
- `seed/bookings.csv`
- `seed/customers.csv`
+7 -4
View File
@@ -26,16 +26,19 @@ reset:
# One-time per environment: imports and activates the n8n return-processing workflow.
# The n8n owner account itself cannot be scripted safely and must be created once at
# http://localhost:5678/setup (any email/password, no verification required) before
# this target's activation takes effect. See docs/17-runbook.md.
# this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
n8n-setup:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n
# One-time per environment: imports and activates the scheduled quality-scan workflow.
# Same owner-account precondition as n8n-setup above.
# Same owner-account and credential preconditions as n8n-setup above (this workflow only
# needs "Fleet Ops Service Token").
n8n-setup-scan:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n
+84 -1
View File
@@ -993,7 +993,12 @@ below.
via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops
final localization" entry below for the next (small correction) round on top of this.
## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — IN PROGRESS on fix/fleet-ops-final-i18n-ux
## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — MERGED TO MASTER
Merged to master via `5f0eaa5`; final evidence commit `c0995b7` added
`artifacts/fleet-ops-final-localization/final-summary.md`. Master head at merge:
`c0995b762e1cbf37172a08e03645baa6b66aa8d5`. Details below are the in-progress working log
kept for reference.
Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head
(`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the
@@ -1074,3 +1079,81 @@ product scope.
`a7ac5ed` (docs), `7851e80` (11D/11F i18n tests), `cda2c32` (clean-checkout
evidence), `2e4fb43` (evidence-summary localization fix, found live on Unraid).
Deployed commit: `2e4fb43f093bfbdb04c4f74eed1e6c6d9a03c069`.
## Live n8n + RAGcore integration (2026-08-04) — IN PROGRESS on feat/live-n8n-ragcore-integration
Branch `feat/live-n8n-ragcore-integration`, from master `c0995b7`. Full brief: treat n8n
(`https://n8n.itworx.tech`, existing shared instance) as a third integration layer
alongside RAGcore and MCP Hub, owning process orchestration only — Fleet Ops keeps all
business rules, authorization, transactions, audit and idempotency. Four canonical
workflows required: (1) Vehicle Return Orchestration, (2) Scheduled Data Quality Scan —
both pre-existing and now hardened; (3) RAGcore Procedure Sync, (4) Workflow Error
Handler — both net-new, not yet built.
- **Current-state audit**: `docs/live-ai-integration/n8n-current-state.md` documents the
live instance (reachable, production webhook base
`http://192.168.10.150:5678/webhook/mobilityops-return`), both existing workflows'
full node structure, and the findings that drove the security fixes below (webhook
Authentication was `None`; both HTTP nodes had `X-Service-Token` hardcoded as a literal
header value instead of a credential).
- **Security fixes applied and live-validated** (commits `b79d485`, `59cb4c0`): webhook
trigger now requires Header Auth (credential `Fleet Ops Webhook Trigger Token`, a new
token generated this round — value stored in `.env`/Unraid `.env` only, never
printed); the outbound callback HTTP node now uses a `Fleet Ops Service Token` Header
Auth credential instead of a literal header value (existing secret copied
clipboard-to-clipboard, never typed/echoed). Backend: `X-Fleet-Ops-Trigger-Token`
header added to the outbox dispatcher's POST (`backend/app/services/dispatcher.py`),
plus a new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting/env var. Also hardened
`_deliver_one` to treat a 2xx response with a non-JSON-object body as a retryable
failure (`malformedResponse`) instead of an unhandled exception — a real failure mode
hit live when a workflow errors before its "Respond to Webhook" node runs; regression
test `test_deliver_one_treats_empty_2xx_body_as_failure` added. Live-validated: curl
probe without the header → `403`; with the header → pass-through; one real end-to-end
vehicle return produced one correct execution visible in both n8n and Fleet Ops
Audit/Automation. Both workflows explicitly `Publish`ed after the fixes (the editor
does not go live on save alone) and both canonical-renamed ("Fleet Ops — Vehicle
Return Orchestration", "Fleet Ops — Scheduled Data Quality Scan").
- **RAGcore real contract discovered** (not the speculative one the adapter was built
against): OpenAPI at `/openapi.json`, health at `/health/live`/`/health/ready` (not
`/health`), ingestion via `POST /v1/uploads`, answers via `POST /v1/answers` with
`requested_space_ids`, control-plane endpoints require an `Idempotency-Key` header.
Bootstrapped a `fleet-ops` application + knowledge space + grant on the real server at
`http://192.168.10.150:1237`. **Blocked**: credential issuance for that application
failed identically via both the raw API and the admin UI ("authoritative
service-account state rejected issuance") — an apparent privilege boundary beyond the
interactive admin session. User chose to issue the credential themselves via another
mechanism and hand over the token; not yet received. `RAGcoreKnowledgeProvider`
(`backend/app/services/knowledge/ragcore.py`) still targets the old speculative
endpoints and needs fixing once that token arrives — approved, not started.
- **Repository source of truth started** (task in progress): `n8n/workflows/` now holds
cleaned definitions for workflows 1-2 — `fleet-ops-vehicle-return.json` (sha256
`e13a3087269fc97019a7adf6c6a6a4ee4bd354c2dd7167d4966d4753a48e970e`),
`fleet-ops-data-quality-scan.json` (sha256
`cc30b28b07dad9f9908a6ea0c564ec4c2f362a3ed71b7e97a7b6894408bb7e2e`) — both credential
auth referenced by name only, no secret values. Reconstructed from direct verified
inspection of every live node, **not** a literal n8n export/download: the UI's "..."
menu has no Download option in this n8n version, and clipboard-based
copy/`navigator.clipboard.readText()` extraction timed out twice. Flagged as a known
limitation for the final evidence doc. `n8n/workflows/MANIFEST.md` records canonical
name/purpose/trigger/contract/credentials/live ID/active-status/checksum for all 4
workflows (3-4 marked not-yet-built). `n8n/workflows/check_drift.py` compares a repo
definition against the live workflow via n8n's Public API (`X-N8N-API-KEY`, read-only,
never auto-overwrites). The old root-level `n8n/mobilityops-return-processing.json`
and `n8n/mobilityops-scheduled-quality-scan.json` (pre-integration starters, still
carrying the literal-token pattern) are removed; `deploy/unraid/setup-existing-n8n.sh`,
`setup-scheduled-scan.sh`, `Makefile` (`n8n-setup`, `n8n-setup-scan`) and
`docs/17-runbook.md` updated to import from `n8n/workflows/` and to document the
now-required manual credential-creation step (credentials are never scripted or
committed).
- **Explicitly deferred/forbidden this phase** (per brief): daily AI ops brief, email,
Slack, automatic vehicle-status changes, customer communication, billing, general
monitoring, autonomous MCP actions. An automatic demo-reset workflow may only be
prepared, not activated, once Fleet Ops goes public.
- **Exact next action**: build workflow 4 (Workflow Error Handler) — does not depend on
the blocked RAGcore token, so it proceeds now. Workflow 3 (RAGcore Procedure Sync) and
the `RAGcoreKnowledgeProvider` adapter fix stay blocked until the user supplies a
working RAGcore application credential/token. After both new workflows exist: update
the Fleet Ops integration status page (task #88), run the full 4-workflow
testing/acceptance pass (task #90), and write
`artifacts/live-ai-integration/final-summary.md` (task #91, explicitly: no credentials
or secret values in that file).
+14 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1
fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
if [ -z "$callback_token" ]; then
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
exit 1
fi
# The workflow file no longer carries the callback token as a literal header value -- both
# the webhook trigger and the outbound callback authenticate via named n8n Header Auth
# credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
# exist in the target n8n instance before this workflow is activated; see the echo below.
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-return-processing.json"
cleanup() {
@@ -32,15 +30,17 @@ cleanup() {
}
trap cleanup EXIT INT TERM
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
jq --arg callback_url "$callback_url" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
docker restart "$container_name" >/dev/null
echo "Published MobilityOps return workflow to existing container ${container_name}"
echo "Imported Fleet Ops — Vehicle Return Orchestration into container ${container_name}."
echo "Before activating: in the n8n UI, create Header Auth credentials named"
echo " 'Fleet Ops Webhook Trigger Token' (value = MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN from .env)"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "those secret values, and does not restart the container -- restart it yourself once"
echo "credentials are wired up and the workflow is published, if required."
+13 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}"
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
source_workflow="${3:-n8n/mobilityops-scheduled-quality-scan.json}"
source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1
fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
if [ -z "$callback_token" ]; then
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
exit 1
fi
# The workflow file no longer carries the callback token as a literal header value -- the
# scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
# Token"), which must exist in the target n8n instance before this workflow is activated;
# see the echo below.
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
cleanup() {
@@ -32,15 +30,16 @@ cleanup() {
}
trap cleanup EXIT INT TERM
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" '
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url |
(.nodes[] | select(.id == "scan-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
jq --arg scan_url "$scan_url" '
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker restart "$container_name" >/dev/null
echo "Published MobilityOps scheduled quality-scan workflow to existing container ${container_name}"
echo "Imported Fleet Ops — Scheduled Data Quality Scan into container ${container_name}."
echo "Before activating: in the n8n UI, create a Header Auth credential named"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "that secret value, and does not restart the container -- restart it yourself once"
echo "the credential is wired up and the workflow is published, if required."
+15 -15
View File
@@ -16,7 +16,7 @@ Steps:
4. return a stable workflow result;
5. on errors, fail visibly so the outbox dispatcher can retry.
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency.
The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-return.json` (see `n8n/workflows/MANIFEST.md`); it authenticates via named Header Auth credentials rather than a literal token, per the live-hardening pass documented in `docs/live-ai-integration/n8n-current-state.md`.
## Second live workflow: scheduled quality scan
@@ -39,24 +39,24 @@ Steps:
open, so a duplicate or overlapping trigger (a manual test run firing close to the
scheduled one, or a retried HTTP call) does no duplicate domain work.
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and
published the same way as the return-processing workflow (see
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with
`"active": false` so it cannot fire against any environment until deliberately
published with a real service token.
The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
(see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
active on the live instance; a fresh import ships inactive until credentials are wired up
and it is deliberately published.
## Deferred: knowledge sync
## RAGcore procedure sync (in progress)
Input: manual trigger or manifest-changed event.
RAGcore is now reachable in this environment; a live inspection of its real contract is
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops —
RAGcore Procedure Sync", is being built against that real contract (not the sketch
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
Steps:
## Workflow error handler (in progress)
1. read the fixed knowledge manifest;
2. call RAGcore ingestion/sync API;
3. record per-document results through MobilityOps integration status API.
Deferred until RAGcore's live ingestion API is available in this environment; must not
delay or block the core demo.
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
## Outbox dispatcher
+11 -2
View File
@@ -48,7 +48,7 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
which runs:
```bash
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n
```
@@ -56,6 +56,12 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
(`n8n import:workflow` always leaves the workflow deactivated regardless of its
`"active"` field; `publish:workflow` + a restart is what actually activates it.)
Before it will actually process a return, create two Header Auth credentials in the n8n
UI — `Fleet Ops Webhook Trigger Token` (value: `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` from
`.env`) and `Fleet Ops Service Token` (value: `MOBILITYOPS_CALLBACK_TOKEN` from `.env`) —
the workflow's webhook trigger and outbound HTTP call reference these credentials by
name; no secret value is embedded in the workflow file itself.
Verify the full round trip:
```bash
@@ -77,11 +83,14 @@ make n8n-setup-scan
which runs:
```bash
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n
```
This workflow also needs the `Fleet Ops Service Token` Header Auth credential created in
the n8n UI before a run will succeed.
Verify:
```bash
+15 -9
View File
@@ -1,13 +1,19 @@
# n8n workflows
`mobilityops-return-processing.json` is a starter import for the required live workflow.
Canonical, live-validated workflow definitions live under `n8n/workflows/`. Each file is a
cleaned export (secret values replaced by named-credential references, never literal
tokens) of the workflow actually running on `https://n8n.itworx.tech`. See
`n8n/workflows/MANIFEST.md` for the authoritative list: canonical name, purpose, trigger,
required credentials, live workflow ID, active status and a checksum of each file.
Before final validation Claude must:
The two pre-integration starter files that used to live directly under `n8n/`
(`mobilityops-return-processing.json`, `mobilityops-scheduled-quality-scan.json`) have been
retired — they predate the live n8n validation pass and embedded the service token as a
literal header value instead of a Header Auth credential. Do not resurrect them; the
`n8n/workflows/` versions are the superseding source of truth and are what
`deploy/unraid/setup-existing-n8n.sh` / `setup-scheduled-scan.sh` import.
1. import or provision the workflow;
2. configure a service credential for the MobilityOps callback;
3. activate the webhook;
4. ensure event ID is used as the idempotency key;
5. validate online success and offline retry behaviour.
The callback URL in the starter file is intentionally environment-driven and may require an n8n expression or credential adjustment.
Deploying a workflow from these files into a fresh or existing n8n instance still requires a
one-time manual step: create the named Header Auth credentials in the n8n UI (see each
workflow's `credentials` block and the manifest) before publishing. This is deliberate —
credential values are never committed to the repository.
+59
View File
@@ -0,0 +1,59 @@
# n8n workflow manifest
Source of truth for the four canonical Fleet Ops n8n workflows. Definitions in this
directory are cleaned exports of the live workflows on `https://n8n.itworx.tech`
credential values are never embedded; nodes reference named n8n credentials instead. Run
`n8n/workflows/check_drift.py` to compare a live workflow against its repo definition.
## 1. Fleet Ops — Vehicle Return Orchestration
| Field | Value |
|---|---|
| File | `fleet-ops-vehicle-return.json` |
| Purpose | Orchestrate the post-return follow-up (cleaning vs. attention-required) once Fleet Ops emits a `vehicle.returned.v1` outbox event, and report the result back to Fleet Ops. |
| Trigger | Production webhook, `POST /webhook/mobilityops-return`, Header Auth (`Fleet Ops Webhook Trigger Token`) |
| Event contract | `contracts/events.schema.json`, `event_type: vehicle.returned.v1` (envelope: `event_id`, `event_type`, `occurred_at`, `correlation_id`, `aggregate`, `data`) |
| Required credentials | `Fleet Ops Webhook Trigger Token` (Header Auth, on the trigger); `Fleet Ops Service Token` (Header Auth, on the outbound HTTP call) |
| Live workflow ID | `mobilityops-return-processing` |
| Active status (as of 2026-08-04) | Active / Published |
| Checksum (sha256) | `e13a3087269fc97019a7adf6c6a6a4ee4bd354c2dd7167d4966d4753a48e970e` |
## 2. Fleet Ops — Scheduled Data Quality Scan
| Field | Value |
|---|---|
| File | `fleet-ops-data-quality-scan.json` |
| Purpose | Periodically (and on-demand) run the Fleet Ops data-quality scan and summarize created-issue counts per rule. |
| Trigger | Schedule Trigger (hourly, Europe/Brussels instance timezone) + Manual Trigger for on-demand test runs |
| Event contract | N/A — HTTP-triggered scan call, no inbound event envelope. Request: `POST /api/v1/integrations/n8n/scheduled-scan`, Header Auth. |
| Required credentials | `Fleet Ops Service Token` (Header Auth, on the scan HTTP call) |
| Live workflow ID | `mobilityops-scheduled-quality-scan` |
| Active status (as of 2026-08-04) | Active / Published |
| Checksum (sha256) | `cc30b28b07dad9f9908a6ea0c564ec4c2f362a3ed71b7e97a7b6894408bb7e2e` |
## 3. Fleet Ops — RAGcore Procedure Sync
Not yet built. Blocked on a RAGcore application credential (scope `sources:sync`) for the
`fleet-ops` application, to be provided by the project owner. Will sync
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` against the real RAGcore contract
(`POST /v1/uploads`, `GET /v1/knowledge-spaces`, etc. — see
`contracts/ragcore-contract-assumptions.md` and the live inspection notes in
`docs/live-ai-integration/n8n-current-state.md`).
| Field | Value |
|---|---|
| File | `fleet-ops-ragcore-procedure-sync.json` (not yet created) |
| Live workflow ID | — |
| Active status | Not built |
## 4. Fleet Ops — Workflow Error Handler
Not yet built. Central error workflow to be attached to workflows 1-3 via n8n's
per-workflow "Error Workflow" setting. Reports bounded, secret-free failure details to a
new Fleet Ops automation-failure endpoint.
| Field | Value |
|---|---|
| File | `fleet-ops-error-handler.json` (not yet created) |
| Live workflow ID | — |
| Active status | Not built |
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Report drift between the repo's cleaned workflow definitions and the live n8n instance.
Read-only: this script only issues GET requests against n8n's Public API. It never writes,
imports, activates, or otherwise modifies anything in n8n -- fixing drift is a deliberate,
reviewed action a human takes in the n8n UI (or via a separate, explicit import step), not
something this script does automatically.
Usage:
N8N_BASE_URL=https://n8n.itworx.tech N8N_API_KEY=... python n8n/workflows/check_drift.py
N8N_API_KEY must be an n8n Public API key (n8n UI -> Settings -> API), not a session cookie
and not a workflow credential. It is read from the environment only and is never printed.
Exit code is 0 when every checked workflow matches the live instance, 1 when any drift (or
a fetch error) is found, so this is safe to wire into CI as a non-blocking check.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
WORKFLOWS_DIR = Path(__file__).parent
# (repo file name, live workflow ID) -- kept in sync with MANIFEST.md by hand, since the
# manifest is the human-readable source of truth and this is just its machine-checkable echo.
KNOWN_WORKFLOWS = [
("fleet-ops-vehicle-return.json", "mobilityops-return-processing"),
("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"),
]
# Fields that legitimately differ between a committed definition and the live instance
# (instance-assigned identifiers, timestamps, UI-only cosmetics) and must not be reported
# as drift.
_VOLATILE_TOP_LEVEL_KEYS = {
"versionId",
"createdAt",
"updatedAt",
"pinData",
"staticData",
"shared",
"triggerCount",
"isArchived",
}
_VOLATILE_NODE_KEYS = {"position", "webhookId"}
def _strip_credential_ids(value: Any) -> Any:
"""Live workflows carry instance-specific credential IDs alongside the credential name
(e.g. {"id": "3", "name": "Fleet Ops Service Token"}). The repo definitions intentionally
omit the id, since it's meaningless outside the instance that issued it. Drop it from both
sides so credential *references* are compared by name only."""
if isinstance(value, dict):
if set(value.keys()) <= {"id", "name"} and "name" in value:
return {"name": value["name"]}
return {k: _strip_credential_ids(v) for k, v in value.items()}
if isinstance(value, list):
return [_strip_credential_ids(v) for v in value]
return value
def _normalize_node(node: dict[str, Any]) -> dict[str, Any]:
cleaned = {k: v for k, v in node.items() if k not in _VOLATILE_NODE_KEYS}
return _strip_credential_ids(cleaned)
def _normalize_workflow(doc: dict[str, Any]) -> dict[str, Any]:
nodes_by_name = {n["name"]: _normalize_node(n) for n in doc.get("nodes", [])}
return {
"name": doc.get("name"),
"active": doc.get("active"),
"nodes": nodes_by_name,
"connections": doc.get("connections", {}),
"settings": doc.get("settings", {}),
}
def _diff(path: str, local: Any, live: Any, out: list[str]) -> None:
if isinstance(local, dict) and isinstance(live, dict):
for key in sorted(set(local) | set(live)):
if key in _VOLATILE_TOP_LEVEL_KEYS:
continue
if key not in live:
out.append(f"{path}.{key}: present in repo, missing live")
elif key not in local:
out.append(f"{path}.{key}: present live, missing in repo")
else:
_diff(f"{path}.{key}", local[key], live[key], out)
elif local != live:
out.append(f"{path}: repo={local!r} live={live!r}")
def fetch_live_workflow(base_url: str, api_key: str, workflow_id: str) -> dict[str, Any]:
url = f"{base_url.rstrip('/')}/api/v1/workflows/{workflow_id}"
request = urllib.request.Request(url, headers={"X-N8N-API-KEY": api_key, "Accept": "application/json"})
with urllib.request.urlopen(request, timeout=15) as response: # noqa: S310 (fixed https base url from env)
return json.load(response)
def main() -> int:
base_url = os.environ.get("N8N_BASE_URL")
api_key = os.environ.get("N8N_API_KEY")
if not base_url or not api_key:
print(
"Set N8N_BASE_URL and N8N_API_KEY (an n8n Public API key) in the environment.",
file=sys.stderr,
)
return 1
any_drift = False
for filename, workflow_id in KNOWN_WORKFLOWS:
repo_path = WORKFLOWS_DIR / filename
local_doc = json.loads(repo_path.read_text(encoding="utf-8"))
try:
live_doc = fetch_live_workflow(base_url, api_key, workflow_id)
except urllib.error.HTTPError as exc:
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: HTTP {exc.code}")
any_drift = True
continue
except urllib.error.URLError as exc:
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: {exc.reason}")
any_drift = True
continue
differences: list[str] = []
_diff(filename, _normalize_workflow(local_doc), _normalize_workflow(live_doc), differences)
if differences:
any_drift = True
print(f"[{filename}] DRIFT from live workflow {workflow_id}:")
for line in differences:
print(f" - {line}")
else:
print(f"[{filename}] matches live workflow {workflow_id}")
return 1 if any_drift else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,6 +1,6 @@
{
"id": "mobilityops-scheduled-quality-scan",
"name": "MobilityOps - Scheduled Quality Scan",
"name": "Fleet Ops Scheduled Data Quality Scan",
"nodes": [
{
"parameters": {
@@ -17,10 +17,7 @@
"name": "Hourly schedule",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
240,
220
]
"position": [240, 220]
},
{
"parameters": {},
@@ -28,23 +25,17 @@
"name": "Manual test trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
240,
400
]
"position": [240, 400]
},
{
"parameters": {
"method": "POST",
"url": "={{$env.MOBILITYOPS_API_URL || 'http://api:8000'}}/api/v1/integrations/n8n/scheduled-scan",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Token",
"value": "={{$env.MOBILITYOPS_CALLBACK_TOKEN}}"
}
]
"parameters": []
},
"options": {
"timeout": 15000
@@ -54,10 +45,12 @@
"name": "Run quality scan",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
300
]
"position": [520, 300],
"credentials": {
"httpHeaderAuth": {
"name": "Fleet Ops Service Token"
}
}
},
{
"parameters": {
@@ -67,52 +60,24 @@
"name": "Summarize result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
780,
300
]
"position": [780, 300]
}
],
"connections": {
"Hourly schedule": {
"main": [
[
{
"node": "Run quality scan",
"type": "main",
"index": 0
}
]
]
"main": [[{ "node": "Run quality scan", "type": "main", "index": 0 }]]
},
"Manual test trigger": {
"main": [
[
{
"node": "Run quality scan",
"type": "main",
"index": 0
}
]
]
"main": [[{ "node": "Run quality scan", "type": "main", "index": 0 }]]
},
"Run quality scan": {
"main": [
[
{
"node": "Summarize result",
"type": "main",
"index": 0
}
]
]
"main": [[{ "node": "Summarize result", "type": "main", "index": 0 }]]
}
},
"settings": {
"executionOrder": "v1"
},
"active": false,
"versionId": "22222222-2222-4222-8222-222222222222",
"active": true,
"meta": {
"templateCredsSetupCompleted": false
},
@@ -1,11 +1,12 @@
{
"id": "mobilityops-return-processing",
"name": "MobilityOps - Vehicle Return Processing",
"name": "Fleet Ops Vehicle Return Orchestration",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "mobilityops-return",
"authentication": "headerAuth",
"responseMode": "responseNode",
"options": {}
},
@@ -13,11 +14,13 @@
"name": "Return webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
300
],
"webhookId": "mobilityops-return"
"position": [240, 300],
"webhookId": "mobilityops-return",
"credentials": {
"httpHeaderAuth": {
"name": "Fleet Ops Webhook Trigger Token"
}
}
},
{
"parameters": {
@@ -27,25 +30,20 @@
"name": "Validate and derive follow-up",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
500,
300
]
"position": [500, 300]
},
{
"parameters": {
"method": "POST",
"url": "={{$env.MOBILITYOPS_CALLBACK_URL || 'http://api:8000/api/v1/integrations/n8n/return-callback'}}",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Idempotency-Key",
"value": "={{$json.event_id}}"
},
{
"name": "X-Service-Token",
"value": "={{$env.MOBILITYOPS_CALLBACK_TOKEN}}"
}
]
},
@@ -59,10 +57,12 @@
"name": "Record follow-up",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
760,
300
]
"position": [760, 300],
"credentials": {
"httpHeaderAuth": {
"name": "Fleet Ops Service Token"
}
}
},
{
"parameters": {
@@ -74,54 +74,26 @@
"name": "Return result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1020,
300
]
"position": [1020, 300]
}
],
"connections": {
"Return webhook": {
"main": [
[
{
"node": "Validate and derive follow-up",
"type": "main",
"index": 0
}
]
]
"main": [[{ "node": "Validate and derive follow-up", "type": "main", "index": 0 }]]
},
"Validate and derive follow-up": {
"main": [
[
{
"node": "Record follow-up",
"type": "main",
"index": 0
}
]
]
"main": [[{ "node": "Record follow-up", "type": "main", "index": 0 }]]
},
"Record follow-up": {
"main": [
[
{
"node": "Return result",
"type": "main",
"index": 0
}
]
]
"main": [[{ "node": "Return result", "type": "main", "index": 0 }]]
}
},
"settings": {
"executionOrder": "v1"
},
"active": true,
"versionId": "11111111-1111-4111-8111-111111111111",
"meta": {
"templateCredsSetupCompleted": false
},
"tags": []
}
}