Initial commit: Windmill workspace sync
Scripts, flows, apps, resources and resource types from the Windmill workspace. API token excluded via .gitignore (config/).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
summary: Proxmox
|
||||
description: ''
|
||||
@@ -0,0 +1,87 @@
|
||||
summary: Proxmox Backup Webhook
|
||||
description: |
|
||||
Empfängt Backup-Benachrichtigungen von Proxmox via Webhook,
|
||||
speichert alle VM-Backups in MySQL und sendet eine Zusammenfassung
|
||||
an Nextcloud Talk.
|
||||
value:
|
||||
modules:
|
||||
- id: a
|
||||
summary: Payload parsen & aufbereiten
|
||||
value:
|
||||
type: rawscript
|
||||
content: '!inline payload_parsen_&_aufbereiten.py'
|
||||
input_transforms:
|
||||
message:
|
||||
type: javascript
|
||||
expr: flow_input.message || ''
|
||||
severity:
|
||||
type: javascript
|
||||
expr: flow_input.severity || ''
|
||||
title:
|
||||
type: javascript
|
||||
expr: flow_input.title || ''
|
||||
debug_mode:
|
||||
type: javascript
|
||||
expr: flow_input.debug_mode || false
|
||||
lock: '!inline payload_parsen_&_aufbereiten.lock'
|
||||
language: python3
|
||||
- id: b
|
||||
summary: In MySQL speichern
|
||||
value:
|
||||
type: rawscript
|
||||
content: '!inline in_mysql_speichern.py'
|
||||
input_transforms:
|
||||
backups:
|
||||
type: javascript
|
||||
expr: results.a.backups
|
||||
batch_id:
|
||||
type: javascript
|
||||
expr: results.a.batch_id
|
||||
raw_payload:
|
||||
type: javascript
|
||||
expr: results.a.raw_payload
|
||||
total_size:
|
||||
type: javascript
|
||||
expr: results.a.total_size
|
||||
total_time:
|
||||
type: javascript
|
||||
expr: results.a.total_time
|
||||
debug_mode:
|
||||
type: javascript
|
||||
expr: flow_input.debug_mode || false
|
||||
lock: '!inline in_mysql_speichern.lock'
|
||||
language: python3
|
||||
- id: c
|
||||
summary: Nachricht an Nextcloud Talk
|
||||
value:
|
||||
type: rawscript
|
||||
content: '!inline nachricht_an_nextcloud_talk.py'
|
||||
input_transforms:
|
||||
talk_message:
|
||||
type: javascript
|
||||
expr: results.a.talk_message
|
||||
debug_mode:
|
||||
type: javascript
|
||||
expr: flow_input.debug_mode || false
|
||||
lock: '!inline nachricht_an_nextcloud_talk.lock'
|
||||
language: python3
|
||||
schema:
|
||||
$schema: https://json-schema.org/draft/2020-12/schema
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: Vollständige Nachricht mit Summary und Logs
|
||||
default: "Details\n=======\nVMID Name Status Time Size Filename\n127 OLV-CLOUD01-IP17.2 ok 4s 50 GiB vm/127/2026-04-22T19:43:13Z\n131 OLV-WORDP01-IP17.3 ok 3s 50 GiB vm/131/2026-04-22T19:43:17Z\n132 OLV-SMTP01-IP17.1 ok 5s 50 GiB vm/132/2026-04-22T19:43:20Z\n\nTotal running time: 12s\nTotal size: 150 GiB\n\nLogs\n====\nvzdump 127 131 132 --prune-backups 'keep-all=1' --mode snapshot\n\n127: 2026-04-22 21:43:13 INFO: Starting Backup of VM 127 (qemu)\n127: 2026-04-22 21:43:17 INFO: Finished Backup of VM 127 (00:00:04)\n\n131: 2026-04-22 21:43:17 INFO: Starting Backup of VM 131 (qemu)\n131: 2026-04-22 21:43:20 INFO: Finished Backup of VM 131 (00:00:03)\n\n132: 2026-04-22 21:43:20 INFO: Starting Backup of VM 132 (qemu)\n132: 2026-04-22 21:43:25 INFO: Finished Backup of VM 132 (00:00:05)"
|
||||
severity:
|
||||
type: string
|
||||
description: Severity-Level (info, warning, error)
|
||||
default: "info"
|
||||
title:
|
||||
type: string
|
||||
description: Titel der Proxmox-Notification
|
||||
default: "vzdump backup status (proxmox.netbird.stines.de): backup successful"
|
||||
debug_mode:
|
||||
type: boolean
|
||||
description: "Debug-Modus: überspringt DB-Insert und Nextcloud-Nachricht"
|
||||
default: false
|
||||
@@ -0,0 +1,10 @@
|
||||
# py: 3.12
|
||||
anyio==4.13.0
|
||||
certifi==2026.2.25
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
mysql-connector-python==9.6.0
|
||||
typing-extensions==4.15.0
|
||||
wmill==1.688.0
|
||||
@@ -0,0 +1,51 @@
|
||||
import wmill
|
||||
import json
|
||||
import mysql.connector
|
||||
|
||||
def main(backups: list, batch_id: str, total_time: str, total_size: str, raw_payload: dict, debug_mode: bool = False):
|
||||
if not backups:
|
||||
print("Keine Backups zum Speichern.")
|
||||
return {"inserted": 0}
|
||||
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] DB-Insert übersprungen (debug_mode=True)")
|
||||
print(f"[DEBUG] batch_id={batch_id} | {len(backups)} VMs | total_time={total_time!r} | total_size={total_size!r}")
|
||||
for b in backups:
|
||||
print(f"[DEBUG] VM {b['vmid']} ({b['vm_name']}): status={b['status']}, size={b['size']}, duration={b['duration_sec']}s")
|
||||
return {"inserted": 0, "batch_id": batch_id, "debug": True}
|
||||
|
||||
db_cfg = json.loads(wmill.get_variable("f/Backup/mysql_config"))
|
||||
conn = mysql.connector.connect(**db_cfg)
|
||||
cur = conn.cursor()
|
||||
|
||||
inserted = 0
|
||||
|
||||
for b in backups:
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO Kunden.`bronze.proxmox_backup_log`
|
||||
(batch_id, vmid, vm_name, status, duration_sec, size, filename, log_text, total_time, total_size, raw_payload)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
batch_id,
|
||||
b['vmid'],
|
||||
b['vm_name'],
|
||||
b['status'],
|
||||
b['duration_sec'],
|
||||
b['size'],
|
||||
b['filename'],
|
||||
b['log_text'],
|
||||
total_time,
|
||||
total_size,
|
||||
json.dumps(raw_payload)
|
||||
))
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Insert für VM {b['vmid']}: {e}")
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
print(f"{inserted} Backup-Einträge in DB gespeichert (batch_id: {batch_id})")
|
||||
return {"inserted": inserted, "batch_id": batch_id}
|
||||
@@ -0,0 +1,9 @@
|
||||
# py: 3.12
|
||||
anyio==4.13.0
|
||||
certifi==2026.2.25
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
typing-extensions==4.15.0
|
||||
wmill==1.688.0
|
||||
@@ -0,0 +1,46 @@
|
||||
import wmill
|
||||
import httpx
|
||||
import base64
|
||||
|
||||
def main(talk_message: str, debug_mode: bool = False):
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] Nextcloud Talk übersprungen (debug_mode=True)")
|
||||
print(f"[DEBUG] Nachricht die gesendet worden wäre:\n{talk_message}")
|
||||
return {"status": "skipped", "debug": True}
|
||||
|
||||
nc_url = wmill.get_variable("f/Backup/nextcloud_talk_url").rstrip("/")
|
||||
nc_room = wmill.get_variable("f/Backup/nextcloud_talk_room")
|
||||
nc_user = wmill.get_variable("f/Backup/nextcloud_talk_user")
|
||||
nc_password = wmill.get_variable("f/Backup/nextcloud_talk_password")
|
||||
|
||||
credentials = base64.b64encode(
|
||||
f"{nc_user}:{nc_password}".encode()
|
||||
).decode()
|
||||
|
||||
url = f"{nc_url}/ocs/v2.php/apps/spreed/api/v1/chat/{nc_room}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {credentials}",
|
||||
"OCS-APIREQUEST": "true",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
payload = {"message": talk_message}
|
||||
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
verify=False
|
||||
)
|
||||
|
||||
print(f"HTTP: {response.status_code}")
|
||||
if response.status_code not in (200, 201):
|
||||
raise Exception(
|
||||
f"Nextcloud Talk Fehler: {response.status_code} – {response.text}"
|
||||
)
|
||||
|
||||
print("Nachricht gesendet ✓")
|
||||
return {"status": "sent", "http": response.status_code}
|
||||
@@ -0,0 +1 @@
|
||||
# py: 3.12
|
||||
@@ -0,0 +1,174 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def parse_duration(duration_str):
|
||||
"""Parse '1m 21s', '48s', '4s', '1h 58s' to seconds."""
|
||||
if not duration_str:
|
||||
return None
|
||||
total = 0
|
||||
for match in re.finditer(r'(\d+)\s*(h|m|s)', duration_str):
|
||||
val, unit = int(match.group(1)), match.group(2)
|
||||
if unit == 'h':
|
||||
total += val * 3600
|
||||
elif unit == 'm':
|
||||
total += val * 60
|
||||
elif unit == 's':
|
||||
total += val
|
||||
return total if total > 0 else None
|
||||
|
||||
|
||||
def main(title: str = "", message: str = "", severity: str = "", debug_mode: bool = False):
|
||||
batch_id = str(uuid.uuid4())[:8]
|
||||
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] title={title!r} | severity={severity!r}")
|
||||
print(f"[DEBUG] message ({len(message)} Zeichen):\n{message[:2000]}")
|
||||
|
||||
lines = message.split('\n')
|
||||
|
||||
backups = []
|
||||
total_time = None
|
||||
total_size = None
|
||||
|
||||
in_details = False
|
||||
in_logs = False
|
||||
vm_logs = {}
|
||||
current_vmid = None
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith('Total running time:'):
|
||||
total_time = stripped.replace('Total running time:', '').strip()
|
||||
in_details = False
|
||||
elif stripped.startswith('Total size:'):
|
||||
total_size = stripped.replace('Total size:', '').strip()
|
||||
elif stripped == 'Details':
|
||||
in_details = True
|
||||
continue
|
||||
elif stripped == 'Logs':
|
||||
in_details = False
|
||||
in_logs = True
|
||||
continue
|
||||
elif re.match(r'^=+$', stripped):
|
||||
continue
|
||||
elif in_details:
|
||||
# Skip header row and empty lines
|
||||
if not stripped or stripped.upper().startswith('VMID'):
|
||||
continue
|
||||
# Split by 2+ spaces → works for both old and new Proxmox format
|
||||
cols = re.split(r'\s{2,}', stripped)
|
||||
if len(cols) >= 6 and cols[0].isdigit():
|
||||
vmid, name, status, time_str, size_str, filename = cols[:6]
|
||||
backups.append({
|
||||
'vmid': int(vmid),
|
||||
'vm_name': name,
|
||||
'status': status.lower(),
|
||||
'duration_sec': parse_duration(time_str),
|
||||
'size': size_str.strip(),
|
||||
'filename': filename.strip(),
|
||||
'log_text': ''
|
||||
})
|
||||
elif in_logs:
|
||||
vm_match = re.match(r'^(\d+):\s+\d{4}-\d{2}-\d{2}', line)
|
||||
if vm_match:
|
||||
current_vmid = vm_match.group(1)
|
||||
if current_vmid not in vm_logs:
|
||||
vm_logs[current_vmid] = []
|
||||
if current_vmid:
|
||||
vm_logs[current_vmid].append(line)
|
||||
else:
|
||||
# Old format without "Details" header: try direct VM row parsing
|
||||
if stripped and not stripped.upper().startswith('VMID'):
|
||||
cols = re.split(r'\s{2,}', stripped)
|
||||
if len(cols) >= 6 and cols[0].isdigit():
|
||||
vmid, name, status, time_str, size_str, filename = cols[:6]
|
||||
backups.append({
|
||||
'vmid': int(vmid),
|
||||
'vm_name': name,
|
||||
'status': status.lower(),
|
||||
'duration_sec': parse_duration(time_str),
|
||||
'size': size_str.strip(),
|
||||
'filename': filename.strip(),
|
||||
'log_text': ''
|
||||
})
|
||||
|
||||
for backup in backups:
|
||||
vmid_str = str(backup['vmid'])
|
||||
if vmid_str in vm_logs:
|
||||
backup['log_text'] = '\n'.join(vm_logs[vmid_str])
|
||||
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] {len(backups)} VMs geparst: {[b['vm_name'] for b in backups]}")
|
||||
print(f"[DEBUG] total_time={total_time!r} | total_size={total_size!r}")
|
||||
|
||||
if not backups:
|
||||
return {
|
||||
'backups': [],
|
||||
'batch_id': batch_id,
|
||||
'total_time': total_time,
|
||||
'total_size': total_size,
|
||||
'talk_message': f"⚠️ Proxmox Webhook empfangen, aber keine VMs gefunden.\n\n```\n{message[:500]}\n```",
|
||||
'raw_payload': {'title': title, 'message': message, 'severity': severity}
|
||||
}
|
||||
|
||||
failed = [b for b in backups if b['status'] != 'ok']
|
||||
ok = [b for b in backups if b['status'] == 'ok']
|
||||
|
||||
if len(failed) == 0:
|
||||
status_icon = "✅"
|
||||
elif len(ok) == 0:
|
||||
status_icon = "❌"
|
||||
else:
|
||||
status_icon = "⚠️"
|
||||
|
||||
# --- Talk-Nachricht aufbauen ---
|
||||
talk_lines = []
|
||||
|
||||
# Titel-Zeile mit Hostname und Status aus Proxmox
|
||||
display_title = title if title else f"Proxmox Backup – {datetime.now().strftime('%d.%m.%Y')}"
|
||||
talk_lines.append(f"{status_icon} **{display_title}**")
|
||||
talk_lines.append("")
|
||||
|
||||
# Details-Tabelle als Code-Block für korrekte Ausrichtung
|
||||
talk_lines.append("```")
|
||||
talk_lines.append(f"{'VMID':<6} {'Name':<22} {'Status':<8} {'Time':<6} {'Size':<10} Filename")
|
||||
for b in backups:
|
||||
dur = f"{b['duration_sec']}s" if b['duration_sec'] else "?"
|
||||
talk_lines.append(
|
||||
f"{b['vmid']:<6} {b['vm_name']:<22} {b['status']:<8} {dur:<6} {b['size']:<10} {b['filename']}"
|
||||
)
|
||||
talk_lines.append("```")
|
||||
talk_lines.append("")
|
||||
|
||||
# Zusammenfassung
|
||||
parts = []
|
||||
if total_time:
|
||||
parts.append(f"⏱ {total_time}")
|
||||
if total_size:
|
||||
parts.append(f"📦 {total_size}")
|
||||
if parts:
|
||||
talk_lines.append(" | ".join(parts))
|
||||
|
||||
# Fehler hervorheben
|
||||
if failed:
|
||||
talk_lines.append("")
|
||||
talk_lines.append("**❌ Fehler bei:**")
|
||||
for b in failed:
|
||||
talk_lines.append(f" • {b['vm_name']} ({b['vmid']})")
|
||||
|
||||
talk_message = '\n'.join(talk_lines)
|
||||
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] Generierte Talk-Nachricht:\n{talk_message}")
|
||||
|
||||
return {
|
||||
'backups': backups,
|
||||
'batch_id': batch_id,
|
||||
'total_time': total_time,
|
||||
'total_size': total_size,
|
||||
'talk_message': talk_message,
|
||||
'raw_payload': {'title': title, 'message': message, 'severity': severity}
|
||||
}
|
||||
Reference in New Issue
Block a user