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:
@@ -5,8 +5,8 @@ Automatischer Abgleich zwischen Proxmox-Inventar und der Homelab-Dokumentation i
|
|||||||
## Inhalt
|
## 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.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.
|
- `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) patchen, neue VMIDs automatisch als Outline-Seite anlegen, und eine ntfy-Benachrichtigung mit Zusammenfassung senden.
|
- `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
|
## 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)
|
- `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`
|
- 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:
|
Optionale Logrotate-Regel, damit das Log nicht unbegrenzt wächst:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -50,12 +55,18 @@ EOF
|
|||||||
## Funktionsweise (Kurzfassung)
|
## Funktionsweise (Kurzfassung)
|
||||||
|
|
||||||
- Script sammelt Proxmox-Daten (LXC + VM) → sendet JSON an n8n
|
- 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)
|
- 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: gezielter Patch nur der betroffenen Felder (`documents.update`, editMode `patch`)
|
- 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
|
- 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
|
## Bewusst nicht automatisiert
|
||||||
|
|
||||||
- Kategorisierung, Zweck-Beschreibung, Ports/URLs in der zentralen Übersichtstabelle (nicht aus Proxmox ableitbar)
|
- 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
|
- 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": {
|
"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",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
@@ -62,8 +62,12 @@
|
|||||||
"authentication": "genericCredentialType",
|
"authentication": "genericCredentialType",
|
||||||
"genericAuthType": "httpBearerAuth",
|
"genericAuthType": "httpBearerAuth",
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"bodyParameters": {
|
||||||
"jsonBody": "={\n \"id\": \"{{$json.documentId}}\",\n \"editMode\": \"patch\",\n \"findText\": \"{{$json.findText}}\",\n \"text\": \"{{$json.newText}}\"\n }",
|
"parameters": [
|
||||||
|
{ "name": "id", "value": "={{$json.documentId}}" },
|
||||||
|
{ "name": "text", "value": "={{$json.fullText}}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
@@ -213,6 +217,14 @@
|
|||||||
"position": [1024, -160],
|
"position": [1024, -160],
|
||||||
"id": "328ee7b8-62e4-4776-8eb2-f6c4650d8acb",
|
"id": "328ee7b8-62e4-4776-8eb2-f6c4650d8acb",
|
||||||
"name": "If1 skip"
|
"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": {
|
"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 }]] },
|
"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 }]] },
|
"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 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 }]] },
|
"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 JavaScript2": { "main": [[{ "node": "If match", "type": "main", "index": 0 }]] },
|
||||||
"Code in JavaScript3": { "main": [[{ "node": "HTTP Request Outline Create", "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 }]] },
|
"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 }]] },
|
"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 }]] },
|
"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 }]] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
# Voraussetzung: "jq" muss installiert sein.
|
# Voraussetzung: "jq" muss installiert sein.
|
||||||
# apt install -y jq
|
# 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:
|
# Nutzung:
|
||||||
# chmod +x proxmox-inventory-json.sh
|
# chmod +x proxmox-inventory-json.sh
|
||||||
# ./proxmox-inventory-json.sh # baut JSON + sendet an n8n (alle VMIDs)
|
# ./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
|
# 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"
|
# Komma-getrennte Liste von VMIDs, die verarbeitet werden sollen, z.B. "101,102,138"
|
||||||
# Leer lassen ("") um wie gewohnt ALLE Container/VMs zu verarbeiten.
|
# Leer lassen ("") um wie gewohnt ALLE Container/VMs zu verarbeiten.
|
||||||
|
|||||||
Reference in New Issue
Block a user