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.
- config.py +337 -0
- pbs_sync.py +244 -0
- proxsync-2.0.0.dist-info/METADATA +468 -0
- proxsync-2.0.0.dist-info/RECORD +59 -0
- proxsync-2.0.0.dist-info/WHEEL +5 -0
- proxsync-2.0.0.dist-info/top_level.txt +5 -0
- pve_sync.py +1804 -0
- pve_sync_plugin/__init__.py +46 -0
- pve_sync_plugin/api/__init__.py +1 -0
- pve_sync_plugin/api/serializers.py +161 -0
- pve_sync_plugin/api/urls.py +27 -0
- pve_sync_plugin/api/views.py +94 -0
- pve_sync_plugin/apps.py +5 -0
- pve_sync_plugin/choices.py +57 -0
- pve_sync_plugin/filtersets.py +137 -0
- pve_sync_plugin/forms.py +219 -0
- pve_sync_plugin/management/__init__.py +1 -0
- pve_sync_plugin/management/commands/__init__.py +1 -0
- pve_sync_plugin/management/commands/pve_sync.py +182 -0
- pve_sync_plugin/management/commands/run_scheduled_syncs.py +109 -0
- pve_sync_plugin/migrations/0001_initial.py +234 -0
- pve_sync_plugin/migrations/0002_pvepluginsettings.py +42 -0
- pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +148 -0
- pve_sync_plugin/migrations/0004_model_verbose_names.py +34 -0
- pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +111 -0
- pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +124 -0
- pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +26 -0
- pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +26 -0
- pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +39 -0
- pve_sync_plugin/migrations/0010_pvedriftevent.py +62 -0
- pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +19 -0
- pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +39 -0
- pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +19 -0
- pve_sync_plugin/migrations/__init__.py +1 -0
- pve_sync_plugin/models.py +461 -0
- pve_sync_plugin/navigation.py +94 -0
- pve_sync_plugin/signals.py +24 -0
- pve_sync_plugin/sync/__init__.py +8 -0
- pve_sync_plugin/sync/config_bridge.py +221 -0
- pve_sync_plugin/sync/engine.py +50 -0
- pve_sync_plugin/tables.py +278 -0
- pve_sync_plugin/tasks.py +440 -0
- pve_sync_plugin/template_content.py +44 -0
- pve_sync_plugin/templates/pve_sync/dashboard.html +400 -0
- pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +7 -0
- pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +69 -0
- pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +27 -0
- pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +52 -0
- pve_sync_plugin/templates/pve_sync/pvedriftevent.html +89 -0
- pve_sync_plugin/templates/pve_sync/pvesyncjob.html +197 -0
- pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +42 -0
- pve_sync_plugin/templates/pve_sync/settings.html +79 -0
- pve_sync_plugin/templatetags/__init__.py +1 -0
- pve_sync_plugin/templatetags/pve_sync_tags.py +86 -0
- pve_sync_plugin/urls.py +186 -0
- pve_sync_plugin/utils.py +84 -0
- pve_sync_plugin/view_helpers.py +98 -0
- pve_sync_plugin/views.py +535 -0
- 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"
|
pve_sync_plugin/views.py
ADDED
|
@@ -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
|
+
)
|