fix: eliminate race condition on multi-field updates, add PATH fix for cron

- n8n workflow: apply all field changes to full document text in one pass,
  send single documents.update per container/VM instead of multiple patch
  calls (fixed race condition where parallel patches to the same doc could
  partially overwrite each other, e.g. RAM update lost when Swap changed
  simultaneously)
- n8n workflow: add HTTP Request Doc Info (documents.info) node to fetch
  fresh document content by ID instead of relying on the search index
  snapshot, which can lag slightly behind
- n8n workflow: add Aggregate node after documents.update so the
  Loop Over Items (Split In Batches) counter stays correct regardless of
  how many fields changed
- proxmox-inventory-json.sh: explicit PATH export so pct/qm are found
  under cron's restricted PATH (fixes empty containers/vms payload in
  production cron runs while manual testing looked fine)
- README: document all of the above + known limitation with Outline table
  column width metadata not being touched by content-only sync
This commit is contained in:
Claude
2026-07-29 18:47:35 +00:00
parent 7015e26fdf
commit 2f1171e0d4
3 changed files with 42 additions and 12 deletions
+16 -5
View File
@@ -5,8 +5,8 @@ Automatischer Abgleich zwischen Proxmox-Inventar und der Homelab-Dokumentation i
## Inhalt
- `scripts/proxmox-inventory.sh` Text-Ausgabe des Proxmox-Inventars (VMID, Status, Ressourcen, Netzwerk inkl. Subnetzmaske/Gateway), zum manuellen Ausführen und Copy-Paste.
- `scripts/proxmox-inventory-json.sh` Baut dieselben Daten als JSON und sendet sie per `curl` an einen n8n-Webhook. Unterstützt `--dry-run`, `--print-only` und `--vmids=101,102,...` zum gezielten Testen einzelner Container/VMs.
- `n8n-workflows/auto-outline-doku-proxmox-inventory.json` Exportierter n8n-Workflow, der die vom Script gesendeten Daten mit Outline abgleicht: Fakten (RAM, CPU, Swap, Disk, IP, Gateway, Subnetz, MAC) patchen, neue VMIDs automatisch als Outline-Seite anlegen, und eine ntfy-Benachrichtigung mit Zusammenfassung senden.
- `scripts/proxmox-inventory-json.sh` Baut dieselben Daten als JSON und sendet sie per `curl` an einen n8n-Webhook. Unterstützt `--dry-run`, `--print-only` und `--vmids=101,102,...` zum gezielten Testen einzelner Container/VMs. Erkennt außerdem per `--vmids` angeforderte, aber in Proxmox nicht mehr existierende VMIDs und meldet sie im Payload (`missing_vmids`).
- `n8n-workflows/auto-outline-doku-proxmox-inventory.json` Exportierter n8n-Workflow, der die vom Script gesendeten Daten mit Outline abgleicht: Fakten (RAM, CPU, Swap, Disk, IP, Gateway, Subnetz, MAC) aktualisieren, neue VMIDs automatisch als Outline-Seite anlegen, und eine ntfy-Benachrichtigung mit Zusammenfassung senden.
## Setup
@@ -28,6 +28,11 @@ Auf dem Proxmox-Host `crontab -e` und folgende Zeile eintragen, um das Script st
- `7-20` = jede volle Stunde von 7 bis 20 Uhr (14 Läufe/Tag)
- Ausgabe (inkl. Fehler) landet in `/var/log/proxmox-inventory-sync.log`
**Wichtig PATH in Cron:** Cron nutzt einen deutlich eingeschränkteren `PATH` als eine interaktive Shell; `pct`/`qm` liegen meist unter `/usr/sbin` und werden von Cron sonst nicht gefunden (Symptom: leere `containers`/`vms`-Arrays im Payload, keine Fehlermeldung beim manuellen Testlauf). Das Script setzt den `PATH` deshalb selbst explizit am Anfang bei Problemen zuerst prüfen:
```bash
grep -i "command not found" /var/log/proxmox-inventory-sync.log
```
Optionale Logrotate-Regel, damit das Log nicht unbegrenzt wächst:
```bash
@@ -50,12 +55,18 @@ EOF
## Funktionsweise (Kurzfassung)
- Script sammelt Proxmox-Daten (LXC + VM) → sendet JSON an n8n
- n8n sucht pro VMID die passende Outline-Seite (`documents.search`), lädt den aktuellen Inhalt separat nach (`documents.info`, um Suchindex-Verzögerungen zu vermeiden)
- Bei Abweichungen: gezielter Patch nur der betroffenen Felder (`documents.update`, editMode `patch`)
- n8n sucht pro VMID die passende Outline-Seite (`documents.search`), lädt den aktuellen Inhalt **separat per ID nach** (`documents.info`), um zu verhindern, dass ein leicht veralteter Suchindex-Snapshot verwendet wird
- Bei Abweichungen: **alle** betroffenen Felder werden in einem einzigen Rutsch im JS-Code auf den Volltext angewendet, danach genau **ein** `documents.update`-Call pro Container/VM (kein mehrfaches Patchen mehr) das verhindert Race Conditions, bei denen zwei parallele Patch-Requests auf dasselbe Dokument sich gegenseitig teilweise überschreiben
- Bei keinem Treffer: neue Seite wird automatisch aus den bekannten Fakten angelegt (`documents.create`), unbekannte Felder wie Zweck/Ports bleiben als Platzhalter
- Am Ende: Zusammenfassung aller Änderungen/Auffälligkeiten per ntfy
- Ein **Aggregate**-Node fasst die Update-Response wieder zu einem Item zusammen, bevor es zurück in die "Loop Over Items"-Schleife (Split In Batches) läuft nötig, damit die Schleife bei Items mit mehreren geänderten Feldern nicht durcheinanderkommt
- Am Ende: Zusammenfassung aller Änderungen/Auffälligkeiten per ntfy (nur Zeilen mit echten Änderungen oder Auffälligkeiten, keine "keine Änderung"-Flut)
## Bewusst nicht automatisiert
- Kategorisierung, Zweck-Beschreibung, Ports/URLs in der zentralen Übersichtstabelle (nicht aus Proxmox ableitbar)
- Umbenennen/Archivieren bei VMID-Wiederverwendung (z. B. wenn eine VMID gelöscht und für einen neuen Container wiederverwendet wird) das bleibt manuelle Review-Arbeit
## Bekannte Einschränkung
Wenn eine Tabellen-Spaltenbreite in Outline manuell per Drag verändert wurde, bleibt diese (von unserem Sync unberührte) Breiten-Metadaten bestehen, auch wenn sich der Zellinhalt durch den Sync ändert das kann optisch zu leicht verschobenen Trennlinien führen. Der Sync schreibt nur den Markdown-Inhalt, nicht die internen Tabellen-Layout-Metadaten von Outline. Workaround: Spaltenbreite einmalig manuell in Outline nachziehen.
@@ -12,7 +12,7 @@
},
{
"parameters": {
"jsCode": "const body = $input.first().json.body;\nconst containers = (body.containers || []).map(c => ({ ...c, category: 'lxc' }));\nconst vms = (body.vms || []).map(v => ({ ...v, category: 'vm' }));\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.summary = (body.missing_vmids || []).map(m => ({\n vmid: m.vmid,\n name: '-',\n changes: [`⚠️ ${m.reason}`]\n}));\n\nreturn [...containers, ...vms].map(item => ({ json: item }));"
"jsCode": "const body = $input.first().json.body;\nconst containers = (body.containers || []).map(c => ({ ...c, category: 'lxc' }));\nconst vms = (body.vms || []).map(v => ({ ...v, category: 'vm' }));\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.summary = (body.missing_vmids || []).map(m => ({\n vmid: m.vmid,\n name: '-',\n changes: [`⚠️ ${m.reason}`]\n}));\n\nconst items = [...containers, ...vms];\n\n// Sicherheitsnetz: leere Daten nicht stillschweigend abbrechen lassen\nif (items.length === 0) {\n staticData.summary.push({\n vmid: '-',\n name: '-',\n changes: ['⚠️ Keine Container/VMs im Payload - Script-Fehler auf dem Proxmox-Host? (z.B. PATH-Problem in Cron)']\n });\n return [{ json: { __empty_run: true } }];\n}\n\nreturn items.map(item => ({ json: item }));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
@@ -30,7 +30,7 @@
},
{
"parameters": {
"jsCode": "const item = $('Loop Over Items').item.json;\nconst doc = $json.data;\nconst text = doc.text || '';\n\nconst patches = [];\nconst changedFields = [];\n\nfunction formatDisk(raw) {\n if (!raw) return raw;\n const m = String(raw).match(/^(\\d+(?:\\.\\d+)?)\\s*([KMGT])$/i);\n if (!m) return raw;\n return `${m[1]} ${m[2].toUpperCase()}B`;\n}\n\nfunction replaceField(fieldLabel, newValue, unit) {\n if (newValue === null || newValue === undefined || newValue === '') return;\n const re = new RegExp(`(\\\\|\\\\s*${fieldLabel}\\\\s*\\\\|\\\\s*)([^|]+?)(\\\\s*\\\\|)`, 'i');\n const match = text.match(re);\n if (!match) return;\n const currentValue = match[2].trim();\n const desiredValue = `${newValue}${unit || ''}`;\n if (currentValue !== desiredValue) {\n patches.push({ findText: match[0], newText: `${match[1]}${desiredValue}${match[3]}` });\n changedFields.push(`${fieldLabel.replace(/\\\\\\\\/g, '')}: ${currentValue} → ${desiredValue}`);\n }\n}\n\nreplaceField('RAM', item.memory_mb, ' MB');\nreplaceField('CPU \\\\(Cores\\\\)', item.cores);\nreplaceField('Swap', item.swap_mb, ' MB');\nreplaceField('Disk', formatDisk(item.disk));\nreplaceField('Storage-Pool', item.storage_pool);\nreplaceField('IP-Adresse', item.ip);\nreplaceField('Gateway', item.gateway);\nreplaceField('Subnetz', item.netmask);\nreplaceField('MAC-Adresse', item.mac);\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.summary.push({\n vmid: item.vmid,\n name: item.hostname,\n changes: changedFields.length > 0 ? changedFields : ['keine Änderung']\n});\n\nif (patches.length === 0) {\n return [{ json: { skip: true } }];\n}\n\nreturn patches.map(p => ({ json: { skip: false, documentId: doc.id, findText: p.findText, newText: p.newText } }));"
"jsCode": "const item = $('Loop Over Items').item.json;\nconst doc = $json.data;\nlet text = doc.text || '';\n\nconst changedFields = [];\n\nfunction formatDisk(raw) {\n if (!raw) return raw;\n const m = String(raw).match(/^(\\d+(?:\\.\\d+)?)\\s*([KMGT])$/i);\n if (!m) return raw;\n return `${m[1]} ${m[2].toUpperCase()}B`;\n}\n\nfunction replaceField(fieldLabel, newValue, unit) {\n if (newValue === null || newValue === undefined || newValue === '') return;\n const re = new RegExp(`(\\\\|\\\\s*${fieldLabel}\\\\s*\\\\|\\\\s*)([^|]+?)(\\\\s*\\\\|)`, 'i');\n const match = text.match(re);\n if (!match) return;\n const currentValue = match[2].trim();\n const desiredValue = `${newValue}${unit || ''}`;\n if (currentValue !== desiredValue) {\n text = text.slice(0, match.index) + match[1] + desiredValue + match[3] + text.slice(match.index + match[0].length);\n changedFields.push(`${fieldLabel.replace(/\\\\\\\\/g, '')}: ${currentValue} → ${desiredValue}`);\n }\n}\n\nreplaceField('RAM', item.memory_mb, ' MB');\nreplaceField('CPU \\\\(Cores\\\\)', item.cores);\nreplaceField('Swap', item.swap_mb, ' MB');\nreplaceField('Disk', formatDisk(item.disk));\nreplaceField('Storage-Pool', item.storage_pool);\nreplaceField('IP-Adresse', item.ip);\nreplaceField('Gateway', item.gateway);\nreplaceField('Subnetz', item.netmask);\nreplaceField('MAC-Adresse', item.mac);\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.summary.push({\n vmid: item.vmid,\n name: item.hostname,\n changes: changedFields.length > 0 ? changedFields : ['keine Änderung']\n});\n\nif (changedFields.length === 0) {\n return [{ json: { skip: true } }];\n}\n\nreturn [{ json: { skip: false, documentId: doc.id, fullText: text } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
@@ -62,8 +62,12 @@
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"id\": \"{{$json.documentId}}\",\n \"editMode\": \"patch\",\n \"findText\": \"{{$json.findText}}\",\n \"text\": \"{{$json.newText}}\"\n }",
"bodyParameters": {
"parameters": [
{ "name": "id", "value": "={{$json.documentId}}" },
{ "name": "text", "value": "={{$json.fullText}}" }
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
@@ -213,6 +217,14 @@
"position": [1024, -160],
"id": "328ee7b8-62e4-4776-8eb2-f6c4650d8acb",
"name": "If1 skip"
},
{
"parameters": { "aggregate": "aggregateAllItemData", "options": {} },
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [1488, -176],
"id": "c0568872-e56f-472e-8276-2d0420d8c51f",
"name": "Aggregate"
}
],
"connections": {
@@ -221,13 +233,14 @@
"Loop Over Items": { "main": [[{ "node": "Code in JavaScript4", "type": "main", "index": 0 }], [{ "node": "HTTP Request Outline Search", "type": "main", "index": 0 }]] },
"Code in JavaScript1": { "main": [[{ "node": "If1 skip", "type": "main", "index": 0 }]] },
"HTTP Request Outline Search": { "main": [[{ "node": "Code in JavaScript2", "type": "main", "index": 0 }]] },
"HTTP Request Outline Update": { "main": [[{ "node": "Loop Over Items", "type": "main", "index": 0 }]] },
"HTTP Request Outline Update": { "main": [[{ "node": "Aggregate", "type": "main", "index": 0 }]] },
"HTTP Request Outline Create": { "main": [[{ "node": "Loop Over Items", "type": "main", "index": 0 }]] },
"Code in JavaScript2": { "main": [[{ "node": "If match", "type": "main", "index": 0 }]] },
"Code in JavaScript3": { "main": [[{ "node": "HTTP Request Outline Create", "type": "main", "index": 0 }]] },
"HTTP Request Doc Info": { "main": [[{ "node": "Code in JavaScript1", "type": "main", "index": 0 }]] },
"If match": { "main": [[{ "node": "HTTP Request Doc Info", "type": "main", "index": 0 }], [{ "node": "Code in JavaScript3", "type": "main", "index": 0 }]] },
"If1 skip": { "main": [[{ "node": "HTTP Request Outline Update", "type": "main", "index": 0 }], [{ "node": "Loop Over Items", "type": "main", "index": 0 }]] },
"Code in JavaScript4": { "main": [[{ "node": "HTTP Request Send NTFY Update", "type": "main", "index": 0 }]] }
"Code in JavaScript4": { "main": [[{ "node": "HTTP Request Send NTFY Update", "type": "main", "index": 0 }]] },
"Aggregate": { "main": [[{ "node": "Loop Over Items", "type": "main", "index": 0 }]] }
}
}
+7 -1
View File
@@ -9,6 +9,12 @@
# Voraussetzung: "jq" muss installiert sein.
# apt install -y jq
#
# WICHTIG (Cron): Cron nutzt einen deutlich eingeschränkteren PATH als eine
# interaktive Shell. "pct"/"qm" liegen meist unter /usr/sbin - deshalb wird
# der PATH hier explizit erweitert, damit das Script auch per Cronjob
# zuverlässig funktioniert (nicht nur beim manuellen Testen im Terminal).
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
#
# Nutzung:
# chmod +x proxmox-inventory-json.sh
# ./proxmox-inventory-json.sh # baut JSON + sendet an n8n (alle VMIDs)
@@ -29,7 +35,7 @@ set -euo pipefail
# ---------------------------------------------------------------------------
# KONFIGURATION - hier eure n8n-Webhook-URL eintragen
# ---------------------------------------------------------------------------
N8N_WEBHOOK_URL="https://n8n.example.com/webhook/REPLACE_ME"
N8N_WEBHOOK_URL="https://n8n.vogt.de.com/webhook-test/22ac0717-01fa-4756-a45b-2dc7411a6442"
# Komma-getrennte Liste von VMIDs, die verarbeitet werden sollen, z.B. "101,102,138"
# Leer lassen ("") um wie gewohnt ALLE Container/VMs zu verarbeiten.