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,219 @@
1
+ """Forms for the PVE Sync plugin UI."""
2
+
3
+ from django import forms
4
+
5
+ from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm
6
+ from utilities.forms.fields import DynamicModelChoiceField
7
+
8
+ from dcim.models import Site
9
+ from virtualization.models import Cluster, ClusterType
10
+
11
+ from .choices import (
12
+ BackupStatusChoices,
13
+ DriftTypeChoices,
14
+ SyncJobStatusChoices,
15
+ SyncJobTriggerChoices,
16
+ SyncScheduleChoices,
17
+ WebhookEventChoices,
18
+ )
19
+ from .models import PbsServerConfig, PveClusterConfig, PveDriftEvent, PvePluginSettings, PveSyncJob, PveWebhookEvent
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Model Forms (create / edit)
24
+ # ---------------------------------------------------------------------------
25
+
26
+ class PvePluginSettingsForm(NetBoxModelForm):
27
+ """Editable singleton settings for plugin-wide defaults."""
28
+
29
+ netbox_token = forms.CharField(
30
+ required=False,
31
+ widget=forms.PasswordInput(render_value=True),
32
+ )
33
+ telegram_bot_token = forms.CharField(
34
+ required=False,
35
+ widget=forms.PasswordInput(render_value=True),
36
+ )
37
+ webhook_secret = forms.CharField(
38
+ required=False,
39
+ widget=forms.PasswordInput(render_value=True),
40
+ )
41
+
42
+ class Meta:
43
+ model = PvePluginSettings
44
+ fields = (
45
+ "netbox_url",
46
+ "netbox_token",
47
+ "telegram_bot_token",
48
+ "telegram_chat_id",
49
+ "webhook_secret",
50
+ "default_cluster_name",
51
+ "default_netbox_cluster",
52
+ "default_site",
53
+ "default_cluster_type",
54
+ "default_node_role",
55
+ "default_node_type",
56
+ "state_db_path",
57
+ "enable_backup_sync",
58
+ )
59
+ help_texts = {
60
+ "netbox_url": "NetBox base URL (e.g. https://netbox.example.com)",
61
+ "netbox_token": "NetBox API token with write permissions",
62
+ "telegram_bot_token": "Telegram bot token for notifications (optional)",
63
+ "telegram_chat_id": (
64
+ "Telegram chat/group ID for notifications (optional). "
65
+ "For group chats, the ID must start with a minus sign, e.g. -1002581073501"
66
+ ),
67
+ "webhook_secret": "HMAC shared secret for PVE webhook signature verification",
68
+ "state_db_path": "Path to SQLite state database for incremental sync",
69
+ }
70
+
71
+ def save(self, *args, **kwargs):
72
+ instance = super().save(*args, **kwargs)
73
+ # Invalidate cached settings so new values take effect immediately
74
+ from .utils import clear_plugin_config_cache
75
+
76
+ clear_plugin_config_cache()
77
+ return instance
78
+
79
+
80
+ class PveClusterConfigForm(NetBoxModelForm):
81
+ """Cluster configuration form using NetBox dynamic selectors."""
82
+
83
+ pve_secret = forms.CharField(widget=forms.PasswordInput(render_value=True))
84
+
85
+ netbox_site = DynamicModelChoiceField(
86
+ queryset=Site.objects.all(),
87
+ required=False,
88
+ )
89
+ netbox_cluster_type = DynamicModelChoiceField(
90
+ queryset=ClusterType.objects.all(),
91
+ required=False,
92
+ )
93
+ netbox_cluster = DynamicModelChoiceField(
94
+ queryset=Cluster.objects.all(),
95
+ required=False,
96
+ )
97
+
98
+ class Meta:
99
+ model = PveClusterConfig
100
+ fields = (
101
+ "name",
102
+ "description",
103
+ "pve_host",
104
+ "pve_user",
105
+ "pve_token",
106
+ "pve_secret",
107
+ "pve_verify_ssl",
108
+ "netbox_site",
109
+ "netbox_cluster_type",
110
+ "netbox_cluster",
111
+ "enabled",
112
+ "sync_schedule",
113
+ "notify_on_sync",
114
+ )
115
+ help_texts = {
116
+ "sync_schedule": (
117
+ "For reference only. Use a crontab or systemd timer calling "
118
+ "<code>manage.py pve_sync --cluster &lt;name&gt;</code> "
119
+ "at the desired interval."
120
+ ),
121
+ }
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Filter Forms (list view sidebar filters)
126
+ # ---------------------------------------------------------------------------
127
+
128
+ class PveSyncJobFilterForm(NetBoxModelFilterSetForm):
129
+ """Filter form displayed in the PveSyncJob list view sidebar."""
130
+
131
+ model = PveSyncJob
132
+
133
+ status = forms.MultipleChoiceField(
134
+ choices=SyncJobStatusChoices.choices,
135
+ required=False,
136
+ )
137
+ trigger = forms.MultipleChoiceField(
138
+ choices=SyncJobTriggerChoices.choices,
139
+ required=False,
140
+ )
141
+ cluster_name = forms.CharField(required=False)
142
+
143
+
144
+ class PveWebhookEventFilterForm(NetBoxModelFilterSetForm):
145
+ """Filter form for webhook event list views."""
146
+
147
+ model = PveWebhookEvent
148
+
149
+ event_type = forms.MultipleChoiceField(
150
+ choices=WebhookEventChoices.choices,
151
+ required=False,
152
+ )
153
+ processed = forms.NullBooleanField(required=False)
154
+
155
+
156
+ class PveClusterConfigFilterForm(NetBoxModelFilterSetForm):
157
+ """Filter form for cluster config list views."""
158
+
159
+ model = PveClusterConfig
160
+
161
+ enabled = forms.NullBooleanField(required=False)
162
+ sync_schedule = forms.MultipleChoiceField(
163
+ choices=SyncScheduleChoices.choices,
164
+ required=False,
165
+ )
166
+
167
+
168
+ class PbsServerConfigForm(NetBoxModelForm):
169
+ """PBS server configuration form."""
170
+
171
+ pbs_token_secret = forms.CharField(
172
+ widget=forms.PasswordInput(render_value=True),
173
+ )
174
+ netbox_site = DynamicModelChoiceField(
175
+ queryset=Site.objects.all(),
176
+ required=False,
177
+ )
178
+
179
+ class Meta:
180
+ model = PbsServerConfig
181
+ fields = (
182
+ "name",
183
+ "description",
184
+ "pbs_host",
185
+ "pbs_token_name",
186
+ "pbs_token_secret",
187
+ "pbs_verify_ssl",
188
+ "pbs_node_name",
189
+ "netbox_site",
190
+ "enabled",
191
+ "sync_schedule",
192
+ )
193
+ help_texts = {
194
+ "sync_schedule": (
195
+ "For reference only. Use a crontab or systemd timer to run "
196
+ "PBS sync at the desired interval."
197
+ ),
198
+ }
199
+
200
+
201
+ class PveDriftEventFilterForm(NetBoxModelFilterSetForm):
202
+ """Filter form for drift event list views."""
203
+
204
+ model = PveDriftEvent
205
+
206
+ drift_type = forms.MultipleChoiceField(
207
+ choices=DriftTypeChoices.choices,
208
+ required=False,
209
+ )
210
+ cluster_name = forms.CharField(required=False, label="叢集名稱")
211
+ notified_telegram = forms.NullBooleanField(required=False, label="已通知 Telegram")
212
+
213
+
214
+ class PbsServerConfigFilterForm(NetBoxModelFilterSetForm):
215
+ """Filter form for PBS server config list views."""
216
+
217
+ model = PbsServerConfig
218
+
219
+ enabled = forms.NullBooleanField(required=False)
@@ -0,0 +1 @@
1
+ """Management command package for the PVE Sync plugin."""
@@ -0,0 +1 @@
1
+ """Django management commands for the PVE Sync plugin."""
@@ -0,0 +1,182 @@
1
+ """Queue a PVE-NetBox sync through the NetBox plugin job path."""
2
+
3
+ import json
4
+ import time
5
+ from argparse import ArgumentParser
6
+
7
+ from django.core.management.base import BaseCommand, CommandError
8
+ from django.utils import timezone
9
+
10
+
11
+ class Command(BaseCommand):
12
+ """Headless equivalent of clicking Sync now in the plugin UI."""
13
+
14
+ help = "Queue a PVE-NetBox sync using the plugin's PveSyncJob model and RQ worker."
15
+
16
+ def add_arguments(self, parser: ArgumentParser):
17
+ parser.add_argument(
18
+ "--cluster",
19
+ default="default",
20
+ help="PVE cluster profile name to sync. Defaults to the plugin default profile.",
21
+ )
22
+ parser.add_argument(
23
+ "--user",
24
+ dest="username",
25
+ default=None,
26
+ help="Username to attribute the job to. Defaults to the oldest active superuser.",
27
+ )
28
+ parser.add_argument(
29
+ "--wait",
30
+ action="store_true",
31
+ help="Wait for the queued job to finish and mirror its success/failure.",
32
+ )
33
+ parser.add_argument(
34
+ "--timeout",
35
+ type=int,
36
+ default=7200,
37
+ help="Maximum seconds to wait when --wait is set.",
38
+ )
39
+ parser.add_argument(
40
+ "--poll-interval",
41
+ type=float,
42
+ default=2.0,
43
+ help="Seconds between status checks when --wait is set.",
44
+ )
45
+ parser.add_argument(
46
+ "--force",
47
+ action="store_true",
48
+ help="Skip duplicate-job check and force a new sync.",
49
+ )
50
+ parser.add_argument(
51
+ "--dry-run",
52
+ action="store_true",
53
+ help="Print resolved configuration (with secrets redacted) and exit without syncing.",
54
+ )
55
+ parser.add_argument(
56
+ "--cleanup-stale",
57
+ action="store_true",
58
+ help="Mark stale jobs (>30 min in pending/running) as failed, then exit.",
59
+ )
60
+
61
+ def handle(self, *args, **options):
62
+ from pve_sync_plugin.models import PveClusterConfig, PveSyncJob
63
+ from pve_sync_plugin.tasks import cleanup_stale_jobs, enqueue_sync
64
+
65
+ # --cleanup-stale: housekeeping mode
66
+ if options["cleanup_stale"]:
67
+ count = cleanup_stale_jobs()
68
+ self.stdout.write(
69
+ self.style.SUCCESS(f"Cleaned up {count} stale job(s).")
70
+ )
71
+ return
72
+
73
+ cluster_name = options["cluster"]
74
+ if cluster_name != "default":
75
+ exists = PveClusterConfig.objects.filter(
76
+ name=cluster_name,
77
+ enabled=True,
78
+ ).exists()
79
+ if not exists:
80
+ raise CommandError(f"Enabled PVE cluster profile '{cluster_name}' was not found.")
81
+
82
+ # --dry-run: print config and exit
83
+ if options["dry_run"]:
84
+ self._handle_dry_run(cluster_name)
85
+ return
86
+
87
+ # Check for duplicate running jobs (unless --force)
88
+ if not options["force"]:
89
+ active = PveSyncJob.objects.filter(
90
+ cluster_name=cluster_name,
91
+ status__in=["pending", "running"],
92
+ ).exists()
93
+ if active:
94
+ raise CommandError(
95
+ f"A sync job is already running for cluster '{cluster_name}'. "
96
+ f"Use --force to bypass this check."
97
+ )
98
+
99
+ user = self._resolve_user(options.get("username"))
100
+ job = PveSyncJob.objects.create(
101
+ cluster_name=cluster_name,
102
+ status="pending",
103
+ trigger="scheduled",
104
+ triggered_by=user,
105
+ details={"source": "management_command"},
106
+ )
107
+
108
+ try:
109
+ rq_job_id = enqueue_sync(job)
110
+ except Exception as exc:
111
+ raise CommandError(f"Failed to enqueue PVE sync job: {exc}") from exc
112
+
113
+ self.stdout.write(
114
+ self.style.SUCCESS(
115
+ f"Queued PVE sync job #{job.pk} for cluster '{cluster_name}'"
116
+ f" on RQ job {rq_job_id}."
117
+ )
118
+ )
119
+
120
+ if options["wait"]:
121
+ self._wait_for_job(job.pk, options["timeout"], options["poll_interval"])
122
+
123
+ def _handle_dry_run(self, cluster_name):
124
+ """Print the resolved runtime config with secrets redacted."""
125
+ from pve_sync_plugin.sync.config_bridge import build_runtime_config_from_db
126
+
127
+ config = build_runtime_config_from_db(cluster_name)
128
+
129
+ # Redact sensitive values
130
+ for c in config.get("clusters", []):
131
+ pve = c.get("pve", {})
132
+ for secret_key in ("secret", "token"):
133
+ if pve.get(secret_key):
134
+ pve[secret_key] = "***REDACTED***"
135
+ nb = c.get("netbox", {})
136
+ if nb.get("token"):
137
+ nb["token"] = "***REDACTED***"
138
+ tg = config.get("telegram", {})
139
+ if tg.get("bot_token"):
140
+ tg["bot_token"] = "***REDACTED***"
141
+
142
+ self.stdout.write(json.dumps(config, indent=2, ensure_ascii=False))
143
+ self.stdout.write(
144
+ self.style.WARNING("\nDry run — no sync was executed.")
145
+ )
146
+
147
+ def _resolve_user(self, username):
148
+ from django.contrib.auth import get_user_model
149
+
150
+ user_model = get_user_model()
151
+ if username:
152
+ user = user_model.objects.filter(username=username).first()
153
+ if user is None:
154
+ raise CommandError(f"User '{username}' does not exist.")
155
+ return user
156
+
157
+ return (
158
+ user_model.objects.filter(is_active=True, is_superuser=True)
159
+ .order_by("pk")
160
+ .first()
161
+ )
162
+
163
+ def _wait_for_job(self, job_id, timeout, poll_interval):
164
+ from pve_sync_plugin.models import PveSyncJob
165
+
166
+ deadline = timezone.now().timestamp() + timeout
167
+ while timezone.now().timestamp() < deadline:
168
+ job = PveSyncJob.objects.get(pk=job_id)
169
+ if job.status in {"success", "partial"}:
170
+ self.stdout.write(
171
+ self.style.SUCCESS(
172
+ f"PVE sync job #{job_id} finished: {job.status} — "
173
+ f"{job.success_vms}/{job.total_vms} VMs synced."
174
+ )
175
+ )
176
+ return
177
+ if job.status == "failed":
178
+ error = job.details.get("error") or job.details.get("queue_error") or "unknown error"
179
+ raise CommandError(f"PVE sync job #{job_id} failed: {error}")
180
+ time.sleep(poll_interval)
181
+
182
+ raise CommandError(f"Timed out waiting for PVE sync job #{job_id}.")
@@ -0,0 +1,109 @@
1
+ """Check all PVE/PBS configs with a sync schedule and trigger overdue ones."""
2
+
3
+ import datetime
4
+ import logging
5
+
6
+ from django.core.management.base import BaseCommand
7
+ from django.utils import timezone
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _SCHEDULE_INTERVALS = {
12
+ "hourly": datetime.timedelta(hours=1),
13
+ "every_3h": datetime.timedelta(hours=3),
14
+ "every_6h": datetime.timedelta(hours=6),
15
+ "daily": datetime.timedelta(hours=24),
16
+ "weekly": datetime.timedelta(weeks=1),
17
+ }
18
+
19
+
20
+ class Command(BaseCommand):
21
+ help = "Trigger syncs for PVE clusters and PBS servers that are past their scheduled interval."
22
+
23
+ def add_arguments(self, parser):
24
+ parser.add_argument(
25
+ "--dry-run",
26
+ action="store_true",
27
+ help="Print what would run without actually doing it.",
28
+ )
29
+
30
+ def handle(self, *args, **options):
31
+ dry_run = options["dry_run"]
32
+ now = timezone.now()
33
+ triggered = 0
34
+
35
+ triggered += self._check_pve_clusters(now, dry_run)
36
+ triggered += self._check_pbs_servers(now, dry_run)
37
+
38
+ level = self.style.SUCCESS if triggered else self.style.WARNING
39
+ self.stdout.write(level(
40
+ f"{'[dry-run] ' if dry_run else ''}Triggered {triggered} scheduled sync(s)."
41
+ ))
42
+
43
+ def _is_due(self, last_sync, schedule, now):
44
+ interval = _SCHEDULE_INTERVALS.get(schedule)
45
+ if not interval:
46
+ return False
47
+ if last_sync is None:
48
+ return True
49
+ return (now - last_sync) >= interval
50
+
51
+ def _check_pve_clusters(self, now, dry_run):
52
+ from pve_sync_plugin.models import PveClusterConfig, PveSyncJob
53
+ from pve_sync_plugin.view_helpers import has_active_sync_job
54
+ from pve_sync_plugin.tasks import enqueue_sync
55
+
56
+ triggered = 0
57
+ clusters = PveClusterConfig.objects.filter(enabled=True).exclude(sync_schedule="disabled")
58
+ for cluster in clusters:
59
+ if not self._is_due(cluster.last_sync, cluster.sync_schedule, now):
60
+ logger.debug("PVE cluster '%s' not yet due (schedule=%s, last_sync=%s)",
61
+ cluster.name, cluster.sync_schedule, cluster.last_sync)
62
+ continue
63
+
64
+ if has_active_sync_job(cluster.name):
65
+ self.stdout.write(f" Skipping '{cluster.name}': sync already running.")
66
+ continue
67
+
68
+ self.stdout.write(f" Triggering PVE sync for '{cluster.name}' (schedule={cluster.sync_schedule})")
69
+ if not dry_run:
70
+ job = PveSyncJob.objects.create(
71
+ cluster_name=cluster.name,
72
+ status="pending",
73
+ trigger="scheduled",
74
+ details={"source": "run_scheduled_syncs"},
75
+ )
76
+ enqueue_sync(job)
77
+ triggered += 1
78
+
79
+ return triggered
80
+
81
+ def _check_pbs_servers(self, now, dry_run):
82
+ from pve_sync_plugin.models import PbsServerConfig, PveSyncJob
83
+ from pve_sync_plugin.tasks import enqueue_pbs_sync
84
+ from pve_sync_plugin.view_helpers import has_active_sync_job
85
+
86
+ triggered = 0
87
+ servers = PbsServerConfig.objects.filter(enabled=True).exclude(sync_schedule="disabled")
88
+ for pbs in servers:
89
+ if not self._is_due(pbs.last_sync, pbs.sync_schedule, now):
90
+ logger.debug("PBS server '%s' not yet due (schedule=%s, last_sync=%s)",
91
+ pbs.name, pbs.sync_schedule, pbs.last_sync)
92
+ continue
93
+
94
+ if has_active_sync_job(f"pbs:{pbs.name}"):
95
+ self.stdout.write(f" Skipping PBS '{pbs.name}': sync already running.")
96
+ continue
97
+
98
+ self.stdout.write(f" Triggering PBS sync for '{pbs.name}' (schedule={pbs.sync_schedule})")
99
+ if not dry_run:
100
+ job = PveSyncJob.objects.create(
101
+ cluster_name=f"pbs:{pbs.name}",
102
+ status="pending",
103
+ trigger="scheduled",
104
+ details={"source": "run_scheduled_syncs", "pbs_server_pk": pbs.pk},
105
+ )
106
+ enqueue_pbs_sync(job, pbs)
107
+ triggered += 1
108
+
109
+ return triggered