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/forms.py
ADDED
|
@@ -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 <name></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
|