proxsync 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. config.py +337 -0
  2. pbs_sync.py +244 -0
  3. proxsync-2.0.0.dist-info/METADATA +468 -0
  4. proxsync-2.0.0.dist-info/RECORD +59 -0
  5. proxsync-2.0.0.dist-info/WHEEL +5 -0
  6. proxsync-2.0.0.dist-info/top_level.txt +5 -0
  7. pve_sync.py +1804 -0
  8. pve_sync_plugin/__init__.py +46 -0
  9. pve_sync_plugin/api/__init__.py +1 -0
  10. pve_sync_plugin/api/serializers.py +161 -0
  11. pve_sync_plugin/api/urls.py +27 -0
  12. pve_sync_plugin/api/views.py +94 -0
  13. pve_sync_plugin/apps.py +5 -0
  14. pve_sync_plugin/choices.py +57 -0
  15. pve_sync_plugin/filtersets.py +137 -0
  16. pve_sync_plugin/forms.py +219 -0
  17. pve_sync_plugin/management/__init__.py +1 -0
  18. pve_sync_plugin/management/commands/__init__.py +1 -0
  19. pve_sync_plugin/management/commands/pve_sync.py +182 -0
  20. pve_sync_plugin/management/commands/run_scheduled_syncs.py +109 -0
  21. pve_sync_plugin/migrations/0001_initial.py +234 -0
  22. pve_sync_plugin/migrations/0002_pvepluginsettings.py +42 -0
  23. pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +148 -0
  24. pve_sync_plugin/migrations/0004_model_verbose_names.py +34 -0
  25. pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +111 -0
  26. pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +124 -0
  27. pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +26 -0
  28. pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +26 -0
  29. pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +39 -0
  30. pve_sync_plugin/migrations/0010_pvedriftevent.py +62 -0
  31. pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +19 -0
  32. pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +39 -0
  33. pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +19 -0
  34. pve_sync_plugin/migrations/__init__.py +1 -0
  35. pve_sync_plugin/models.py +461 -0
  36. pve_sync_plugin/navigation.py +94 -0
  37. pve_sync_plugin/signals.py +24 -0
  38. pve_sync_plugin/sync/__init__.py +8 -0
  39. pve_sync_plugin/sync/config_bridge.py +221 -0
  40. pve_sync_plugin/sync/engine.py +50 -0
  41. pve_sync_plugin/tables.py +278 -0
  42. pve_sync_plugin/tasks.py +440 -0
  43. pve_sync_plugin/template_content.py +44 -0
  44. pve_sync_plugin/templates/pve_sync/dashboard.html +400 -0
  45. pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +7 -0
  46. pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +69 -0
  47. pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +27 -0
  48. pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +52 -0
  49. pve_sync_plugin/templates/pve_sync/pvedriftevent.html +89 -0
  50. pve_sync_plugin/templates/pve_sync/pvesyncjob.html +197 -0
  51. pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +42 -0
  52. pve_sync_plugin/templates/pve_sync/settings.html +79 -0
  53. pve_sync_plugin/templatetags/__init__.py +1 -0
  54. pve_sync_plugin/templatetags/pve_sync_tags.py +86 -0
  55. pve_sync_plugin/urls.py +186 -0
  56. pve_sync_plugin/utils.py +84 -0
  57. pve_sync_plugin/view_helpers.py +98 -0
  58. pve_sync_plugin/views.py +535 -0
  59. state_db.py +543 -0
@@ -0,0 +1,440 @@
1
+ """Background tasks for NetBox's RQ worker."""
2
+
3
+ import datetime
4
+ import logging
5
+ import os
6
+ from datetime import timezone as dt_timezone
7
+
8
+ from django.utils import timezone
9
+
10
+ from .choices import BackupStatusChoices
11
+ from .models import PveClusterConfig, PveSyncJob, PveWebhookEvent
12
+ from .utils import get_plugin_config
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Maximum allowed runtime before a sync job is considered stale.
17
+ _JOB_TIMEOUT_MINUTES = 30
18
+ # RQ job timeout — must exceed the longest expected sync run.
19
+ _RQ_JOB_TIMEOUT = _JOB_TIMEOUT_MINUTES * 60 # 1800 seconds
20
+
21
+
22
+ def enqueue_sync(job):
23
+ """Queue a sync job on NetBox's default RQ queue."""
24
+ try:
25
+ import django_rq
26
+
27
+ rq_job = django_rq.get_queue("default").enqueue(
28
+ run_sync_job, job.id, job_timeout=_RQ_JOB_TIMEOUT
29
+ )
30
+ job.details["rq_job_id"] = rq_job.id
31
+ job.save(update_fields=["details"])
32
+ return rq_job.id
33
+ except Exception as exc:
34
+ job.status = "failed"
35
+ job.end_time = timezone.now()
36
+ job.details["queue_error"] = str(exc)
37
+ job.save(update_fields=["status", "end_time", "details"])
38
+ raise
39
+
40
+
41
+ def enqueue_webhook_event(event):
42
+ try:
43
+ import django_rq
44
+
45
+ rq_job = django_rq.get_queue("default").enqueue(
46
+ process_webhook_event, event.id
47
+ )
48
+ return rq_job.id
49
+ except Exception as exc:
50
+ event.mark_processed(error=f"Queue error: {exc}")
51
+ raise
52
+
53
+
54
+ def _heartbeat_thread(job_id, stop_event):
55
+ """Write a heartbeat to job.details every 30 s so the UI knows the worker is alive."""
56
+ import threading
57
+ from django.db import connection
58
+
59
+ while not stop_event.wait(30):
60
+ try:
61
+ connection.close() # Each iteration needs a fresh DB connection in this thread
62
+ job = PveSyncJob.objects.get(pk=job_id)
63
+ details = dict(job.details or {})
64
+ details["heartbeat"] = timezone.now().isoformat()
65
+ PveSyncJob.objects.filter(pk=job_id).update(details=details)
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ def run_sync_job(job_id):
71
+ """Run a PVE sync from a PveSyncJob record."""
72
+ import threading
73
+
74
+ job = PveSyncJob.objects.get(pk=job_id)
75
+ job.status = "running"
76
+ job.save(update_fields=["status"])
77
+ config_path = None
78
+
79
+ stop_hb = threading.Event()
80
+ hb = threading.Thread(target=_heartbeat_thread, args=(job_id, stop_hb), daemon=True)
81
+ hb.start()
82
+
83
+ try:
84
+ from .sync.config_bridge import cleanup_runtime_env, write_runtime_config_file
85
+ from .sync.engine import PVESyncEngine
86
+
87
+ config_path = write_runtime_config_file(job.cluster_name)
88
+
89
+ engine = PVESyncEngine(config_path=config_path, job_id=job.id)
90
+ stats = engine.run()
91
+
92
+ job.refresh_from_db()
93
+ job.status = "success"
94
+ job.end_time = timezone.now()
95
+ job.total_vms = stats.get("total_vms", 0)
96
+ job.success_vms = stats.get("success_vms", 0)
97
+ job.failed_vms = max(job.total_vms - job.success_vms, 0)
98
+ job.nodes_offline = stats.get("nodes_offline", 0)
99
+ job.config_drifts = stats.get("config_drifts_detected", 0)
100
+ job.tag_changes = stats.get("tag_changes", 0)
101
+ job.resource_alerts = stats.get("resources_alert", 0)
102
+ job.details.pop("heartbeat", None)
103
+ job.save()
104
+
105
+ _update_cluster_status(job)
106
+ logger.info("PVE sync job %s completed", job.id)
107
+ return {"status": "success", "job_id": job.id}
108
+
109
+ except Exception as exc:
110
+ logger.exception("PVE sync job %s failed", job.id)
111
+ job.refresh_from_db()
112
+ job.status = "failed"
113
+ job.end_time = timezone.now()
114
+ job.details["error"] = str(exc)
115
+ job.details.pop("heartbeat", None)
116
+ job.save(update_fields=["status", "end_time", "details"])
117
+ _update_cluster_status(job)
118
+ return {"status": "failed", "job_id": job.id, "error": str(exc)}
119
+
120
+ finally:
121
+ stop_hb.set()
122
+
123
+ if config_path:
124
+ try:
125
+ os.unlink(config_path)
126
+ except OSError:
127
+ pass
128
+
129
+ try:
130
+ from .sync.config_bridge import cleanup_runtime_env
131
+
132
+ cleanup_runtime_env()
133
+ except ImportError:
134
+ pass
135
+
136
+
137
+ def process_webhook_event(event_id):
138
+ event = PveWebhookEvent.objects.get(pk=event_id)
139
+ if event.processed:
140
+ return {"status": "skipped", "event_id": event.id}
141
+
142
+ event_mapping = {
143
+ "vm-started": True,
144
+ "vm-stopped": True,
145
+ "vm-migrated": True,
146
+ "backup-done": True,
147
+ "backup-failed": True,
148
+ "configuration-change": True,
149
+ }
150
+ should_sync = event_mapping.get(event.event_type, False)
151
+ sync_job = None
152
+
153
+ if should_sync:
154
+ sync_job = PveSyncJob.objects.create(
155
+ cluster_name=get_plugin_config("default_cluster_name", "default"),
156
+ status="pending",
157
+ trigger="webhook",
158
+ details={"webhook_event_id": event.id, "vmid": event.vmid},
159
+ )
160
+ enqueue_sync(sync_job)
161
+
162
+ event.mark_processed(sync_job=sync_job)
163
+ return {
164
+ "status": "processed",
165
+ "event_id": event.id,
166
+ "sync_triggered": should_sync,
167
+ }
168
+
169
+
170
+ def cleanup_stale_jobs():
171
+ """Mark jobs stuck in 'pending' or 'running' for too long as failed.
172
+
173
+ This is intended to be called periodically (e.g. from a management
174
+ command or housekeeping cron) to prevent orphaned jobs.
175
+ """
176
+ cutoff = timezone.now() - datetime.timedelta(minutes=_JOB_TIMEOUT_MINUTES)
177
+ stale_jobs = PveSyncJob.objects.filter(
178
+ status__in=["pending", "running"],
179
+ start_time__lt=cutoff,
180
+ )
181
+ count = stale_jobs.update(
182
+ status="failed",
183
+ end_time=timezone.now(),
184
+ )
185
+ if count:
186
+ logger.warning("Marked %d stale sync jobs as failed", count)
187
+ return count
188
+
189
+
190
+ def _update_cluster_status(job):
191
+ cluster = PveClusterConfig.objects.filter(name=job.cluster_name).first()
192
+ if not cluster:
193
+ return
194
+ cluster.last_sync = job.end_time
195
+ cluster.last_sync_status = job.status
196
+ cluster.save(update_fields=["last_sync", "last_sync_status"])
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # PBS sync
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def enqueue_pbs_sync(job, pbs_server):
204
+ """Queue a PBS sync job on NetBox's default RQ queue."""
205
+ try:
206
+ import django_rq
207
+
208
+ rq_job = django_rq.get_queue("default").enqueue(
209
+ run_pbs_sync_job, job.id, pbs_server.pk, job_timeout=_RQ_JOB_TIMEOUT
210
+ )
211
+ job.details["rq_job_id"] = rq_job.id
212
+ job.save(update_fields=["details"])
213
+ return rq_job.id
214
+ except Exception as exc:
215
+ job.status = "failed"
216
+ job.end_time = timezone.now()
217
+ job.details["queue_error"] = str(exc)
218
+ job.save(update_fields=["status", "end_time", "details"])
219
+ raise
220
+
221
+
222
+ def run_pbs_sync_job(job_id, pbs_server_pk):
223
+ """Run a PBS sync from a PbsServerConfig record."""
224
+ import threading
225
+ from .models import PbsServerConfig
226
+
227
+ job = PveSyncJob.objects.get(pk=job_id)
228
+ pbs = PbsServerConfig.objects.get(pk=pbs_server_pk)
229
+ job.status = "running"
230
+ job.save(update_fields=["status"])
231
+
232
+ stop_hb = threading.Event()
233
+ hb = threading.Thread(target=_heartbeat_thread, args=(job_id, stop_hb), daemon=True)
234
+ hb.start()
235
+
236
+ try:
237
+ import os
238
+
239
+ # Set environment variables expected by the PBS sync script
240
+ os.environ["PBS_HOST"] = pbs.pbs_host
241
+ os.environ["PBS_TOKEN_NAME"] = pbs.pbs_token_name
242
+ os.environ["PBS_TOKEN_SECRET"] = pbs.pbs_token_secret
243
+ os.environ["PBS_VERIFY_SSL"] = "true" if pbs.pbs_verify_ssl else "false"
244
+ os.environ["PBS_NODE_NAME"] = pbs.pbs_node_name
245
+ os.environ["PBS_NETBOX_SITE"] = pbs.netbox_site.name if pbs.netbox_site else ""
246
+ os.environ["NB_API_URL"] = get_plugin_config("netbox_url", "")
247
+ os.environ["NB_API_TOKEN"] = get_plugin_config("netbox_token", "")
248
+
249
+ from pbs_sync import PBSToNetBoxSync
250
+
251
+ syncer = PBSToNetBoxSync()
252
+ syncer.sync()
253
+
254
+ # Write backup records to PveBackupStatus
255
+ try:
256
+ backup_records = _fetch_pbs_snapshots(pbs)
257
+ updated, skipped = _apply_pbs_backup_status(backup_records)
258
+ job.details["backup_updated"] = updated
259
+ job.details["backup_skipped"] = skipped
260
+ logger.info("PBS backup status: %d updated, %d no VM match", updated, skipped)
261
+ except Exception as exc:
262
+ logger.warning("PBS backup status sync failed (non-fatal): %s", exc)
263
+
264
+ job.refresh_from_db()
265
+ job.status = "success"
266
+ job.end_time = timezone.now()
267
+ job.details.pop("heartbeat", None)
268
+ job.save()
269
+
270
+ pbs.last_sync = job.end_time
271
+ pbs.last_sync_status = "success"
272
+ pbs.save(update_fields=["last_sync", "last_sync_status"])
273
+
274
+ logger.info("PBS sync job %s completed for server %s", job.id, pbs.name)
275
+ return {"status": "success", "job_id": job.id}
276
+
277
+ except Exception as exc:
278
+ logger.exception("PBS sync job %s failed for server %s", job.id, pbs.name)
279
+ job.refresh_from_db()
280
+ job.status = "failed"
281
+ job.end_time = timezone.now()
282
+ job.details["error"] = str(exc)
283
+ job.details.pop("heartbeat", None)
284
+ job.save(update_fields=["status", "end_time", "details"])
285
+
286
+ pbs.last_sync = job.end_time
287
+ pbs.last_sync_status = "failed"
288
+ pbs.save(update_fields=["last_sync", "last_sync_status"])
289
+
290
+ return {"status": "failed", "job_id": job.id, "error": str(exc)}
291
+
292
+ finally:
293
+ stop_hb.set()
294
+ for key in ["PBS_HOST", "PBS_TOKEN_NAME", "PBS_TOKEN_SECRET",
295
+ "PBS_VERIFY_SSL", "PBS_NODE_NAME", "PBS_NETBOX_SITE",
296
+ "NB_API_URL", "NB_API_TOKEN"]:
297
+ os.environ.pop(key, None)
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # PBS backup status helpers
302
+ # ---------------------------------------------------------------------------
303
+
304
+ def _fetch_pbs_snapshots(pbs):
305
+ """Query PBS API and return the latest backup per vmid across all datastores.
306
+
307
+ Returns:
308
+ dict: {(backup_type, vmid_str): {'last_backup': datetime, 'size': int, 'pve_backup_id': str}}
309
+ """
310
+ import urllib3
311
+ import requests
312
+
313
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
314
+
315
+ session = requests.Session()
316
+ session.headers.update({
317
+ "Authorization": f"PBSAPIToken={pbs.pbs_token_name}:{pbs.pbs_token_secret}",
318
+ "Accept": "application/json",
319
+ })
320
+ host = pbs.pbs_host.rstrip("/")
321
+ verify = pbs.pbs_verify_ssl
322
+
323
+ # Get datastore list
324
+ try:
325
+ resp = session.get(f"{host}/api2/json/admin/datastore", verify=verify, timeout=15)
326
+ datastores = resp.json().get("data", []) if resp.status_code == 200 else []
327
+ except Exception as exc:
328
+ logger.warning("PBS: cannot list datastores: %s", exc)
329
+ return {}
330
+
331
+ best = {} # (type, vmid) -> best snapshot info
332
+
333
+ for ds in datastores:
334
+ store = ds.get("store") or ds.get("name")
335
+ if not store:
336
+ continue
337
+ try:
338
+ resp = session.get(
339
+ f"{host}/api2/json/admin/datastore/{store}/snapshots",
340
+ verify=verify, timeout=30,
341
+ )
342
+ if resp.status_code != 200:
343
+ continue
344
+ snapshots = resp.json().get("data", [])
345
+ except Exception as exc:
346
+ logger.warning("PBS: cannot list snapshots for %s: %s", store, exc)
347
+ continue
348
+
349
+ for snap in snapshots:
350
+ btype = snap.get("backup-type", "vm")
351
+ vmid = str(snap.get("backup-id", ""))
352
+ if not vmid or btype not in ("vm", "ct"):
353
+ continue
354
+
355
+ ts = snap.get("backup-time", 0)
356
+ backup_dt = (
357
+ datetime.datetime.fromtimestamp(ts, tz=dt_timezone.utc) if ts else None
358
+ )
359
+ size = snap.get("size", 0) or 0
360
+
361
+ key = (btype, vmid)
362
+ existing = best.get(key)
363
+ if existing is None or (backup_dt and backup_dt > existing["last_backup"]):
364
+ best[key] = {
365
+ "last_backup": backup_dt,
366
+ "size": size,
367
+ "pve_backup_id": f"{btype}/{vmid}",
368
+ "backup_path": f"{store}/{btype}/{vmid}",
369
+ }
370
+
371
+ logger.info("PBS: found latest backups for %d VMs/CTs", len(best))
372
+ return best
373
+
374
+
375
+ def _apply_pbs_backup_status(backup_records):
376
+ """Write PBS backup data into PveBackupStatus Django model records.
377
+
378
+ Matching priority:
379
+ 1. Existing PveBackupStatus where pve_backup_id already matches.
380
+ 2. VirtualMachine with custom_field_data__pve_vmid == vmid.
381
+
382
+ Returns:
383
+ (updated: int, skipped: int)
384
+ """
385
+ from .models import PveBackupStatus
386
+ from virtualization.models import VirtualMachine
387
+
388
+ updated = 0
389
+ skipped = 0
390
+
391
+ for (btype, vmid), info in backup_records.items():
392
+ pve_backup_id = info["pve_backup_id"]
393
+ last_backup = info.get("last_backup")
394
+ size = info.get("size", 0)
395
+
396
+ vm = None
397
+
398
+ # 1. Find via existing pve_backup_id link
399
+ existing = (
400
+ PveBackupStatus.objects.filter(pve_backup_id=pve_backup_id)
401
+ .select_related("vm")
402
+ .first()
403
+ )
404
+ if existing:
405
+ vm = existing.vm
406
+
407
+ # 2. Find via custom field pve_vmid
408
+ if vm is None:
409
+ try:
410
+ vm = VirtualMachine.objects.filter(
411
+ custom_field_data__vm_id=int(vmid)
412
+ ).first()
413
+ except Exception:
414
+ pass
415
+
416
+ if vm is None:
417
+ skipped += 1
418
+ logger.debug("PBS: no NetBox VM for %s/%s — skipping", btype, vmid)
419
+ continue
420
+
421
+ status_obj, _ = PveBackupStatus.objects.get_or_create(vm=vm)
422
+
423
+ # Only update if this backup is newer than what is stored
424
+ if last_backup and (status_obj.last_backup is None or last_backup > status_obj.last_backup):
425
+ age_days = (timezone.now() - last_backup).days
426
+ status_obj.last_backup = last_backup
427
+ status_obj.backup_size = size if size else status_obj.backup_size
428
+ status_obj.pve_backup_id = pve_backup_id
429
+ status_obj.backup_path = info.get("backup_path", "")
430
+ status_obj.backup_status = (
431
+ BackupStatusChoices.STATUS_SUCCESS
432
+ if age_days <= 7
433
+ else BackupStatusChoices.STATUS_FAILED
434
+ )
435
+ status_obj.save(update_fields=[
436
+ "last_backup", "backup_size", "pve_backup_id", "backup_path", "backup_status",
437
+ ])
438
+ updated += 1
439
+
440
+ return updated, skipped
@@ -0,0 +1,44 @@
1
+ """Template extensions injected into NetBox core object pages."""
2
+
3
+ from netbox.plugins import PluginTemplateExtension
4
+
5
+ from .models import PveClusterConfig, PveSyncJob
6
+
7
+
8
+ class VirtualMachineSyncButton(PluginTemplateExtension):
9
+ """Add a PVE sync action to NetBox virtual machine detail pages."""
10
+
11
+ models = ["virtualization.virtualmachine"]
12
+
13
+ def buttons(self):
14
+ request = self.context.get("request")
15
+ if request and not request.user.has_perm("pve_sync_plugin.add_pvesyncjob"):
16
+ return ""
17
+
18
+ vm = self.context["object"]
19
+ cluster_config = None
20
+ if getattr(vm, "cluster_id", None):
21
+ cluster_config = PveClusterConfig.objects.filter(
22
+ netbox_cluster_id=vm.cluster_id,
23
+ enabled=True,
24
+ ).first()
25
+
26
+ recent_job = (
27
+ PveSyncJob.objects.filter(
28
+ details__vm_id=vm.pk,
29
+ )
30
+ .order_by("-start_time")
31
+ .first()
32
+ )
33
+
34
+ return self.render(
35
+ "pve_sync/inc/vm_sync_button.html",
36
+ extra_context={
37
+ "vm": vm,
38
+ "cluster_config": cluster_config,
39
+ "recent_pve_sync_job": recent_job,
40
+ },
41
+ )
42
+
43
+
44
+ template_extensions = [VirtualMachineSyncButton]