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
pve_sync_plugin/tasks.py
ADDED
|
@@ -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]
|