netbox-vcloud 0.1.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 (35) hide show
  1. netbox_vcloud-0.1.2/MANIFEST.in +3 -0
  2. netbox_vcloud-0.1.2/PKG-INFO +69 -0
  3. netbox_vcloud-0.1.2/README.md +53 -0
  4. netbox_vcloud-0.1.2/netbox_cloudsync/__init__.py +22 -0
  5. netbox_vcloud-0.1.2/netbox_cloudsync/admin.py +37 -0
  6. netbox_vcloud-0.1.2/netbox_cloudsync/api/serializers.py +12 -0
  7. netbox_vcloud-0.1.2/netbox_cloudsync/api/urls.py +7 -0
  8. netbox_vcloud-0.1.2/netbox_cloudsync/api/views.py +8 -0
  9. netbox_vcloud-0.1.2/netbox_cloudsync/forms.py +20 -0
  10. netbox_vcloud-0.1.2/netbox_cloudsync/jobs/__init__.py +1 -0
  11. netbox_vcloud-0.1.2/netbox_cloudsync/jobs/cloudsync.py +340 -0
  12. netbox_vcloud-0.1.2/netbox_cloudsync/migrations/0001_initial.py +40 -0
  13. netbox_vcloud-0.1.2/netbox_cloudsync/migrations/0002_cloudsyncconfig_next_sync.py +18 -0
  14. netbox_vcloud-0.1.2/netbox_cloudsync/migrations/0003_cloudsyncconfig_netbox_cluster_and_more.py +52 -0
  15. netbox_vcloud-0.1.2/netbox_cloudsync/migrations/__init__.py +1 -0
  16. netbox_vcloud-0.1.2/netbox_cloudsync/models.py +47 -0
  17. netbox_vcloud-0.1.2/netbox_cloudsync/navigation.py +15 -0
  18. netbox_vcloud-0.1.2/netbox_cloudsync/sync.py +102 -0
  19. netbox_vcloud-0.1.2/netbox_cloudsync/sync_utils.py +37 -0
  20. netbox_vcloud-0.1.2/netbox_cloudsync/tables.py +18 -0
  21. netbox_vcloud-0.1.2/netbox_cloudsync/template_content.py +22 -0
  22. netbox_vcloud-0.1.2/netbox_cloudsync/templates/netbox_cloudsync/cloudsyncconfig.html +32 -0
  23. netbox_vcloud-0.1.2/netbox_cloudsync/templates/netbox_cloudsync/cloudsyncconfig_list.html +5 -0
  24. netbox_vcloud-0.1.2/netbox_cloudsync/templatetags/__init__.py +1 -0
  25. netbox_vcloud-0.1.2/netbox_cloudsync/templatetags/boolean_icon.py +10 -0
  26. netbox_vcloud-0.1.2/netbox_cloudsync/urls.py +16 -0
  27. netbox_vcloud-0.1.2/netbox_cloudsync/views.py +60 -0
  28. netbox_vcloud-0.1.2/netbox_vcloud.egg-info/PKG-INFO +69 -0
  29. netbox_vcloud-0.1.2/netbox_vcloud.egg-info/SOURCES.txt +33 -0
  30. netbox_vcloud-0.1.2/netbox_vcloud.egg-info/dependency_links.txt +1 -0
  31. netbox_vcloud-0.1.2/netbox_vcloud.egg-info/requires.txt +4 -0
  32. netbox_vcloud-0.1.2/netbox_vcloud.egg-info/top_level.txt +1 -0
  33. netbox_vcloud-0.1.2/pyproject.toml +36 -0
  34. netbox_vcloud-0.1.2/setup.cfg +4 -0
  35. netbox_vcloud-0.1.2/test/test_sync_utils.py +47 -0
@@ -0,0 +1,3 @@
1
+ include README.md
2
+ recursive-include netbox_cloudsync/templates *
3
+ recursive-include netbox_cloudsync/migrations *.py
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbox-vcloud
3
+ Version: 0.1.2
4
+ Summary: NetBox plugin for synchronizing vCloud VMs via ORM
5
+ Author-email: Serhii Zahuba <dev@sss.com>
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: System Administrators
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Framework :: Django
10
+ Classifier: Framework :: Django :: 5.2
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests>=2.31
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8.2; extra == "test"
16
+
17
+ <div align="center">
18
+
19
+ # NetBox vCloud Plugin
20
+ ### _Synchronize vCloud Director VMs into NetBox_
21
+
22
+ [![GitHub Repo stars](https://img.shields.io/github/stars/SerhiiZahuba/netbox-vcloud-sync-plugin?style=social)](https://github.com/SerhiiZahuba/netbox-vcloud-sync-plugin/stargazers)
23
+ ![NetBox version](https://img.shields.io/badge/netbox-4.4.1-blue)
24
+ ![Python](https://img.shields.io/badge/python-3.11%2B-yellow)
25
+ ![License](https://img.shields.io/github/license/SerhiiZahuba/netbox-vcloud-sync-plugin?color=green)
26
+
27
+ </div>
28
+
29
+ ---
30
+
31
+ ## Overview
32
+
33
+ **NetBox vCloud** is a NetBox plugin for synchronizing virtual machines from **vCloud Director** into NetBox.
34
+
35
+ ---
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ cd /opt/netbox/
41
+ source /opt/netbox/venv/bin/activate
42
+ pip install netbox-vcloud
43
+ python3 manage.py migrate netbox_cloudsync
44
+ ```
45
+
46
+ ## Settings
47
+
48
+ Add to `configuration.py`:
49
+
50
+ ```python
51
+ PLUGINS = [
52
+ "netbox_cloudsync",
53
+ ]
54
+ ```
55
+
56
+ ## Development
57
+
58
+ Run tests locally:
59
+
60
+ ```bash
61
+ pytest
62
+ ```
63
+
64
+ Publish to PyPI from GitHub Actions by pushing a version tag:
65
+
66
+ ```bash
67
+ git tag v0.1.0
68
+ git push origin v0.1.0
69
+ ```
@@ -0,0 +1,53 @@
1
+ <div align="center">
2
+
3
+ # NetBox vCloud Plugin
4
+ ### _Synchronize vCloud Director VMs into NetBox_
5
+
6
+ [![GitHub Repo stars](https://img.shields.io/github/stars/SerhiiZahuba/netbox-vcloud-sync-plugin?style=social)](https://github.com/SerhiiZahuba/netbox-vcloud-sync-plugin/stargazers)
7
+ ![NetBox version](https://img.shields.io/badge/netbox-4.4.1-blue)
8
+ ![Python](https://img.shields.io/badge/python-3.11%2B-yellow)
9
+ ![License](https://img.shields.io/github/license/SerhiiZahuba/netbox-vcloud-sync-plugin?color=green)
10
+
11
+ </div>
12
+
13
+ ---
14
+
15
+ ## Overview
16
+
17
+ **NetBox vCloud** is a NetBox plugin for synchronizing virtual machines from **vCloud Director** into NetBox.
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ cd /opt/netbox/
25
+ source /opt/netbox/venv/bin/activate
26
+ pip install netbox-vcloud
27
+ python3 manage.py migrate netbox_cloudsync
28
+ ```
29
+
30
+ ## Settings
31
+
32
+ Add to `configuration.py`:
33
+
34
+ ```python
35
+ PLUGINS = [
36
+ "netbox_cloudsync",
37
+ ]
38
+ ```
39
+
40
+ ## Development
41
+
42
+ Run tests locally:
43
+
44
+ ```bash
45
+ pytest
46
+ ```
47
+
48
+ Publish to PyPI from GitHub Actions by pushing a version tag:
49
+
50
+ ```bash
51
+ git tag v0.1.0
52
+ git push origin v0.1.0
53
+ ```
@@ -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_cloudsync"
9
+ verbose_name = "Cloud Synchronization"
10
+ description = "Synchronize vCloud VMs into NetBox using ORM"
11
+ version = "0.1.0"
12
+ author = "Serhii Zahuba"
13
+ author_email = "dev@cre.com"
14
+ base_url = "cloudsync"
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_cloudsync', '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_cloudsync', '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_cloudsync:cloudsyncconfig_list',
6
+ link_text='Cloud Sync Configs',
7
+ buttons=(
8
+ PluginMenuButton(
9
+ link='plugins:netbox_cloudsync: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_cloudsync.cloudsyncconfig'
8
+
9
+ def buttons(self):
10
+ obj = self.context.get('object')
11
+ if not obj or obj._meta.label_lower != 'netbox_cloudsync.cloudsyncconfig':
12
+ return ""
13
+
14
+
15
+ url = reverse('plugins:netbox_cloudsync: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,60 @@
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
+
24
+ def get_queryset(self, request):
25
+ return CloudSyncConfig.objects.restrict(request.user, 'view')
26
+
27
+
28
+ class CloudSyncConfigEditView(generic.ObjectEditView):
29
+ queryset = CloudSyncConfig.objects.all()
30
+ form = CloudSyncConfigForm
31
+
32
+ def get_queryset(self, request):
33
+ return CloudSyncConfig.objects.restrict(request.user, 'edit')
34
+
35
+
36
+ class CloudSyncConfigDeleteView(generic.ObjectDeleteView):
37
+ queryset = CloudSyncConfig.objects.all()
38
+
39
+ def get_queryset(self, request):
40
+ return CloudSyncConfig.objects.restrict(request.user, 'delete')
41
+
42
+
43
+ class CloudSyncConfigChangeLogView(ObjectChangeLogView):
44
+ queryset = CloudSyncConfig.objects.all()
45
+
46
+ def get(self, request, *args, **kwargs):
47
+ return super().get(request, model=CloudSyncConfig, *args, **kwargs)
48
+
49
+
50
+ class RunSyncNowView(View):
51
+ """Manual run CloudSyncJob for CloudSyncConfig."""
52
+
53
+ def get(self, request, pk):
54
+ cfg = get_object_or_404(CloudSyncConfig, pk=pk)
55
+ job = CloudSyncJob.enqueue(config_id=cfg.id)
56
+ messages.success(request, f"✅ Sync '{cfg.name}' run in back.")
57
+ try:
58
+ return redirect(reverse("core:job", args=[job.id]))
59
+ except Exception:
60
+ return redirect(f"/core/jobs/{job.id}/")
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbox-vcloud
3
+ Version: 0.1.2
4
+ Summary: NetBox plugin for synchronizing vCloud VMs via ORM
5
+ Author-email: Serhii Zahuba <dev@sss.com>
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: System Administrators
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Framework :: Django
10
+ Classifier: Framework :: Django :: 5.2
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests>=2.31
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8.2; extra == "test"
16
+
17
+ <div align="center">
18
+
19
+ # NetBox vCloud Plugin
20
+ ### _Synchronize vCloud Director VMs into NetBox_
21
+
22
+ [![GitHub Repo stars](https://img.shields.io/github/stars/SerhiiZahuba/netbox-vcloud-sync-plugin?style=social)](https://github.com/SerhiiZahuba/netbox-vcloud-sync-plugin/stargazers)
23
+ ![NetBox version](https://img.shields.io/badge/netbox-4.4.1-blue)
24
+ ![Python](https://img.shields.io/badge/python-3.11%2B-yellow)
25
+ ![License](https://img.shields.io/github/license/SerhiiZahuba/netbox-vcloud-sync-plugin?color=green)
26
+
27
+ </div>
28
+
29
+ ---
30
+
31
+ ## Overview
32
+
33
+ **NetBox vCloud** is a NetBox plugin for synchronizing virtual machines from **vCloud Director** into NetBox.
34
+
35
+ ---
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ cd /opt/netbox/
41
+ source /opt/netbox/venv/bin/activate
42
+ pip install netbox-vcloud
43
+ python3 manage.py migrate netbox_cloudsync
44
+ ```
45
+
46
+ ## Settings
47
+
48
+ Add to `configuration.py`:
49
+
50
+ ```python
51
+ PLUGINS = [
52
+ "netbox_cloudsync",
53
+ ]
54
+ ```
55
+
56
+ ## Development
57
+
58
+ Run tests locally:
59
+
60
+ ```bash
61
+ pytest
62
+ ```
63
+
64
+ Publish to PyPI from GitHub Actions by pushing a version tag:
65
+
66
+ ```bash
67
+ git tag v0.1.0
68
+ git push origin v0.1.0
69
+ ```
@@ -0,0 +1,33 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ netbox_cloudsync/__init__.py
5
+ netbox_cloudsync/admin.py
6
+ netbox_cloudsync/forms.py
7
+ netbox_cloudsync/models.py
8
+ netbox_cloudsync/navigation.py
9
+ netbox_cloudsync/sync.py
10
+ netbox_cloudsync/sync_utils.py
11
+ netbox_cloudsync/tables.py
12
+ netbox_cloudsync/template_content.py
13
+ netbox_cloudsync/urls.py
14
+ netbox_cloudsync/views.py
15
+ netbox_cloudsync/api/serializers.py
16
+ netbox_cloudsync/api/urls.py
17
+ netbox_cloudsync/api/views.py
18
+ netbox_cloudsync/jobs/__init__.py
19
+ netbox_cloudsync/jobs/cloudsync.py
20
+ netbox_cloudsync/migrations/0001_initial.py
21
+ netbox_cloudsync/migrations/0002_cloudsyncconfig_next_sync.py
22
+ netbox_cloudsync/migrations/0003_cloudsyncconfig_netbox_cluster_and_more.py
23
+ netbox_cloudsync/migrations/__init__.py
24
+ netbox_cloudsync/templates/netbox_cloudsync/cloudsyncconfig.html
25
+ netbox_cloudsync/templates/netbox_cloudsync/cloudsyncconfig_list.html
26
+ netbox_cloudsync/templatetags/__init__.py
27
+ netbox_cloudsync/templatetags/boolean_icon.py
28
+ netbox_vcloud.egg-info/PKG-INFO
29
+ netbox_vcloud.egg-info/SOURCES.txt
30
+ netbox_vcloud.egg-info/dependency_links.txt
31
+ netbox_vcloud.egg-info/requires.txt
32
+ netbox_vcloud.egg-info/top_level.txt
33
+ test/test_sync_utils.py
@@ -0,0 +1,4 @@
1
+ requests>=2.31
2
+
3
+ [test]
4
+ pytest>=8.2
@@ -0,0 +1 @@
1
+ netbox_cloudsync
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "netbox-vcloud"
7
+ version = "0.1.2"
8
+ authors = [
9
+ {name = "Serhii Zahuba", email = "dev@sss.com"},
10
+ ]
11
+ description = "NetBox plugin for synchronizing vCloud VMs via ORM"
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: System Administrators",
18
+ "Programming Language :: Python :: 3",
19
+ "Framework :: Django",
20
+ "Framework :: Django :: 5.2",
21
+ ]
22
+
23
+ dependencies = ["requests>=2.31"]
24
+
25
+ [project.optional-dependencies]
26
+ test = ["pytest>=8.2"]
27
+
28
+ [tool.setuptools]
29
+ include-package-data = true
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["netbox_cloudsync*"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,47 @@
1
+ from types import SimpleNamespace
2
+
3
+ from netbox_cloudsync.sync_utils import (
4
+ build_basic_auth_header,
5
+ extract_primary_connection,
6
+ normalize_vm_status,
7
+ should_sync_vm,
8
+ )
9
+
10
+
11
+ def test_build_basic_auth_header_encodes_credentials():
12
+ assert build_basic_auth_header("user", "pass") == "Basic dXNlcjpwYXNz"
13
+
14
+
15
+ def test_extract_primary_connection_returns_first_network_values():
16
+ ip, mac = extract_primary_connection(
17
+ {
18
+ "section": [
19
+ {
20
+ "_type": "NetworkConnectionSectionType",
21
+ "networkConnection": [
22
+ {"ipAddress": "10.0.0.5", "macAddress": "00:11:22:33:44:55"}
23
+ ],
24
+ }
25
+ ]
26
+ }
27
+ )
28
+
29
+ assert ip == "10.0.0.5"
30
+ assert mac == "00:11:22:33:44:55"
31
+
32
+
33
+ def test_extract_primary_connection_returns_empty_values_when_missing():
34
+ assert extract_primary_connection({"section": []}) == (None, None)
35
+
36
+
37
+ def test_should_sync_vm_respects_power_and_template_flags():
38
+ cfg = SimpleNamespace(sync_poweroff=False, sync_templates=False)
39
+
40
+ assert should_sync_vm({"status": "POWERED_ON"}, cfg) is True
41
+ assert should_sync_vm({"status": "POWERED_OFF"}, cfg) is False
42
+ assert should_sync_vm({"status": "POWERED_ON", "isVAppTemplate": True}, cfg) is False
43
+
44
+
45
+ def test_normalize_vm_status_maps_power_state():
46
+ assert normalize_vm_status("POWERED_ON") == "active"
47
+ assert normalize_vm_status("POWERED_OFF") == "offline"