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,98 @@
1
+ import logging
2
+
3
+ from django.shortcuts import get_object_or_404
4
+ from django.utils import timezone
5
+
6
+ from .models import PveClusterConfig, PveSyncJob
7
+ from .tasks import enqueue_sync
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def has_active_sync_job(cluster_name):
13
+ # Reconcile any RQ-failed jobs before checking for active ones, so a
14
+ # worker restart doesn't permanently block future syncs.
15
+ _reconcile_rq_status(cluster_name)
16
+ from .tasks import cleanup_stale_jobs
17
+ cleanup_stale_jobs()
18
+
19
+ return PveSyncJob.objects.filter(
20
+ cluster_name=cluster_name,
21
+ status__in=["pending", "running"],
22
+ ).exists()
23
+
24
+
25
+ def _reconcile_rq_status(cluster_name):
26
+ """Mark DB jobs as failed when their RQ counterpart has already failed/disappeared."""
27
+ active_jobs = PveSyncJob.objects.filter(
28
+ cluster_name=cluster_name,
29
+ status__in=["pending", "running"],
30
+ )
31
+ if not active_jobs.exists():
32
+ return
33
+
34
+ try:
35
+ import django_rq
36
+ from rq.job import Job, JobStatus
37
+
38
+ q = django_rq.get_queue("default")
39
+ terminal = {JobStatus.FAILED, JobStatus.CANCELED}
40
+
41
+ for job in active_jobs:
42
+ rq_job_id = job.details.get("rq_job_id")
43
+ if not rq_job_id:
44
+ continue
45
+ try:
46
+ rq_job = Job.fetch(rq_job_id, connection=q.connection)
47
+ if rq_job.get_status() in terminal:
48
+ exc_info = getattr(rq_job, "exc_info", None) or ""
49
+ job.status = "failed"
50
+ job.end_time = timezone.now()
51
+ job.details["error"] = f"RQ job {rq_job_id} failed: {str(exc_info)[:200]}"
52
+ job.save(update_fields=["status", "end_time", "details"])
53
+ logger.warning("Reconciled RQ-failed job #%s for cluster %s", job.id, cluster_name)
54
+ except Exception:
55
+ # Job not found in Redis (expired / worker crashed).
56
+ # If stuck >5 min, mark failed immediately instead of waiting 30 min.
57
+ stuck_seconds = (timezone.now() - job.start_time).total_seconds()
58
+ if stuck_seconds > 300:
59
+ job.status = "failed"
60
+ job.end_time = timezone.now()
61
+ job.details["error"] = (
62
+ f"RQ job {rq_job_id} not found in Redis after {int(stuck_seconds)}s "
63
+ "(worker crash or Redis flush?)"
64
+ )
65
+ job.save(update_fields=["status", "end_time", "details"])
66
+ logger.warning(
67
+ "Reconciled missing-RQ job #%s for cluster %s (stuck %ds)",
68
+ job.id, cluster_name, stuck_seconds,
69
+ )
70
+ except Exception as exc:
71
+ logger.debug("RQ reconcile skipped: %s", exc)
72
+
73
+
74
+ def create_and_enqueue_sync_job(cluster_name, user, trigger, details=None):
75
+ if cluster_name != "default":
76
+ get_object_or_404(PveClusterConfig, name=cluster_name, enabled=True)
77
+
78
+ job = PveSyncJob.objects.create(
79
+ cluster_name=cluster_name,
80
+ status="pending",
81
+ trigger=trigger,
82
+ triggered_by=user if getattr(user, "is_authenticated", False) else None,
83
+ details=details or {},
84
+ )
85
+ enqueue_sync(job)
86
+ job.refresh_from_db()
87
+ return job
88
+
89
+
90
+ def resolve_cluster_name_for_vm(vm):
91
+ if getattr(vm, "cluster_id", None):
92
+ cluster = PveClusterConfig.objects.filter(
93
+ netbox_cluster_id=vm.cluster_id,
94
+ enabled=True,
95
+ ).first()
96
+ if cluster:
97
+ return cluster.name
98
+ return "default"
@@ -0,0 +1,535 @@
1
+ import logging
2
+
3
+ from django.contrib import messages
4
+ from django.contrib.auth.mixins import PermissionRequiredMixin
5
+ from django.db.models import Count, Q
6
+ from django.http import JsonResponse
7
+ from django.shortcuts import get_object_or_404, redirect, render
8
+ from django.urls import reverse
9
+ from django.utils import timezone
10
+ from django.views import View
11
+ from django.views.decorators.csrf import csrf_exempt
12
+ from django.views.decorators.http import require_http_methods
13
+
14
+ from netbox.views import generic
15
+ from dcim.models import Device
16
+ from virtualization.models import VirtualMachine
17
+
18
+ import json
19
+
20
+ logger = logging.getLogger(__name__)
21
+ import hmac
22
+ import hashlib
23
+
24
+ from netbox.context import current_request as _nb_current_request
25
+
26
+ from .filtersets import (
27
+ PbsServerConfigFilterSet,
28
+ PveBackupStatusFilterSet,
29
+ PveClusterConfigFilterSet,
30
+ PveDriftEventFilterSet,
31
+ PveSyncJobFilterSet,
32
+ PveWebhookEventFilterSet,
33
+ )
34
+ from .forms import (
35
+ PbsServerConfigFilterForm,
36
+ PbsServerConfigForm,
37
+ PveClusterConfigFilterForm,
38
+ PveClusterConfigForm,
39
+ PveDriftEventFilterForm,
40
+ PvePluginSettingsForm,
41
+ PveSyncJobFilterForm,
42
+ PveWebhookEventFilterForm,
43
+ )
44
+ from .models import (
45
+ PbsServerConfig,
46
+ PveBackupStatus,
47
+ PveClusterConfig,
48
+ PveDriftEvent,
49
+ PvePluginSettings,
50
+ PveSyncJob,
51
+ PveWebhookEvent,
52
+ )
53
+ from .tables import (
54
+ PbsServerConfigTable,
55
+ PveBackupStatusTable,
56
+ PveClusterConfigTable,
57
+ PveDriftEventTable,
58
+ PveSyncJobTable,
59
+ PveWebhookEventTable,
60
+ )
61
+ from .tasks import enqueue_webhook_event
62
+ from .utils import get_plugin_config
63
+ from .view_helpers import (
64
+ create_and_enqueue_sync_job,
65
+ has_active_sync_job,
66
+ resolve_cluster_name_for_vm,
67
+ )
68
+
69
+
70
+ # ============================================================================
71
+ # Dashboard
72
+ # ============================================================================
73
+
74
+ class DashboardView(PermissionRequiredMixin, View):
75
+ """Main plugin dashboard with recent jobs, alerts, and quick actions."""
76
+
77
+ permission_required = "pve_sync_plugin.view_pvesyncjob"
78
+
79
+ def get(self, request):
80
+ clusters = PveClusterConfig.objects.filter(enabled=True).select_related("netbox_cluster", "netbox_site")
81
+ pbs_servers = PbsServerConfig.objects.filter(enabled=True).select_related("netbox_site")
82
+
83
+ # Collect all NetBox cluster IDs linked to enabled PVE configs
84
+ nb_cluster_ids = [c.netbox_cluster_id for c in clusters if c.netbox_cluster_id]
85
+
86
+ # Node stats (devices inside PVE clusters)
87
+ node_qs = Device.objects.filter(cluster__id__in=nb_cluster_ids) if nb_cluster_ids else Device.objects.none()
88
+ total_nodes = node_qs.count()
89
+ online_nodes = node_qs.filter(status='active').count()
90
+ offline_nodes = total_nodes - online_nodes
91
+
92
+ # VM stats across all PVE clusters
93
+ vm_qs = VirtualMachine.objects.filter(cluster__id__in=nb_cluster_ids) if nb_cluster_ids else VirtualMachine.objects.none()
94
+ vm_stats = vm_qs.aggregate(
95
+ total=Count("id"),
96
+ running=Count("id", filter=Q(status="active")),
97
+ stopped=Count("id", filter=Q(status="offline")),
98
+ staged=Count("id", filter=Q(status="staged")),
99
+ )
100
+
101
+ # Per-cluster node & VM counts for the clusters table
102
+ cluster_stats = {}
103
+ if nb_cluster_ids:
104
+ for row in Device.objects.filter(cluster__id__in=nb_cluster_ids).values("cluster_id").annotate(
105
+ total=Count("id"), online=Count("id", filter=Q(status="active"))
106
+ ):
107
+ cluster_stats[row["cluster_id"]] = {"nodes": row["total"], "nodes_online": row["online"]}
108
+ for row in VirtualMachine.objects.filter(cluster__id__in=nb_cluster_ids).values("cluster_id").annotate(
109
+ total=Count("id"), running=Count("id", filter=Q(status="active"))
110
+ ):
111
+ cid = row["cluster_id"]
112
+ cluster_stats.setdefault(cid, {})
113
+ cluster_stats[cid]["vms"] = row["total"]
114
+ cluster_stats[cid]["vms_running"] = row["running"]
115
+
116
+ # Attach stats to each cluster object for easy template access
117
+ for c in clusters:
118
+ s = cluster_stats.get(c.netbox_cluster_id, {})
119
+ c.stat_nodes = s.get("nodes", 0)
120
+ c.stat_nodes_online = s.get("nodes_online", 0)
121
+ c.stat_vms = s.get("vms", 0)
122
+ c.stat_vms_running = s.get("vms_running", 0)
123
+
124
+ # Alerts
125
+ pending_webhooks = PveWebhookEvent.objects.filter(processed=False).count()
126
+ backup_alerts = PveBackupStatus.objects.filter(
127
+ last_backup__lt=timezone.now() - timezone.timedelta(days=7)
128
+ ).count()
129
+ offline_node_list = (
130
+ node_qs.exclude(status="active")
131
+ .select_related("cluster")
132
+ .order_by("name")
133
+ ) if nb_cluster_ids else Device.objects.none()
134
+
135
+ # Recent jobs (last 10 only — full history has its own list page)
136
+ recent_jobs = PveSyncJob.objects.order_by("-start_time")[:10]
137
+
138
+ webhook_url = request.build_absolute_uri(reverse("plugins:pve_sync_plugin:webhook"))
139
+
140
+ context = {
141
+ "clusters": clusters,
142
+ "pbs_servers": pbs_servers,
143
+ "total_nodes": total_nodes,
144
+ "online_nodes": online_nodes,
145
+ "offline_nodes": offline_nodes,
146
+ "total_vms": vm_stats["total"],
147
+ "running_vms": vm_stats["running"],
148
+ "stopped_vms": vm_stats["stopped"],
149
+ "staged_vms": vm_stats["staged"],
150
+ "pending_webhooks": pending_webhooks,
151
+ "backup_alerts": backup_alerts,
152
+ "offline_node_list": offline_node_list,
153
+ "recent_jobs": recent_jobs,
154
+ "webhook_url": webhook_url,
155
+ }
156
+ return render(request, "pve_sync/dashboard.html", context)
157
+
158
+
159
+ # ============================================================================
160
+ # PveSyncJob Views
161
+ # ============================================================================
162
+
163
+ class PveSyncJobListView(generic.ObjectListView):
164
+ queryset = PveSyncJob.objects.all()
165
+ table = PveSyncJobTable
166
+ filterset = PveSyncJobFilterSet
167
+ filterset_form = PveSyncJobFilterForm
168
+
169
+
170
+ class PveSyncJobView(generic.ObjectView):
171
+ queryset = PveSyncJob.objects.all()
172
+ template_name = "pve_sync/pvesyncjob.html"
173
+
174
+
175
+ class PveSyncJobDeleteView(generic.ObjectDeleteView):
176
+ queryset = PveSyncJob.objects.all()
177
+
178
+
179
+ class PveSyncJobBulkDeleteView(generic.BulkDeleteView):
180
+ queryset = PveSyncJob.objects.all()
181
+ table = PveSyncJobTable
182
+
183
+
184
+ # ============================================================================
185
+ # PveWebhookEvent Views
186
+ # ============================================================================
187
+
188
+ class PveWebhookEventListView(generic.ObjectListView):
189
+ queryset = PveWebhookEvent.objects.all()
190
+ table = PveWebhookEventTable
191
+ filterset = PveWebhookEventFilterSet
192
+ filterset_form = PveWebhookEventFilterForm
193
+
194
+
195
+ class PveWebhookEventView(generic.ObjectView):
196
+ queryset = PveWebhookEvent.objects.all()
197
+ template_name = "pve_sync/pvewebhookevent.html"
198
+
199
+
200
+ class PveWebhookEventDeleteView(generic.ObjectDeleteView):
201
+ queryset = PveWebhookEvent.objects.all()
202
+
203
+
204
+ class PveWebhookEventBulkDeleteView(generic.BulkDeleteView):
205
+ queryset = PveWebhookEvent.objects.all()
206
+ table = PveWebhookEventTable
207
+
208
+
209
+ # ============================================================================
210
+ # PveClusterConfig Views
211
+ # ============================================================================
212
+
213
+ class PveClusterConfigListView(generic.ObjectListView):
214
+ queryset = PveClusterConfig.objects.select_related(
215
+ "netbox_site", "netbox_cluster_type", "netbox_cluster"
216
+ )
217
+ table = PveClusterConfigTable
218
+ filterset = PveClusterConfigFilterSet
219
+ filterset_form = PveClusterConfigFilterForm
220
+
221
+
222
+ class PveClusterConfigView(generic.ObjectView):
223
+ queryset = PveClusterConfig.objects.all()
224
+ template_name = "pve_sync/pveclusterconfig.html"
225
+
226
+
227
+ class PveClusterConfigEditView(generic.ObjectEditView):
228
+ queryset = PveClusterConfig.objects.all()
229
+ form = PveClusterConfigForm
230
+
231
+
232
+ class PveClusterConfigDeleteView(generic.ObjectDeleteView):
233
+ queryset = PveClusterConfig.objects.all()
234
+
235
+
236
+ class PveClusterConfigBulkDeleteView(generic.BulkDeleteView):
237
+ queryset = PveClusterConfig.objects.all()
238
+ table = PveClusterConfigTable
239
+
240
+
241
+ # ============================================================================
242
+ # PbsServerConfig Views
243
+ # ============================================================================
244
+
245
+ class PbsServerConfigListView(generic.ObjectListView):
246
+ queryset = PbsServerConfig.objects.select_related("netbox_site")
247
+ table = PbsServerConfigTable
248
+ filterset = PbsServerConfigFilterSet
249
+ filterset_form = PbsServerConfigFilterForm
250
+
251
+
252
+ class PbsServerConfigView(generic.ObjectView):
253
+ queryset = PbsServerConfig.objects.all()
254
+ template_name = "pve_sync/pbsserverconfig.html"
255
+
256
+
257
+ class PbsServerConfigEditView(generic.ObjectEditView):
258
+ queryset = PbsServerConfig.objects.all()
259
+ form = PbsServerConfigForm
260
+
261
+
262
+ class PbsServerConfigDeleteView(generic.ObjectDeleteView):
263
+ queryset = PbsServerConfig.objects.all()
264
+
265
+
266
+ class PbsServerConfigBulkDeleteView(generic.BulkDeleteView):
267
+ queryset = PbsServerConfig.objects.all()
268
+ table = PbsServerConfigTable
269
+
270
+
271
+ class TriggerPbsSyncView(PermissionRequiredMixin, View):
272
+ """Web GUI endpoint for manually triggering a PBS sync."""
273
+
274
+ permission_required = "pve_sync_plugin.add_pvesyncjob"
275
+
276
+ def post(self, request, pbs_pk):
277
+ pbs = get_object_or_404(PbsServerConfig, pk=pbs_pk, enabled=True)
278
+ cluster_name = f"pbs:{pbs.name}"
279
+
280
+ if has_active_sync_job(cluster_name):
281
+ messages.warning(request, f"PBS sync for {pbs.name} is already running.")
282
+ return redirect(request.POST.get("return_url") or reverse("plugins:pve_sync_plugin:dashboard"))
283
+
284
+ try:
285
+ job = PveSyncJob.objects.create(
286
+ cluster_name=cluster_name,
287
+ status="pending",
288
+ trigger="manual",
289
+ triggered_by=request.user if request.user.is_authenticated else None,
290
+ details={"pbs_server_id": pbs.pk, "pbs_server_name": pbs.name},
291
+ )
292
+ from .tasks import enqueue_pbs_sync
293
+ enqueue_pbs_sync(job, pbs)
294
+ messages.success(request, f"PBS sync for {pbs.name} queued as job #{job.id}.")
295
+ except Exception as exc:
296
+ messages.error(request, f"Unable to start PBS sync: {exc}")
297
+ return redirect(
298
+ request.POST.get("return_url") or reverse("plugins:pve_sync_plugin:dashboard")
299
+ )
300
+
301
+
302
+ # ============================================================================
303
+ # PveBackupStatus Views
304
+ # ============================================================================
305
+
306
+ class PveBackupStatusListView(generic.ObjectListView):
307
+ queryset = PveBackupStatus.objects.select_related("vm")
308
+ table = PveBackupStatusTable
309
+ filterset = PveBackupStatusFilterSet
310
+
311
+
312
+ class PveBackupStatusView(generic.ObjectView):
313
+ queryset = PveBackupStatus.objects.all()
314
+ template_name = "pve_sync/pvebackupstatus.html"
315
+
316
+
317
+ # ============================================================================
318
+ # PveDriftEvent
319
+ # ============================================================================
320
+
321
+ class PveDriftEventListView(generic.ObjectListView):
322
+ queryset = PveDriftEvent.objects.select_related("sync_job")
323
+ table = PveDriftEventTable
324
+ filterset = PveDriftEventFilterSet
325
+ filterset_form = PveDriftEventFilterForm
326
+
327
+
328
+ class PveDriftEventView(generic.ObjectView):
329
+ queryset = PveDriftEvent.objects.select_related("sync_job")
330
+ template_name = "pve_sync/pvedriftevent.html"
331
+
332
+
333
+ class PveDriftEventDeleteView(generic.ObjectDeleteView):
334
+ queryset = PveDriftEvent.objects.all()
335
+
336
+
337
+ class PveDriftEventBulkDeleteView(generic.BulkDeleteView):
338
+ queryset = PveDriftEvent.objects.all()
339
+ table = PveDriftEventTable
340
+
341
+
342
+ # ============================================================================
343
+ # Plugin Settings (singleton)
344
+ # ============================================================================
345
+
346
+ class PvePluginSettingsView(PermissionRequiredMixin, View):
347
+ """Edit singleton plugin settings from the NetBox Web UI."""
348
+
349
+ permission_required = "pve_sync_plugin.change_pvepluginsettings"
350
+
351
+ def get(self, request):
352
+ settings_obj = PvePluginSettings.load()
353
+ form = PvePluginSettingsForm(instance=settings_obj)
354
+ return render(request, "pve_sync/settings.html", {"form": form})
355
+
356
+ def post(self, request):
357
+ settings_obj = PvePluginSettings.load()
358
+ form = PvePluginSettingsForm(request.POST, instance=settings_obj)
359
+ if form.is_valid():
360
+ form.save()
361
+ messages.success(request, "Proxmox Sync settings saved.")
362
+ return redirect(reverse("plugins:pve_sync_plugin:settings"))
363
+ return render(request, "pve_sync/settings.html", {"form": form})
364
+
365
+
366
+ # ============================================================================
367
+ # Sync Triggers
368
+ # ============================================================================
369
+
370
+ class TriggerSyncView(PermissionRequiredMixin, View):
371
+ """Web GUI endpoint for manually starting a sync."""
372
+
373
+ permission_required = "pve_sync_plugin.add_pvesyncjob"
374
+
375
+ def post(self, request):
376
+ cluster_name = request.POST.get("cluster") or "default"
377
+
378
+ # Prevent duplicate sync jobs for the same cluster
379
+ if has_active_sync_job(cluster_name):
380
+ messages.warning(
381
+ request,
382
+ f"Cluster '{cluster_name}' already has an active sync job. Please try again later.",
383
+ )
384
+ return redirect(reverse("plugins:pve_sync_plugin:dashboard"))
385
+
386
+ try:
387
+ job = create_and_enqueue_sync_job(cluster_name, request.user, "manual")
388
+ messages.success(request, f"PVE sync queued as background job #{job.id}.")
389
+ return redirect(job.get_absolute_url())
390
+ except Exception as exc:
391
+ messages.error(request, f"Unable to start sync: {exc}")
392
+ return redirect(reverse("plugins:pve_sync_plugin:dashboard"))
393
+
394
+ def get(self, request):
395
+ messages.info(request, "Use Sync Now from the dashboard to start a sync.")
396
+ return redirect(reverse("plugins:pve_sync_plugin:dashboard"))
397
+
398
+
399
+ class FullSyncView(PermissionRequiredMixin, View):
400
+ """Clear state_db cache for a cluster and trigger a full resync."""
401
+
402
+ permission_required = "pve_sync_plugin.add_pvesyncjob"
403
+
404
+ def post(self, request, pk):
405
+ from .models import PveClusterConfig, PvePluginSettings
406
+ cluster = get_object_or_404(PveClusterConfig, pk=pk)
407
+
408
+ if has_active_sync_job(cluster.name):
409
+ messages.warning(request, f"Cluster '{cluster.name}' already has an active sync — please wait.")
410
+ return redirect(cluster.get_absolute_url())
411
+
412
+ try:
413
+ settings = PvePluginSettings.load()
414
+ db_path = settings.state_db_path or "/var/lib/pve-sync/state.db"
415
+ import sys, os
416
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
417
+ from state_db import StateDB
418
+ cleared = StateDB(db_path).clear_cluster_cache(cluster.name)
419
+ logger.info("Full sync: cleared %d state_db rows for cluster '%s'", cleared, cluster.name)
420
+ except Exception as exc:
421
+ logger.warning("Full sync: could not clear state_db: %s", exc)
422
+
423
+ try:
424
+ job = create_and_enqueue_sync_job(cluster.name, request.user, "manual",
425
+ details={"source": "full_sync"})
426
+ messages.success(request, f"Cache cleared — full sync queued as job #{job.id}.")
427
+ return redirect(job.get_absolute_url())
428
+ except Exception as exc:
429
+ messages.error(request, f"Unable to start full sync: {exc}")
430
+ return redirect(cluster.get_absolute_url())
431
+
432
+
433
+ class TriggerVmSyncView(PermissionRequiredMixin, View):
434
+ """Queue a sync from a VM detail page."""
435
+
436
+ permission_required = "pve_sync_plugin.add_pvesyncjob"
437
+
438
+ def post(self, request, vm_id):
439
+ from virtualization.models import VirtualMachine
440
+
441
+ vm = get_object_or_404(VirtualMachine, pk=vm_id)
442
+ cluster_name = resolve_cluster_name_for_vm(vm)
443
+
444
+ try:
445
+ job = create_and_enqueue_sync_job(
446
+ cluster_name,
447
+ request.user,
448
+ "manual",
449
+ details={
450
+ "vm_id": vm.pk,
451
+ "vm_name": vm.name,
452
+ "source": "vm_detail",
453
+ },
454
+ )
455
+ messages.success(request, f"PVE sync for {vm.name} queued as background job #{job.id}.")
456
+ except Exception as exc:
457
+ messages.error(request, f"Unable to start sync for {vm.name}: {exc}")
458
+
459
+ return redirect(request.POST.get("return_url") or vm.get_absolute_url())
460
+
461
+
462
+ # ============================================================================
463
+ # Webhook Receiver (function-based 鈥?needs @csrf_exempt)
464
+ # ============================================================================
465
+
466
+ @csrf_exempt
467
+ @require_http_methods(["POST"])
468
+ def webhook_receiver(request):
469
+ """
470
+ 鎺ユ敹 PVE Webhook 浜嬩欢
471
+
472
+ POST /api/plugins/pve-sync/webhook/
473
+
474
+ Headers:
475
+ X-PVE-Signature: <hmac_sha256_signature>
476
+
477
+ Body (JSON):
478
+ {
479
+ "event": "vm-started",
480
+ "node": "pve01",
481
+ "vmid": 100,
482
+ "vmname": "web01",
483
+ "timestamp": "2025-03-30T15:30:00Z"
484
+ }
485
+ """
486
+ try:
487
+ raw_body = request.body.decode("utf-8")
488
+ data = json.loads(raw_body)
489
+
490
+ webhook_secret = get_plugin_config("webhook_secret")
491
+ if webhook_secret:
492
+ signature = request.headers.get("X-PVE-Signature", "")
493
+ expected = hmac.new(
494
+ webhook_secret.encode(), raw_body.encode(), hashlib.sha256
495
+ ).hexdigest()
496
+
497
+ if not hmac.compare_digest(signature, expected):
498
+ return JsonResponse(
499
+ {"status": "error", "message": "Invalid signature"}, status=403
500
+ )
501
+
502
+ # Suppress changelog: webhook is unauthenticated so current_request.user
503
+ # would be AnonymousUser which ObjectChange.user rejects.
504
+ _saved_request = _nb_current_request.get()
505
+ _nb_current_request.set(None)
506
+ try:
507
+ event = PveWebhookEvent.objects.create(
508
+ event_type=data.get("event", "unknown"),
509
+ node=data.get("node"),
510
+ vmid=data.get("vmid"),
511
+ vm_name=data.get("vmname"),
512
+ raw_data=data,
513
+ )
514
+ finally:
515
+ _nb_current_request.set(_saved_request)
516
+
517
+ rq_job_id = enqueue_webhook_event(event)
518
+
519
+ return JsonResponse(
520
+ {
521
+ "status": "success",
522
+ "message": "Webhook received",
523
+ "event_id": event.id,
524
+ "rq_job_id": rq_job_id,
525
+ }
526
+ )
527
+
528
+ except json.JSONDecodeError:
529
+ return JsonResponse(
530
+ {"status": "error", "message": "Invalid JSON"}, status=400
531
+ )
532
+ except Exception as e:
533
+ return JsonResponse(
534
+ {"status": "error", "message": str(e)}, status=500
535
+ )