diff --git a/README.md b/README.md index 9ab7255..1976796 100644 --- a/README.md +++ b/README.md @@ -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. + diff --git a/n8n-workflows/auto-outline-doku-proxmox-inventory.json b/n8n-workflows/auto-outline-doku-proxmox-inventory.json index f38392d..7df0be9 100644 --- a/n8n-workflows/auto-outline-doku-proxmox-inventory.json +++ b/n8n-workflows/auto-outline-doku-proxmox-inventory.json @@ -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 }]] } } } diff --git a/scripts/proxmox-inventory-json.sh b/scripts/proxmox-inventory-json.sh index 65064c3..c476a82 100644 --- a/scripts/proxmox-inventory-json.sh +++ b/scripts/proxmox-inventory-json.sh @@ -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.