netbox-vcloud 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/PKG-INFO +1 -1
- netbox_vcloud-0.2.2/netbox_vcloud/__init__.py +22 -0
- netbox_vcloud-0.2.2/netbox_vcloud/admin.py +37 -0
- netbox_vcloud-0.2.2/netbox_vcloud/api/serializers.py +12 -0
- netbox_vcloud-0.2.2/netbox_vcloud/api/urls.py +7 -0
- netbox_vcloud-0.2.2/netbox_vcloud/api/views.py +8 -0
- netbox_vcloud-0.2.2/netbox_vcloud/forms.py +20 -0
- netbox_vcloud-0.2.2/netbox_vcloud/jobs/__init__.py +1 -0
- netbox_vcloud-0.2.2/netbox_vcloud/jobs/cloudsync.py +340 -0
- netbox_vcloud-0.2.2/netbox_vcloud/migrations/0001_initial.py +40 -0
- netbox_vcloud-0.2.2/netbox_vcloud/migrations/0002_cloudsyncconfig_next_sync.py +18 -0
- netbox_vcloud-0.2.2/netbox_vcloud/migrations/0003_cloudsyncconfig_netbox_cluster_and_more.py +52 -0
- netbox_vcloud-0.2.2/netbox_vcloud/models.py +47 -0
- netbox_vcloud-0.2.2/netbox_vcloud/navigation.py +15 -0
- netbox_vcloud-0.2.2/netbox_vcloud/sync.py +102 -0
- netbox_vcloud-0.2.2/netbox_vcloud/sync_utils.py +37 -0
- netbox_vcloud-0.2.2/netbox_vcloud/tables.py +18 -0
- netbox_vcloud-0.2.2/netbox_vcloud/template_content.py +22 -0
- netbox_vcloud-0.2.2/netbox_vcloud/templates/netbox_cloudsync/cloudsyncconfig.html +32 -0
- netbox_vcloud-0.2.2/netbox_vcloud/templates/netbox_cloudsync/cloudsyncconfig_list.html +5 -0
- netbox_vcloud-0.2.2/netbox_vcloud/templatetags/__init__.py +1 -0
- netbox_vcloud-0.2.2/netbox_vcloud/templatetags/boolean_icon.py +10 -0
- netbox_vcloud-0.2.2/netbox_vcloud/urls.py +16 -0
- netbox_vcloud-0.2.2/netbox_vcloud/views.py +61 -0
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/netbox_vcloud.egg-info/PKG-INFO +1 -1
- netbox_vcloud-0.2.2/netbox_vcloud.egg-info/SOURCES.txt +33 -0
- netbox_vcloud-0.2.2/netbox_vcloud.egg-info/top_level.txt +1 -0
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/pyproject.toml +1 -1
- netbox_vcloud-0.2.0/netbox_vcloud.egg-info/SOURCES.txt +0 -9
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/MANIFEST.in +0 -0
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/README.md +0 -0
- /netbox_vcloud-0.2.0/netbox_vcloud.egg-info/top_level.txt → /netbox_vcloud-0.2.2/netbox_vcloud/migrations/__init__.py +0 -0
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/netbox_vcloud.egg-info/dependency_links.txt +0 -0
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/netbox_vcloud.egg-info/requires.txt +0 -0
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/setup.cfg +0 -0
- {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/test/test_sync_utils.py +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from netbox.plugins import PluginConfig
|
|
3
|
+
except ModuleNotFoundError: # pragma: no cover - allows lightweight tooling/tests
|
|
4
|
+
PluginConfig = None
|
|
5
|
+
config = None
|
|
6
|
+
else:
|
|
7
|
+
class CloudSyncConfig(PluginConfig):
|
|
8
|
+
name = "netbox_vcloud"
|
|
9
|
+
verbose_name = "NetBox vCloud"
|
|
10
|
+
description = "Synchronize vCloud VMs into NetBox using ORM"
|
|
11
|
+
version = "0.2.0"
|
|
12
|
+
author = "Serhii Zahuba"
|
|
13
|
+
author_email = "dev@cre.com"
|
|
14
|
+
base_url = "vcloud"
|
|
15
|
+
required_settings = []
|
|
16
|
+
default_settings = {}
|
|
17
|
+
|
|
18
|
+
def ready(self):
|
|
19
|
+
super().ready()
|
|
20
|
+
from . import jobs, template_content
|
|
21
|
+
|
|
22
|
+
config = CloudSyncConfig
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from .models import CloudSyncConfig
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@admin.register(CloudSyncConfig)
|
|
6
|
+
class CloudSyncConfigAdmin(admin.ModelAdmin):
|
|
7
|
+
"""Адмін-інтерфейс для таблиці конфігурацій CloudSync"""
|
|
8
|
+
|
|
9
|
+
list_display = (
|
|
10
|
+
"name",
|
|
11
|
+
"vcloud_url",
|
|
12
|
+
"vcloud_user",
|
|
13
|
+
"sync_interval_minutes",
|
|
14
|
+
"last_sync",
|
|
15
|
+
"enabled",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
list_filter = ("enabled",)
|
|
19
|
+
search_fields = ("name", "vcloud_url", "vcloud_user")
|
|
20
|
+
ordering = ("name",)
|
|
21
|
+
|
|
22
|
+
fieldsets = (
|
|
23
|
+
(None, {
|
|
24
|
+
"fields": (
|
|
25
|
+
"name",
|
|
26
|
+
"vcloud_url",
|
|
27
|
+
"vcloud_user",
|
|
28
|
+
"vcloud_password",
|
|
29
|
+
"sync_interval_minutes",
|
|
30
|
+
"enabled",
|
|
31
|
+
)
|
|
32
|
+
}),
|
|
33
|
+
("Статус", {
|
|
34
|
+
"fields": ("last_sync",),
|
|
35
|
+
"classes": ("collapse",),
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
2
|
+
from ..models import CloudSyncConfig
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CloudSyncConfigSerializer(NetBoxModelSerializer):
|
|
6
|
+
class Meta:
|
|
7
|
+
model = CloudSyncConfig
|
|
8
|
+
fields = (
|
|
9
|
+
'id', 'display', 'name', 'vcloud_url',
|
|
10
|
+
'vcloud_user', 'sync_interval_minutes',
|
|
11
|
+
'last_sync', 'enabled', 'created', 'last_updated'
|
|
12
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from netbox.api.viewsets import NetBoxModelViewSet
|
|
2
|
+
from ..models import CloudSyncConfig
|
|
3
|
+
from .serializers import CloudSyncConfigSerializer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CloudSyncConfigViewSet(NetBoxModelViewSet):
|
|
7
|
+
queryset = CloudSyncConfig.objects.all()
|
|
8
|
+
serializer_class = CloudSyncConfigSerializer
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from netbox.forms import NetBoxModelForm
|
|
2
|
+
from dcim.models import Site, DeviceRole, Platform
|
|
3
|
+
from virtualization.models import Cluster
|
|
4
|
+
from tenancy.models import Tenant
|
|
5
|
+
from .models import CloudSyncConfig
|
|
6
|
+
|
|
7
|
+
class CloudSyncConfigForm(NetBoxModelForm):
|
|
8
|
+
class Meta:
|
|
9
|
+
model = CloudSyncConfig
|
|
10
|
+
fields = (
|
|
11
|
+
'name', 'vcloud_url', 'vcloud_user', 'vcloud_password',
|
|
12
|
+
'sync_interval_minutes', 'sync_templates', 'sync_poweroff',
|
|
13
|
+
'netbox_site', 'netbox_cluster', 'netbox_role',
|
|
14
|
+
'netbox_tenant', 'netbox_platform',
|
|
15
|
+
'last_sync', 'next_sync', 'enabled'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs):
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
self.fields['sync_interval_minutes'].disabled = True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .cloudsync import *
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import time
|
|
3
|
+
import re
|
|
4
|
+
import requests
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
from netbox.jobs import JobRunner, system_job
|
|
8
|
+
from core.choices import JobIntervalChoices
|
|
9
|
+
from ..models import CloudSyncConfig
|
|
10
|
+
from virtualization.models import VirtualMachine, Cluster, VMInterface
|
|
11
|
+
from tenancy.models import Tenant
|
|
12
|
+
from dcim.models import Site, Platform, DeviceRole
|
|
13
|
+
from ipam.models import IPAddress, VLAN
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ==============================================
|
|
17
|
+
# 🧩 Main job: sync vCloud → NetBox
|
|
18
|
+
# ==============================================
|
|
19
|
+
class CloudSyncJob(JobRunner):
|
|
20
|
+
"""Sync vCloud → NetBox (via ORM)"""
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
name = "Cloud Sync Job"
|
|
24
|
+
description = "Synchronize vCloud VMs into NetBox using ORM"
|
|
25
|
+
|
|
26
|
+
def run(self, *args, **kwargs):
|
|
27
|
+
user = getattr(self.job, "user", None)
|
|
28
|
+
started_by = f"👤 Started by {user}" if user else ""
|
|
29
|
+
self.logger.info(f"=== 🔄 Cloud Sync Job started {started_by} ===")
|
|
30
|
+
|
|
31
|
+
config_id = kwargs.get("config_id")
|
|
32
|
+
configs = (
|
|
33
|
+
CloudSyncConfig.objects.filter(pk=config_id, enabled=True)
|
|
34
|
+
if config_id
|
|
35
|
+
else CloudSyncConfig.objects.filter(enabled=True)
|
|
36
|
+
)
|
|
37
|
+
if not configs.exists():
|
|
38
|
+
self.logger.warning("❗ No active configurations found..")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
for cfg in configs:
|
|
42
|
+
try:
|
|
43
|
+
self.logger.info(f"➡️ Sync '{cfg.name}' ({cfg.vcloud_url}) ...")
|
|
44
|
+
|
|
45
|
+
self.sync_config(cfg)
|
|
46
|
+
|
|
47
|
+
# 🕓 Update time sync
|
|
48
|
+
cfg.last_sync = timezone.now()
|
|
49
|
+
interval = cfg.sync_interval_minutes or 60
|
|
50
|
+
cfg.next_sync = cfg.last_sync + timedelta(minutes=interval)
|
|
51
|
+
cfg.save(update_fields=["last_sync", "next_sync"])
|
|
52
|
+
|
|
53
|
+
self.logger.info(
|
|
54
|
+
f"✅ '{cfg.name}' completed (next_sync={cfg.next_sync})"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
self.logger.error(f"⚠️ Error '{cfg.name}': {e}")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
self.logger.info("=== ✅ Cloud Sync Job completed ===")
|
|
62
|
+
|
|
63
|
+
# ======================================================
|
|
64
|
+
# 🔧 Sync config — token, page, run sync_vm
|
|
65
|
+
# ======================================================
|
|
66
|
+
def sync_config(self, cfg):
|
|
67
|
+
creds = f"{cfg.vcloud_user}:{cfg.vcloud_password}"
|
|
68
|
+
creds_b64 = base64.b64encode(creds.encode()).decode()
|
|
69
|
+
|
|
70
|
+
token_resp = requests.post(
|
|
71
|
+
f"{cfg.vcloud_url}/cloudapi/1.0.0/sessions",
|
|
72
|
+
headers={
|
|
73
|
+
"Authorization": f"Basic {creds_b64}",
|
|
74
|
+
"Accept": "application/*;version=38.1",
|
|
75
|
+
},
|
|
76
|
+
timeout=15,
|
|
77
|
+
)
|
|
78
|
+
if token_resp.status_code != 200:
|
|
79
|
+
self.logger.warning(f"❌ Token not received for {cfg.name}")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
vcloud_token = token_resp.headers.get("x-vmware-vcloud-access-token")
|
|
83
|
+
if not vcloud_token:
|
|
84
|
+
self.logger.warning(f"❌ Empty token in response from {cfg.name}")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
self.logger.info(f"✅ Token received for {cfg.name}")
|
|
88
|
+
|
|
89
|
+
# --- Get all pages from ВМ
|
|
90
|
+
all_vms = []
|
|
91
|
+
page, page_size = 1, 128
|
|
92
|
+
|
|
93
|
+
while True:
|
|
94
|
+
vms_resp = requests.get(
|
|
95
|
+
f"{cfg.vcloud_url}/api/query?type=vm&page={page}&pageSize={page_size}&format=records",
|
|
96
|
+
headers={
|
|
97
|
+
"Accept": "application/*+json;version=38.1",
|
|
98
|
+
"Authorization": f"Bearer {vcloud_token}",
|
|
99
|
+
},
|
|
100
|
+
timeout=30,
|
|
101
|
+
)
|
|
102
|
+
if vms_resp.status_code != 200:
|
|
103
|
+
self.logger.error(
|
|
104
|
+
f"❌ Failed to get VM (page {page}): HTTP {vms_resp.status_code}"
|
|
105
|
+
)
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
data = vms_resp.json()
|
|
109
|
+
records = data.get("record", [])
|
|
110
|
+
if not records:
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
all_vms.extend(records)
|
|
114
|
+
self.logger.info(f"📄 Page {page}: {len(records)} VM")
|
|
115
|
+
|
|
116
|
+
if len(records) < page_size:
|
|
117
|
+
break
|
|
118
|
+
page += 1
|
|
119
|
+
time.sleep(0.3)
|
|
120
|
+
|
|
121
|
+
self.logger.info(f"📦 Total {len(all_vms)} VMs received")
|
|
122
|
+
|
|
123
|
+
for vm in all_vms:
|
|
124
|
+
try:
|
|
125
|
+
href = vm.get("href")
|
|
126
|
+
if not href:
|
|
127
|
+
continue
|
|
128
|
+
self.sync_vm(cfg, vm, vcloud_token, href)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
self.logger.error(f"⚠️ Error during sync_vm {vm.get('name')}: {e}")
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# ======================================================
|
|
134
|
+
# 🧠 Processing a single VM + interfaces + IP
|
|
135
|
+
# ======================================================
|
|
136
|
+
def sync_vm(self, cfg, vm, token, href):
|
|
137
|
+
try:
|
|
138
|
+
name = vm["name"]
|
|
139
|
+
status = vm.get("status", "POWERED_ON")
|
|
140
|
+
cpu = vm.get("numberOfCpus", 0)
|
|
141
|
+
ram = vm.get("memoryMB", 0)
|
|
142
|
+
disk = vm.get("totalStorageAllocatedMb", 0)
|
|
143
|
+
|
|
144
|
+
if vm.get("isVAppTemplate") and not cfg.sync_templates:
|
|
145
|
+
self.logger.info(f"⏭ Skipped template {name}")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if status == "POWERED_OFF" and not cfg.sync_poweroff:
|
|
149
|
+
self.logger.info(f"⏭ Skipped offline VM {name}")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# --- Details VM
|
|
153
|
+
details = requests.get(
|
|
154
|
+
href,
|
|
155
|
+
headers={
|
|
156
|
+
"Accept": "application/*+json;version=38.1",
|
|
157
|
+
"Authorization": f"Bearer {token}",
|
|
158
|
+
},
|
|
159
|
+
timeout=20,
|
|
160
|
+
)
|
|
161
|
+
if details.status_code != 200:
|
|
162
|
+
self.logger.warning(f"⚠️ Could not get details for {name}")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
details_json = details.json()
|
|
166
|
+
net_sections = [
|
|
167
|
+
s
|
|
168
|
+
for s in details_json.get("section", [])
|
|
169
|
+
if s.get("_type") == "NetworkConnectionSectionType"
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
site = Site.objects.filter(id=cfg.netbox_site_id).first()
|
|
173
|
+
cluster = Cluster.objects.filter(id=cfg.netbox_cluster_id).first()
|
|
174
|
+
role = DeviceRole.objects.filter(id=cfg.netbox_role_id).first()
|
|
175
|
+
tenant = Tenant.objects.filter(id=cfg.netbox_tenant_id).first()
|
|
176
|
+
platform = Platform.objects.filter(id=cfg.netbox_platform_id).first()
|
|
177
|
+
|
|
178
|
+
vm_obj, created = VirtualMachine.objects.get_or_create(
|
|
179
|
+
name=name,
|
|
180
|
+
defaults={
|
|
181
|
+
"site": site,
|
|
182
|
+
"cluster": cluster,
|
|
183
|
+
"role": role,
|
|
184
|
+
"tenant": tenant,
|
|
185
|
+
"platform": platform,
|
|
186
|
+
"vcpus": cpu,
|
|
187
|
+
"memory": ram,
|
|
188
|
+
"disk": disk,
|
|
189
|
+
"status": "active" if status == "POWERED_ON" else "offline",
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if not created:
|
|
194
|
+
vm_obj.vcpus = cpu
|
|
195
|
+
vm_obj.memory = ram
|
|
196
|
+
vm_obj.disk = disk
|
|
197
|
+
vm_obj.status = (
|
|
198
|
+
"active" if status.upper() == "POWERED_ON" else "offline"
|
|
199
|
+
)
|
|
200
|
+
vm_obj.save()
|
|
201
|
+
self.logger.info(f"♻️ Update VM: {name}")
|
|
202
|
+
else:
|
|
203
|
+
self.logger.info(f"🆕 Create VM: {name}")
|
|
204
|
+
|
|
205
|
+
# --- Network interfaces + IPs ---
|
|
206
|
+
net_data = []
|
|
207
|
+
if net_sections:
|
|
208
|
+
connections = net_sections[0].get("networkConnection", [])
|
|
209
|
+
for conn in connections:
|
|
210
|
+
net_data.append({
|
|
211
|
+
"network": conn.get("network"),
|
|
212
|
+
"ip": conn.get("ipAddress"),
|
|
213
|
+
"ext_ip": conn.get("externalIpAddress"),
|
|
214
|
+
"mac": conn.get("macAddress"),
|
|
215
|
+
})
|
|
216
|
+
else:
|
|
217
|
+
if vm.get("networkName") and vm.get("ipAddress"):
|
|
218
|
+
net_data.append({
|
|
219
|
+
"network": vm.get("networkName"),
|
|
220
|
+
"ip": vm.get("ipAddress"),
|
|
221
|
+
"ext_ip": None,
|
|
222
|
+
"mac": None,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
if not net_data:
|
|
226
|
+
self.logger.info(f"ℹ️ {name} has no network interfaces or IPs")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
for net in net_data:
|
|
230
|
+
net_name = net.get("network")
|
|
231
|
+
ip_addr = (net.get("ip") or "").strip()
|
|
232
|
+
ext_ip = (net.get("ext_ip") or "").strip()
|
|
233
|
+
mac = (net.get("mac") or "").strip()
|
|
234
|
+
|
|
235
|
+
iface_obj = VMInterface.objects.filter(
|
|
236
|
+
virtual_machine=vm_obj,
|
|
237
|
+
name=net_name or "eth0",
|
|
238
|
+
).first()
|
|
239
|
+
|
|
240
|
+
iface_obj, _ = VMInterface.objects.get_or_create(
|
|
241
|
+
virtual_machine=vm_obj,
|
|
242
|
+
name=net_name or "eth0",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if mac:
|
|
247
|
+
mac = mac.strip().lower()
|
|
248
|
+
from dcim.models import MACAddress
|
|
249
|
+
|
|
250
|
+
mac_obj, _ = MACAddress.objects.get_or_create(
|
|
251
|
+
mac_address=mac,
|
|
252
|
+
defaults={"description": f"Imported via CloudSync ({cfg.name})"},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Set mac as primary
|
|
256
|
+
if iface_obj.primary_mac_address != mac_obj:
|
|
257
|
+
iface_obj.primary_mac_address = mac_obj
|
|
258
|
+
iface_obj.save()
|
|
259
|
+
self.logger.info(f"🔁 MAC update for {name}: {mac}")
|
|
260
|
+
else:
|
|
261
|
+
self.logger.debug(f"✅ MAC {mac} is already relevant for {name}")
|
|
262
|
+
else:
|
|
263
|
+
self.logger.debug(f"ℹ️ MAC missing for {name}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
vlan = None
|
|
267
|
+
if net_name:
|
|
268
|
+
try:
|
|
269
|
+
vid_match = re.search(r"(\d+)", net_name)
|
|
270
|
+
vid = int(vid_match.group(1)) if vid_match else None
|
|
271
|
+
vlan_defaults = {
|
|
272
|
+
"tenant": tenant,
|
|
273
|
+
"status": "active",
|
|
274
|
+
}
|
|
275
|
+
vlan, _ = VLAN.objects.get_or_create(
|
|
276
|
+
site=site,
|
|
277
|
+
vid=vid or 0,
|
|
278
|
+
defaults={"name": net_name, **vlan_defaults},
|
|
279
|
+
)
|
|
280
|
+
except Exception as vlan_err:
|
|
281
|
+
self.logger.warning(f"⚠️ Skipped VLAN {net_name}: {vlan_err}")
|
|
282
|
+
|
|
283
|
+
for addr in [ip_addr, ext_ip]:
|
|
284
|
+
if not addr:
|
|
285
|
+
continue
|
|
286
|
+
if "/" not in addr:
|
|
287
|
+
addr = f"{addr}/24"
|
|
288
|
+
try:
|
|
289
|
+
ip_obj, _ = IPAddress.objects.get_or_create(
|
|
290
|
+
address=addr,
|
|
291
|
+
defaults={
|
|
292
|
+
"tenant": tenant,
|
|
293
|
+
"status": "active",
|
|
294
|
+
"description": f"Imported via CloudSync ({cfg.name})",
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
ip_obj.assigned_object = iface_obj
|
|
298
|
+
ip_obj.save()
|
|
299
|
+
if not vm_obj.primary_ip4:
|
|
300
|
+
vm_obj.primary_ip4 = ip_obj
|
|
301
|
+
vm_obj.save()
|
|
302
|
+
self.logger.info(f"🌐 IP {addr} → {name}")
|
|
303
|
+
except Exception as ip_err:
|
|
304
|
+
self.logger.warning(f"⚠️ Skipped IP {addr}: {ip_err}")
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
self.logger.error(f"⚠️ Error sync_vm {vm.get('name')} (network): {e}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ==============================================================
|
|
312
|
+
# 🕓 Scheduler — autorun CloudSyncJob with sync_interval_minutes
|
|
313
|
+
# ==============================================================
|
|
314
|
+
#@system_job(interval=JobIntervalChoices.INTERVAL_HOURLY)
|
|
315
|
+
@system_job(interval=JobIntervalChoices.INTERVAL_MINUTELY)
|
|
316
|
+
class CloudSyncScheduler(JobRunner):
|
|
317
|
+
"""Scheduler: check configs and run CloudSyncJob by next_sync"""
|
|
318
|
+
|
|
319
|
+
class Meta:
|
|
320
|
+
name = "Cloud Sync Scheduler"
|
|
321
|
+
description = "Auto-schedules CloudSync jobs based on DB intervals"
|
|
322
|
+
|
|
323
|
+
def run(self, *args, **kwargs):
|
|
324
|
+
now = timezone.now()
|
|
325
|
+
due_configs = CloudSyncConfig.objects.filter(enabled=True, next_sync__lte=now)
|
|
326
|
+
|
|
327
|
+
if not due_configs.exists():
|
|
328
|
+
self.logger.info("⏳ There are no configs for synchronization at this time.")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
self.logger.info(f"🕓 Find {due_configs.count()} configs for run.")
|
|
332
|
+
for cfg in due_configs:
|
|
333
|
+
try:
|
|
334
|
+
CloudSyncJob.enqueue(config_id=cfg.id)
|
|
335
|
+
interval = cfg.sync_interval_minutes or 60
|
|
336
|
+
cfg.next_sync = now + timedelta(minutes=interval)
|
|
337
|
+
cfg.save(update_fields=["next_sync"])
|
|
338
|
+
self.logger.info(f"✅ Run sync: {cfg.name} (next={cfg.next_sync})")
|
|
339
|
+
except Exception as e:
|
|
340
|
+
self.logger.error(f"⚠️ Failed '{cfg.name}' queue: {e}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Generated by Django 5.2.6 on 2025-10-06 09:37
|
|
2
|
+
|
|
3
|
+
import netbox.models.deletion
|
|
4
|
+
import taggit.managers
|
|
5
|
+
import utilities.json
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
('extras', '0133_make_cf_minmax_decimal'),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name='CloudSyncConfig',
|
|
20
|
+
fields=[
|
|
21
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
22
|
+
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
|
23
|
+
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
|
24
|
+
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
|
25
|
+
('name', models.CharField(default='Default', max_length=100)),
|
|
26
|
+
('vcloud_url', models.URLField()),
|
|
27
|
+
('vcloud_user', models.CharField(max_length=200)),
|
|
28
|
+
('vcloud_password', models.CharField(max_length=200)),
|
|
29
|
+
('sync_interval_minutes', models.PositiveIntegerField(default=60)),
|
|
30
|
+
('last_sync', models.DateTimeField(blank=True, null=True)),
|
|
31
|
+
('enabled', models.BooleanField(default=True)),
|
|
32
|
+
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
|
33
|
+
],
|
|
34
|
+
options={
|
|
35
|
+
'verbose_name': 'Cloud Sync Config',
|
|
36
|
+
'verbose_name_plural': 'Cloud Sync Configs',
|
|
37
|
+
},
|
|
38
|
+
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
|
39
|
+
),
|
|
40
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 5.2.6 on 2025-10-06 11:11
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('netbox_vcloud', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='cloudsyncconfig',
|
|
15
|
+
name='next_sync',
|
|
16
|
+
field=models.DateTimeField(blank=True, null=True),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Generated by Django 5.2.6 on 2025-10-06 11:55
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('dcim', '0215_rackreservation_status'),
|
|
11
|
+
('netbox_vcloud', '0002_cloudsyncconfig_next_sync'),
|
|
12
|
+
('tenancy', '0020_remove_contactgroupmembership'),
|
|
13
|
+
('virtualization', '0048_populate_mac_addresses'),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.AddField(
|
|
18
|
+
model_name='cloudsyncconfig',
|
|
19
|
+
name='netbox_cluster',
|
|
20
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='virtualization.cluster'),
|
|
21
|
+
),
|
|
22
|
+
migrations.AddField(
|
|
23
|
+
model_name='cloudsyncconfig',
|
|
24
|
+
name='netbox_platform',
|
|
25
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.platform'),
|
|
26
|
+
),
|
|
27
|
+
migrations.AddField(
|
|
28
|
+
model_name='cloudsyncconfig',
|
|
29
|
+
name='netbox_role',
|
|
30
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.devicerole'),
|
|
31
|
+
),
|
|
32
|
+
migrations.AddField(
|
|
33
|
+
model_name='cloudsyncconfig',
|
|
34
|
+
name='netbox_site',
|
|
35
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.site'),
|
|
36
|
+
),
|
|
37
|
+
migrations.AddField(
|
|
38
|
+
model_name='cloudsyncconfig',
|
|
39
|
+
name='netbox_tenant',
|
|
40
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenancy.tenant'),
|
|
41
|
+
),
|
|
42
|
+
migrations.AddField(
|
|
43
|
+
model_name='cloudsyncconfig',
|
|
44
|
+
name='sync_poweroff',
|
|
45
|
+
field=models.BooleanField(default=False),
|
|
46
|
+
),
|
|
47
|
+
migrations.AddField(
|
|
48
|
+
model_name='cloudsyncconfig',
|
|
49
|
+
name='sync_templates',
|
|
50
|
+
field=models.BooleanField(default=False),
|
|
51
|
+
),
|
|
52
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from django.utils import timezone
|
|
4
|
+
from netbox.models import NetBoxModel
|
|
5
|
+
from dcim.models import Site, DeviceRole, Platform
|
|
6
|
+
from virtualization.models import Cluster
|
|
7
|
+
from tenancy.models import Tenant
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CloudSyncConfig(NetBoxModel):
|
|
11
|
+
"""Конфігурація синхронізації з vCloud"""
|
|
12
|
+
name = models.CharField(max_length=100, default="Default")
|
|
13
|
+
|
|
14
|
+
# vCloud credentials
|
|
15
|
+
vcloud_url = models.URLField()
|
|
16
|
+
vcloud_user = models.CharField(max_length=200)
|
|
17
|
+
vcloud_password = models.CharField(max_length=200)
|
|
18
|
+
|
|
19
|
+
# Основні налаштування
|
|
20
|
+
sync_interval_minutes = models.PositiveIntegerField(default=60)
|
|
21
|
+
last_sync = models.DateTimeField(null=True, blank=True)
|
|
22
|
+
next_sync = models.DateTimeField(null=True, blank=True)
|
|
23
|
+
enabled = models.BooleanField(default=True)
|
|
24
|
+
|
|
25
|
+
# Нові поля для параметрів синхронізації
|
|
26
|
+
sync_templates = models.BooleanField(default=False)
|
|
27
|
+
sync_poweroff = models.BooleanField(default=False)
|
|
28
|
+
|
|
29
|
+
netbox_site = models.ForeignKey(Site, on_delete=models.SET_NULL, null=True, blank=True)
|
|
30
|
+
netbox_cluster = models.ForeignKey(Cluster, on_delete=models.SET_NULL, null=True, blank=True)
|
|
31
|
+
netbox_role = models.ForeignKey(DeviceRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
32
|
+
netbox_tenant = models.ForeignKey(Tenant, on_delete=models.SET_NULL, null=True, blank=True)
|
|
33
|
+
netbox_platform = models.ForeignKey(Platform, on_delete=models.SET_NULL, null=True, blank=True)
|
|
34
|
+
|
|
35
|
+
class Meta:
|
|
36
|
+
verbose_name = "Cloud Sync Config"
|
|
37
|
+
verbose_name_plural = "Cloud Sync Configs"
|
|
38
|
+
|
|
39
|
+
def __str__(self):
|
|
40
|
+
return f"{self.name}"
|
|
41
|
+
|
|
42
|
+
def save(self, *args, **kwargs):
|
|
43
|
+
if self.last_sync and self.sync_interval_minutes:
|
|
44
|
+
self.next_sync = self.last_sync + timedelta(minutes=self.sync_interval_minutes)
|
|
45
|
+
elif not self.next_sync:
|
|
46
|
+
self.next_sync = timezone.now() + timedelta(minutes=self.sync_interval_minutes)
|
|
47
|
+
super().save(*args, **kwargs)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from netbox.plugins import PluginMenuItem, PluginMenuButton
|
|
2
|
+
|
|
3
|
+
menu_items = (
|
|
4
|
+
PluginMenuItem(
|
|
5
|
+
link='plugins:netbox_vcloud:cloudsyncconfig_list',
|
|
6
|
+
link_text='Cloud Sync Configs',
|
|
7
|
+
buttons=(
|
|
8
|
+
PluginMenuButton(
|
|
9
|
+
link='plugins:netbox_vcloud:cloudsyncconfig_add',
|
|
10
|
+
title='Add Config',
|
|
11
|
+
icon_class='mdi mdi-plus-thick',
|
|
12
|
+
),
|
|
13
|
+
),
|
|
14
|
+
),
|
|
15
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from django.utils import timezone
|
|
3
|
+
from virtualization.models import VirtualMachine, Cluster, VMInterface
|
|
4
|
+
from tenancy.models import Tenant
|
|
5
|
+
from dcim.models import Site, DeviceRole
|
|
6
|
+
from ipam.models import IPAddress
|
|
7
|
+
|
|
8
|
+
from .sync_utils import (
|
|
9
|
+
build_basic_auth_header,
|
|
10
|
+
extract_primary_connection,
|
|
11
|
+
normalize_vm_status,
|
|
12
|
+
should_sync_vm,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
REQUEST_TIMEOUT = 30
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_sync(cfg):
|
|
19
|
+
headers = {
|
|
20
|
+
"Authorization": build_basic_auth_header(cfg.vcloud_user, cfg.vcloud_password),
|
|
21
|
+
"Accept": "application/*;version=38.1",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
response = requests.post(
|
|
25
|
+
f"{cfg.vcloud_url}/cloudapi/1.0.0/sessions",
|
|
26
|
+
headers=headers,
|
|
27
|
+
timeout=REQUEST_TIMEOUT,
|
|
28
|
+
)
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
|
|
31
|
+
token = response.headers.get("X-VMWARE-VCLOUD-ACCESS-TOKEN")
|
|
32
|
+
if not token:
|
|
33
|
+
raise RuntimeError("vCloud auth failed: missing access token")
|
|
34
|
+
|
|
35
|
+
headers = {
|
|
36
|
+
"Authorization": f"Bearer {token}",
|
|
37
|
+
"Accept": "application/*+json;version=38.1",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
vms_response = requests.get(
|
|
41
|
+
f"{cfg.vcloud_url}/api/query?type=vm&page=1&pageSize=100&format=records",
|
|
42
|
+
headers=headers,
|
|
43
|
+
timeout=REQUEST_TIMEOUT,
|
|
44
|
+
)
|
|
45
|
+
vms_response.raise_for_status()
|
|
46
|
+
|
|
47
|
+
vms = vms_response.json().get("record", [])
|
|
48
|
+
if isinstance(vms, dict):
|
|
49
|
+
vms = [vms]
|
|
50
|
+
|
|
51
|
+
cluster = cfg.netbox_cluster or Cluster.objects.first()
|
|
52
|
+
site = cfg.netbox_site or Site.objects.first()
|
|
53
|
+
role = cfg.netbox_role or DeviceRole.objects.first()
|
|
54
|
+
tenant = cfg.netbox_tenant or Tenant.objects.first()
|
|
55
|
+
|
|
56
|
+
for vm in vms:
|
|
57
|
+
if not should_sync_vm(vm, cfg):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
name = vm.get("name")
|
|
61
|
+
href = vm.get("href")
|
|
62
|
+
if not name or not href:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
details_response = requests.get(href, headers=headers, timeout=REQUEST_TIMEOUT)
|
|
66
|
+
details_response.raise_for_status()
|
|
67
|
+
|
|
68
|
+
ip, mac = extract_primary_connection(details_response.json())
|
|
69
|
+
|
|
70
|
+
vm_obj, _ = VirtualMachine.objects.update_or_create(
|
|
71
|
+
name=name,
|
|
72
|
+
defaults={
|
|
73
|
+
"status": normalize_vm_status(vm.get("status", "POWERED_ON")),
|
|
74
|
+
"vcpus": vm.get("numberOfCpus", 0),
|
|
75
|
+
"memory": vm.get("memoryMB", 0),
|
|
76
|
+
"disk": vm.get("totalStorageAllocatedMb", 0),
|
|
77
|
+
"cluster": cluster,
|
|
78
|
+
"site": site,
|
|
79
|
+
"tenant": tenant,
|
|
80
|
+
"role": role,
|
|
81
|
+
"comments": f"Synced {timezone.now().strftime('%F %T')}",
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
iface_defaults = {"enabled": True}
|
|
86
|
+
if mac:
|
|
87
|
+
iface_defaults["mac_address"] = mac
|
|
88
|
+
|
|
89
|
+
iface, _ = VMInterface.objects.get_or_create(
|
|
90
|
+
virtual_machine=vm_obj,
|
|
91
|
+
name="eth0",
|
|
92
|
+
defaults=iface_defaults,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if ip:
|
|
96
|
+
ip_obj, _ = IPAddress.objects.get_or_create(address=f"{ip}/24")
|
|
97
|
+
ip_obj.assigned_object = iface
|
|
98
|
+
ip_obj.status = "active"
|
|
99
|
+
ip_obj.save()
|
|
100
|
+
|
|
101
|
+
cfg.last_sync = timezone.now()
|
|
102
|
+
cfg.save()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def build_basic_auth_header(username, password):
|
|
5
|
+
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
6
|
+
return f"Basic {token}"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_primary_connection(details):
|
|
10
|
+
for section in details.get("section", []):
|
|
11
|
+
if section.get("_type") != "NetworkConnectionSectionType":
|
|
12
|
+
continue
|
|
13
|
+
|
|
14
|
+
connections = section.get("networkConnection") or []
|
|
15
|
+
if not connections:
|
|
16
|
+
return None, None
|
|
17
|
+
|
|
18
|
+
connection = connections[0]
|
|
19
|
+
return connection.get("ipAddress"), connection.get("macAddress")
|
|
20
|
+
|
|
21
|
+
return None, None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def should_sync_vm(vm, cfg):
|
|
25
|
+
status = (vm.get("status") or "").upper()
|
|
26
|
+
if not cfg.sync_poweroff and status != "POWERED_ON":
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
is_template = vm.get("isVAppTemplate") in (True, "true", "True")
|
|
30
|
+
if is_template and not cfg.sync_templates:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_vm_status(status):
|
|
37
|
+
return "active" if (status or "").upper() == "POWERED_ON" else "offline"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import django_tables2 as tables
|
|
2
|
+
from netbox.tables import NetBoxTable
|
|
3
|
+
from .models import CloudSyncConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CloudSyncConfigTable(NetBoxTable):
|
|
7
|
+
name = tables.Column(linkify=True)
|
|
8
|
+
|
|
9
|
+
class Meta(NetBoxTable.Meta):
|
|
10
|
+
model = CloudSyncConfig
|
|
11
|
+
fields = (
|
|
12
|
+
'pk', 'name', 'vcloud_url', 'netbox_site', 'netbox_cluster',
|
|
13
|
+
'sync_interval_minutes', 'sync_templates', 'sync_poweroff',
|
|
14
|
+
'next_sync', 'enabled'
|
|
15
|
+
)
|
|
16
|
+
default_columns = (
|
|
17
|
+
'name', 'netbox_site', 'netbox_cluster', 'next_sync', 'enabled'
|
|
18
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from netbox.plugins import PluginTemplateExtension
|
|
2
|
+
from django.urls import reverse
|
|
3
|
+
from django.utils.html import format_html
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CloudSyncConfigButtons(PluginTemplateExtension):
|
|
7
|
+
model = 'netbox_vcloud.cloudsyncconfig'
|
|
8
|
+
|
|
9
|
+
def buttons(self):
|
|
10
|
+
obj = self.context.get('object')
|
|
11
|
+
if not obj or obj._meta.label_lower != 'netbox_vcloud.cloudsyncconfig':
|
|
12
|
+
return ""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
url = reverse('plugins:netbox_vcloud:run_sync_now', args=[obj.pk])
|
|
16
|
+
return format_html(
|
|
17
|
+
f'<a href="{url}" class="btn btn-sm btn-primary">'
|
|
18
|
+
f'<i class="mdi mdi-sync"></i> Run Sync Now</a>'
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
template_extensions = [CloudSyncConfigButtons]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{% extends 'generic/object.html' %}
|
|
2
|
+
{% load boolean_icon %}
|
|
3
|
+
|
|
4
|
+
{% block content %}
|
|
5
|
+
<div class="row">
|
|
6
|
+
<div class="col-md-8">
|
|
7
|
+
<div class="card">
|
|
8
|
+
<h5 class="card-header">Cloud Sync Configuration</h5>
|
|
9
|
+
<div class="card-body">
|
|
10
|
+
<table class="table table-hover attr-table">
|
|
11
|
+
<tr><th>Name</th><td>{{ object.name }}</td></tr>
|
|
12
|
+
<tr><th>vCloud URL</th><td>{{ object.vcloud_url }}</td></tr>
|
|
13
|
+
<tr><th>User</th><td>{{ object.vcloud_user }}</td></tr>
|
|
14
|
+
<tr><th>Interval (min)</th><td>{{ object.sync_interval_minutes }}</td></tr>
|
|
15
|
+
<tr><th>Sync Templates</th><td>{{ object.sync_templates|boolean_icon|safe }}</td></tr>
|
|
16
|
+
<tr><th>Sync Powered Off</th><td>{{ object.sync_poweroff|boolean_icon|safe }}</td></tr>
|
|
17
|
+
|
|
18
|
+
<tr><th>Site</th><td>{{ object.netbox_site }}</td></tr>
|
|
19
|
+
<tr><th>Cluster</th><td>{{ object.netbox_cluster }}</td></tr>
|
|
20
|
+
<tr><th>Role</th><td>{{ object.netbox_role }}</td></tr>
|
|
21
|
+
<tr><th>Tenant</th><td>{{ object.netbox_tenant }}</td></tr>
|
|
22
|
+
<tr><th>Platform</th><td>{{ object.netbox_platform }}</td></tr>
|
|
23
|
+
|
|
24
|
+
<tr><th>Last Sync</th><td>{{ object.last_sync|date:"Y-m-d H:i:s" }}</td></tr>
|
|
25
|
+
<tr><th>Next Sync</th><td>{{ object.next_sync|date:"Y-m-d H:i:s" }}</td></tr>
|
|
26
|
+
<tr><th>Enabled</th><td>{{ object.enabled|boolean_icon|safe }}</td></tr>
|
|
27
|
+
</table>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
{% endblock %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django import template
|
|
2
|
+
register = template.Library()
|
|
3
|
+
|
|
4
|
+
@register.filter()
|
|
5
|
+
def boolean_icon(value):
|
|
6
|
+
|
|
7
|
+
if value:
|
|
8
|
+
return '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>'
|
|
9
|
+
else:
|
|
10
|
+
return '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from netbox.plugins import PluginTemplateExtension
|
|
2
|
+
from netbox.views.generic import ObjectChangeLogView
|
|
3
|
+
from .models import CloudSyncConfig
|
|
4
|
+
from django.urls import path
|
|
5
|
+
from . import views
|
|
6
|
+
|
|
7
|
+
urlpatterns = [
|
|
8
|
+
path('', views.CloudSyncConfigListView.as_view(), name='cloudsyncconfig_list'),
|
|
9
|
+
path('add/', views.CloudSyncConfigEditView.as_view(), name='cloudsyncconfig_add'),
|
|
10
|
+
path('<int:pk>/', views.CloudSyncConfigView.as_view(), name='cloudsyncconfig'),
|
|
11
|
+
path('<int:pk>/edit/', views.CloudSyncConfigEditView.as_view(), name='cloudsyncconfig_edit'),
|
|
12
|
+
path('<int:pk>/delete/', views.CloudSyncConfigDeleteView.as_view(), name='cloudsyncconfig_delete'),
|
|
13
|
+
path('<int:pk>/changelog/', views.CloudSyncConfigChangeLogView.as_view(),
|
|
14
|
+
name='cloudsyncconfig_changelog'),
|
|
15
|
+
path('<int:pk>/run/', views.RunSyncNowView.as_view(), name='run_sync_now'),
|
|
16
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from netbox.views import generic
|
|
2
|
+
from netbox.views.generic import ObjectChangeLogView
|
|
3
|
+
from django.contrib import messages
|
|
4
|
+
from django.shortcuts import redirect, get_object_or_404
|
|
5
|
+
from django.views import View
|
|
6
|
+
from .models import CloudSyncConfig
|
|
7
|
+
from .tables import CloudSyncConfigTable
|
|
8
|
+
from .forms import CloudSyncConfigForm
|
|
9
|
+
from .jobs.cloudsync import CloudSyncJob
|
|
10
|
+
from django.urls import reverse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CloudSyncConfigListView(generic.ObjectListView):
|
|
14
|
+
queryset = CloudSyncConfig.objects.all()
|
|
15
|
+
table = CloudSyncConfigTable
|
|
16
|
+
|
|
17
|
+
def get_queryset(self, request):
|
|
18
|
+
return CloudSyncConfig.objects.restrict(request.user, 'view')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CloudSyncConfigView(generic.ObjectView):
|
|
22
|
+
queryset = CloudSyncConfig.objects.all()
|
|
23
|
+
template_name = "netbox_vcloud/cloudsyncconfig.html"
|
|
24
|
+
|
|
25
|
+
def get_queryset(self, request):
|
|
26
|
+
return CloudSyncConfig.objects.restrict(request.user, 'view')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CloudSyncConfigEditView(generic.ObjectEditView):
|
|
30
|
+
queryset = CloudSyncConfig.objects.all()
|
|
31
|
+
form = CloudSyncConfigForm
|
|
32
|
+
|
|
33
|
+
def get_queryset(self, request):
|
|
34
|
+
return CloudSyncConfig.objects.restrict(request.user, 'edit')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CloudSyncConfigDeleteView(generic.ObjectDeleteView):
|
|
38
|
+
queryset = CloudSyncConfig.objects.all()
|
|
39
|
+
|
|
40
|
+
def get_queryset(self, request):
|
|
41
|
+
return CloudSyncConfig.objects.restrict(request.user, 'delete')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CloudSyncConfigChangeLogView(ObjectChangeLogView):
|
|
45
|
+
queryset = CloudSyncConfig.objects.all()
|
|
46
|
+
|
|
47
|
+
def get(self, request, *args, **kwargs):
|
|
48
|
+
return super().get(request, model=CloudSyncConfig, *args, **kwargs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RunSyncNowView(View):
|
|
52
|
+
"""Manual run CloudSyncJob for CloudSyncConfig."""
|
|
53
|
+
|
|
54
|
+
def get(self, request, pk):
|
|
55
|
+
cfg = get_object_or_404(CloudSyncConfig, pk=pk)
|
|
56
|
+
job = CloudSyncJob.enqueue(config_id=cfg.id)
|
|
57
|
+
messages.success(request, f"✅ Sync '{cfg.name}' run in back.")
|
|
58
|
+
try:
|
|
59
|
+
return redirect(reverse("core:job", args=[job.id]))
|
|
60
|
+
except Exception:
|
|
61
|
+
return redirect(f"/core/jobs/{job.id}/")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
netbox_vcloud/__init__.py
|
|
5
|
+
netbox_vcloud/admin.py
|
|
6
|
+
netbox_vcloud/forms.py
|
|
7
|
+
netbox_vcloud/models.py
|
|
8
|
+
netbox_vcloud/navigation.py
|
|
9
|
+
netbox_vcloud/sync.py
|
|
10
|
+
netbox_vcloud/sync_utils.py
|
|
11
|
+
netbox_vcloud/tables.py
|
|
12
|
+
netbox_vcloud/template_content.py
|
|
13
|
+
netbox_vcloud/urls.py
|
|
14
|
+
netbox_vcloud/views.py
|
|
15
|
+
netbox_vcloud.egg-info/PKG-INFO
|
|
16
|
+
netbox_vcloud.egg-info/SOURCES.txt
|
|
17
|
+
netbox_vcloud.egg-info/dependency_links.txt
|
|
18
|
+
netbox_vcloud.egg-info/requires.txt
|
|
19
|
+
netbox_vcloud.egg-info/top_level.txt
|
|
20
|
+
netbox_vcloud/api/serializers.py
|
|
21
|
+
netbox_vcloud/api/urls.py
|
|
22
|
+
netbox_vcloud/api/views.py
|
|
23
|
+
netbox_vcloud/jobs/__init__.py
|
|
24
|
+
netbox_vcloud/jobs/cloudsync.py
|
|
25
|
+
netbox_vcloud/migrations/0001_initial.py
|
|
26
|
+
netbox_vcloud/migrations/0002_cloudsyncconfig_next_sync.py
|
|
27
|
+
netbox_vcloud/migrations/0003_cloudsyncconfig_netbox_cluster_and_more.py
|
|
28
|
+
netbox_vcloud/migrations/__init__.py
|
|
29
|
+
netbox_vcloud/templates/netbox_cloudsync/cloudsyncconfig.html
|
|
30
|
+
netbox_vcloud/templates/netbox_cloudsync/cloudsyncconfig_list.html
|
|
31
|
+
netbox_vcloud/templatetags/__init__.py
|
|
32
|
+
netbox_vcloud/templatetags/boolean_icon.py
|
|
33
|
+
test/test_sync_utils.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
netbox_vcloud
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|