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.
Files changed (36) hide show
  1. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/PKG-INFO +1 -1
  2. netbox_vcloud-0.2.2/netbox_vcloud/__init__.py +22 -0
  3. netbox_vcloud-0.2.2/netbox_vcloud/admin.py +37 -0
  4. netbox_vcloud-0.2.2/netbox_vcloud/api/serializers.py +12 -0
  5. netbox_vcloud-0.2.2/netbox_vcloud/api/urls.py +7 -0
  6. netbox_vcloud-0.2.2/netbox_vcloud/api/views.py +8 -0
  7. netbox_vcloud-0.2.2/netbox_vcloud/forms.py +20 -0
  8. netbox_vcloud-0.2.2/netbox_vcloud/jobs/__init__.py +1 -0
  9. netbox_vcloud-0.2.2/netbox_vcloud/jobs/cloudsync.py +340 -0
  10. netbox_vcloud-0.2.2/netbox_vcloud/migrations/0001_initial.py +40 -0
  11. netbox_vcloud-0.2.2/netbox_vcloud/migrations/0002_cloudsyncconfig_next_sync.py +18 -0
  12. netbox_vcloud-0.2.2/netbox_vcloud/migrations/0003_cloudsyncconfig_netbox_cluster_and_more.py +52 -0
  13. netbox_vcloud-0.2.2/netbox_vcloud/models.py +47 -0
  14. netbox_vcloud-0.2.2/netbox_vcloud/navigation.py +15 -0
  15. netbox_vcloud-0.2.2/netbox_vcloud/sync.py +102 -0
  16. netbox_vcloud-0.2.2/netbox_vcloud/sync_utils.py +37 -0
  17. netbox_vcloud-0.2.2/netbox_vcloud/tables.py +18 -0
  18. netbox_vcloud-0.2.2/netbox_vcloud/template_content.py +22 -0
  19. netbox_vcloud-0.2.2/netbox_vcloud/templates/netbox_cloudsync/cloudsyncconfig.html +32 -0
  20. netbox_vcloud-0.2.2/netbox_vcloud/templates/netbox_cloudsync/cloudsyncconfig_list.html +5 -0
  21. netbox_vcloud-0.2.2/netbox_vcloud/templatetags/__init__.py +1 -0
  22. netbox_vcloud-0.2.2/netbox_vcloud/templatetags/boolean_icon.py +10 -0
  23. netbox_vcloud-0.2.2/netbox_vcloud/urls.py +16 -0
  24. netbox_vcloud-0.2.2/netbox_vcloud/views.py +61 -0
  25. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/netbox_vcloud.egg-info/PKG-INFO +1 -1
  26. netbox_vcloud-0.2.2/netbox_vcloud.egg-info/SOURCES.txt +33 -0
  27. netbox_vcloud-0.2.2/netbox_vcloud.egg-info/top_level.txt +1 -0
  28. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/pyproject.toml +1 -1
  29. netbox_vcloud-0.2.0/netbox_vcloud.egg-info/SOURCES.txt +0 -9
  30. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/MANIFEST.in +0 -0
  31. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/README.md +0 -0
  32. /netbox_vcloud-0.2.0/netbox_vcloud.egg-info/top_level.txt → /netbox_vcloud-0.2.2/netbox_vcloud/migrations/__init__.py +0 -0
  33. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/netbox_vcloud.egg-info/dependency_links.txt +0 -0
  34. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/netbox_vcloud.egg-info/requires.txt +0 -0
  35. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/setup.cfg +0 -0
  36. {netbox_vcloud-0.2.0 → netbox_vcloud-0.2.2}/test/test_sync_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-vcloud
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: NetBox plugin for synchronizing vCloud VMs via ORM
5
5
  Author-email: Serhii Zahuba <dev@sss.com>
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -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,7 @@
1
+ from netbox.api.routers import NetBoxRouter
2
+ from . import views
3
+
4
+ router = NetBoxRouter()
5
+ router.register('configs', views.CloudSyncConfigViewSet)
6
+
7
+ urlpatterns = router.urls
@@ -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,5 @@
1
+ {% extends 'generic/object_list.html' %}
2
+ {% load render_table from django_tables2 %}
3
+ {% block content %}
4
+ {% render_table table %}
5
+ {% endblock %}
@@ -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}/")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-vcloud
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: NetBox plugin for synchronizing vCloud VMs via ORM
5
5
  Author-email: Serhii Zahuba <dev@sss.com>
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "netbox-vcloud"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  authors = [
9
9
  {name = "Serhii Zahuba", email = "dev@sss.com"},
10
10
  ]
@@ -1,9 +0,0 @@
1
- MANIFEST.in
2
- README.md
3
- pyproject.toml
4
- netbox_vcloud.egg-info/PKG-INFO
5
- netbox_vcloud.egg-info/SOURCES.txt
6
- netbox_vcloud.egg-info/dependency_links.txt
7
- netbox_vcloud.egg-info/requires.txt
8
- netbox_vcloud.egg-info/top_level.txt
9
- test/test_sync_utils.py
File without changes
File without changes
File without changes