proxsync 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. config.py +337 -0
  2. pbs_sync.py +244 -0
  3. proxsync-2.0.0.dist-info/METADATA +468 -0
  4. proxsync-2.0.0.dist-info/RECORD +59 -0
  5. proxsync-2.0.0.dist-info/WHEEL +5 -0
  6. proxsync-2.0.0.dist-info/top_level.txt +5 -0
  7. pve_sync.py +1804 -0
  8. pve_sync_plugin/__init__.py +46 -0
  9. pve_sync_plugin/api/__init__.py +1 -0
  10. pve_sync_plugin/api/serializers.py +161 -0
  11. pve_sync_plugin/api/urls.py +27 -0
  12. pve_sync_plugin/api/views.py +94 -0
  13. pve_sync_plugin/apps.py +5 -0
  14. pve_sync_plugin/choices.py +57 -0
  15. pve_sync_plugin/filtersets.py +137 -0
  16. pve_sync_plugin/forms.py +219 -0
  17. pve_sync_plugin/management/__init__.py +1 -0
  18. pve_sync_plugin/management/commands/__init__.py +1 -0
  19. pve_sync_plugin/management/commands/pve_sync.py +182 -0
  20. pve_sync_plugin/management/commands/run_scheduled_syncs.py +109 -0
  21. pve_sync_plugin/migrations/0001_initial.py +234 -0
  22. pve_sync_plugin/migrations/0002_pvepluginsettings.py +42 -0
  23. pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +148 -0
  24. pve_sync_plugin/migrations/0004_model_verbose_names.py +34 -0
  25. pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +111 -0
  26. pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +124 -0
  27. pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +26 -0
  28. pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +26 -0
  29. pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +39 -0
  30. pve_sync_plugin/migrations/0010_pvedriftevent.py +62 -0
  31. pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +19 -0
  32. pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +39 -0
  33. pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +19 -0
  34. pve_sync_plugin/migrations/__init__.py +1 -0
  35. pve_sync_plugin/models.py +461 -0
  36. pve_sync_plugin/navigation.py +94 -0
  37. pve_sync_plugin/signals.py +24 -0
  38. pve_sync_plugin/sync/__init__.py +8 -0
  39. pve_sync_plugin/sync/config_bridge.py +221 -0
  40. pve_sync_plugin/sync/engine.py +50 -0
  41. pve_sync_plugin/tables.py +278 -0
  42. pve_sync_plugin/tasks.py +440 -0
  43. pve_sync_plugin/template_content.py +44 -0
  44. pve_sync_plugin/templates/pve_sync/dashboard.html +400 -0
  45. pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +7 -0
  46. pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +69 -0
  47. pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +27 -0
  48. pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +52 -0
  49. pve_sync_plugin/templates/pve_sync/pvedriftevent.html +89 -0
  50. pve_sync_plugin/templates/pve_sync/pvesyncjob.html +197 -0
  51. pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +42 -0
  52. pve_sync_plugin/templates/pve_sync/settings.html +79 -0
  53. pve_sync_plugin/templatetags/__init__.py +1 -0
  54. pve_sync_plugin/templatetags/pve_sync_tags.py +86 -0
  55. pve_sync_plugin/urls.py +186 -0
  56. pve_sync_plugin/utils.py +84 -0
  57. pve_sync_plugin/view_helpers.py +98 -0
  58. pve_sync_plugin/views.py +535 -0
  59. state_db.py +543 -0
pve_sync.py ADDED
@@ -0,0 +1,1804 @@
1
+ """
2
+ proxsync: 同步 Proxmox VE 到 NetBox
3
+ 優化版 - 使用預加載和快取來提高效能,包含 Telegram 通知
4
+ 增強版 - 支援狀態持久化、節點監控、配置漂移檢測、增量同步
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import time
10
+ import json
11
+ import hashlib
12
+ from datetime import datetime
13
+ from typing import Optional, Dict, Any, List, Tuple, Set
14
+ import urllib3
15
+ import pynetbox
16
+ from proxmoxer import ProxmoxAPI, ResourceException
17
+ from requests.exceptions import ReadTimeout, ConnectionError
18
+ import requests
19
+
20
+ try:
21
+ from state_db import StateDB, compute_config_hash
22
+ from config import get_config
23
+ ENHANCED_MODE = True
24
+ except ImportError:
25
+ def compute_config_hash(*args, **kwargs) -> str:
26
+ return "fallback_hash"
27
+ ENHANCED_MODE = False
28
+ StateDB = None
29
+ get_config = None
30
+
31
+
32
+ class OptimizedPVEToNetBoxSync:
33
+ """優化的 PVE 到 NetBox 同步器"""
34
+
35
+ def __init__(self, job_id=None):
36
+ """初始化"""
37
+ self.pve_api = None
38
+ self.nb_api = None
39
+ self.custom_fields_created = False
40
+ self.job_id = job_id
41
+
42
+ self.stats = {
43
+ 'nodes_offline': 0,
44
+ 'config_drifts_detected': 0,
45
+ 'tag_changes': 0,
46
+ 'resources_alert': 0,
47
+ 'total_vms': 0,
48
+ 'success_vms': 0,
49
+ 'elapsed_time': 0
50
+ }
51
+
52
+ self.enhanced_mode = ENHANCED_MODE
53
+ self.state_db = None
54
+ self.config = None
55
+ self.cluster_name = "default"
56
+ self.monitoring_config = {}
57
+ self.sync_config = {}
58
+ self._newly_created_nodes: set = set()
59
+ self._known_vmids: dict = {} # populated at start of sync_pve_virtual_machines
60
+
61
+ if self.enhanced_mode:
62
+ try:
63
+ self.config = get_config()
64
+ clusters = self.config.get_cluster_configs()
65
+ if clusters:
66
+ self.cluster_config = clusters[0]
67
+ self.cluster_name = self.cluster_config.get('name', 'default')
68
+ state_db_config = self.config.get_state_db_config()
69
+ self.state_db = StateDB(state_db_config.get('path', '/var/lib/pve-sync/state.db'))
70
+ self.monitoring_config = self.config.get_monitoring_config()
71
+ self.sync_config = self.config.get_sync_config()
72
+ print(f"✓ 增強模式已啟用: 集群={self.cluster_name}, 狀態DB={state_db_config.get('path')}")
73
+ else:
74
+ print("⚠ 未找到集群配置,回退到基礎模式")
75
+ self.enhanced_mode = False
76
+ except Exception as e:
77
+ print(f"⚠ 增強模式初始化失敗: {e},回退到基礎模式")
78
+ self.enhanced_mode = False
79
+
80
+ # Telegram 配置
81
+ if self.enhanced_mode and self.config:
82
+ telegram_config = self.config.get_telegram_config()
83
+ if telegram_config:
84
+ self.telegram_bot_token = telegram_config.get('bot_token')
85
+ self.telegram_chat_id = telegram_config.get('chat_id')
86
+ else:
87
+ self.telegram_bot_token = None
88
+ self.telegram_chat_id = None
89
+ else:
90
+ self.telegram_bot_token = os.environ.get('TELEGRAM_BOT_TOKEN')
91
+ self.telegram_chat_id = os.environ.get('TELEGRAM_CHAT_ID')
92
+
93
+ # NetBox 快取
94
+ self.nb_cache = {
95
+ 'devices': {},
96
+ 'virtual_machines': {},
97
+ 'virtual_machines_by_serial': {},
98
+ 'virtual_machines_by_name': {},
99
+ 'vm_interfaces': {},
100
+ 'device_interfaces': {},
101
+ 'mac_addresses': {},
102
+ 'prefixes': {},
103
+ 'ip_addresses': {},
104
+ 'vlans': {},
105
+ 'vm_disks': {},
106
+ 'tags': {},
107
+ 'platforms': {},
108
+ 'roles': {},
109
+ 'clusters': {},
110
+ 'sites': {},
111
+ 'manufacturers': {},
112
+ 'device_types': {},
113
+ 'device_roles': {},
114
+ 'cluster_types': {},
115
+ }
116
+
117
+ self.pve_cache = {
118
+ 'nodes': [],
119
+ 'vms_by_node': {},
120
+ 'pools': {},
121
+ 'node_networks': {},
122
+ }
123
+
124
+ self.error_log = []
125
+
126
+ # ---------- Telegram ----------
127
+ def send_telegram_notification(self, message: str):
128
+ try:
129
+ url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
130
+ payload = {'chat_id': self.telegram_chat_id, 'text': message, 'parse_mode': 'HTML'}
131
+ response = requests.post(url, data=payload, timeout=10)
132
+ if response.status_code == 200:
133
+ print(f"✓ Telegram 通知已發送")
134
+ return True
135
+ else:
136
+ print(f"✗ Telegram 通知發送失敗: {response.status_code}")
137
+ return False
138
+ except Exception as e:
139
+ print(f"✗ 發送 Telegram 通知失敗: {e}")
140
+ return False
141
+
142
+ def log_ip_conflict_error(self, vm_name: str, ip_address: str, error_message: str):
143
+ error_info = {
144
+ 'timestamp': time.strftime("%Y-%m-%d %H:%M:%S"),
145
+ 'vm_name': vm_name,
146
+ 'ip_address': ip_address,
147
+ 'error': error_message
148
+ }
149
+ self.error_log.append(error_info)
150
+ message = f"""
151
+ 🚨 <b>PVE-NetBox 同步 IP 衝突警告</b>
152
+
153
+ 📅 時間: {error_info['timestamp']}
154
+ 🖥️ 虛擬機: {vm_name}
155
+ 🌐 IP 位址: {ip_address}
156
+ ❌ 錯誤: {error_message}
157
+
158
+ ⚠️ 需要手動處理
159
+ """
160
+ self.send_telegram_notification(message)
161
+ print(f"📧 已發送 IP 衝突通知: {vm_name} - {ip_address}")
162
+
163
+ def send_enhanced_summary(self):
164
+ if not hasattr(self, 'stats'):
165
+ return
166
+ total_count = self.stats.get('total_vms', 0)
167
+ success_count = self.stats.get('success_vms', 0)
168
+ error_count = total_count - success_count
169
+ success_rate = (success_count / total_count * 100) if total_count > 0 else 0
170
+ elapsed = self.stats.get('elapsed_time', 0)
171
+ message = f"""
172
+ 📊 <b>PVE-NetBox 同步報告</b>
173
+
174
+ 📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}
175
+ 🏷️ 集群: {self.cluster_name}
176
+ ⏱️ 耗時: {elapsed:.1f}秒
177
+
178
+ ✅ 成功: {success_count} 台 VM
179
+ 📊 總計: {total_count} 台 VM
180
+ ❌ 失敗: {error_count} 台
181
+ 📈 成功率: {success_rate:.1f}%
182
+
183
+ """
184
+ extras = []
185
+ if self.stats.get('nodes_offline', 0) > 0:
186
+ extras.append(f"🚨 節點離線: {self.stats['nodes_offline']}")
187
+ if self.stats.get('config_drifts_detected', 0) > 0:
188
+ extras.append(f"🔄 配置變更: {self.stats['config_drifts_detected']}")
189
+ if self.stats.get('tag_changes', 0) > 0:
190
+ extras.append(f"🏷️ 標籤變更: {self.stats['tag_changes']}")
191
+ if self.stats.get('resources_alert', 0) > 0:
192
+ extras.append(f"⚠️ 資源告警: {self.stats['resources_alert']}")
193
+ if extras:
194
+ message += "\n🔍 檢測發現:\n" + "\n".join(extras)
195
+ if self.error_log:
196
+ message += f"\n⚠️ <b>需處理的錯誤:</b>\n"
197
+ for error in self.error_log[:5]:
198
+ message += f"• {error['vm_name']} - {error['ip_address']}\n"
199
+ if len(self.error_log) > 5:
200
+ message += f"• ... 還有 {len(self.error_log) - 5} 個錯誤\n"
201
+ self.send_telegram_notification(message)
202
+
203
+ # ---------- API 連接 ----------
204
+ def connect_pve(self) -> bool:
205
+ max_retries = 3
206
+ retry_delay = 5
207
+ for attempt in range(max_retries):
208
+ try:
209
+ self.pve_api = ProxmoxAPI(
210
+ host=os.environ['PVE_API_HOST'],
211
+ user=os.environ['PVE_API_USER'],
212
+ token_name=os.environ['PVE_API_TOKEN'],
213
+ token_value=os.environ['PVE_API_SECRET'],
214
+ verify_ssl=os.getenv('PVE_API_VERIFY_SSL', 'false').lower() == 'true',
215
+ timeout=30,
216
+ )
217
+ self.pve_api.nodes.get()
218
+ print("✓ PVE API 連接成功")
219
+ return True
220
+ except (ReadTimeout, ConnectionError) as e:
221
+ print(f"PVE API 連接失敗 (嘗試 {attempt + 1}/{max_retries}): {e}")
222
+ if attempt < max_retries - 1:
223
+ time.sleep(retry_delay)
224
+ raise RuntimeError(
225
+ f"PVE API 無法連線 (host={os.environ.get('PVE_API_HOST')}),"
226
+ f"已重試 {max_retries} 次仍失敗,請確認網路連線與防火牆設定"
227
+ )
228
+
229
+ def connect_netbox(self) -> bool:
230
+ try:
231
+ self.nb_api = pynetbox.api(
232
+ url=os.environ['NB_API_URL'],
233
+ token=os.environ['NB_API_TOKEN'],
234
+ )
235
+ self.nb_api.http_session.verify = False
236
+ self.nb_api.http_session.timeout = 30
237
+ count = self.nb_api.dcim.devices.count()
238
+ print(f"✓ NetBox API 連接成功 (現有設備數: {count})")
239
+ return True
240
+ except Exception as e:
241
+ print(f"✗ NetBox API 連接失敗: {e}")
242
+ return False
243
+
244
+ # ---------- NetBox 預加載 ----------
245
+ def load_all_netbox_objects(self):
246
+ print("\n預加載 NetBox 物件...")
247
+ start_time = time.time()
248
+ for device in self.nb_api.dcim.devices.all():
249
+ self.nb_cache['devices'][device.name.lower()] = device
250
+ dev_cluster_id = device.cluster.id if device.cluster else 'no-cluster'
251
+ self.nb_cache['devices'][f"{device.name.lower()}::{dev_cluster_id}"] = device
252
+ for vm in self.nb_api.virtualization.virtual_machines.all():
253
+ self.nb_cache['virtual_machines'][vm.id] = vm
254
+ if vm.serial:
255
+ cluster_id = vm.cluster.id if vm.cluster else 'no-cluster'
256
+ # Key includes cluster_id so same vmid from different PVE clusters
257
+ # are stored separately and don't overwrite each other.
258
+ serial_key = f"{vm.serial}::{cluster_id}"
259
+ self.nb_cache['virtual_machines_by_serial'][serial_key] = vm
260
+ cluster_id = vm.cluster.id if vm.cluster else 'no-cluster'
261
+ key = f"{vm.name.lower()}::{cluster_id}"
262
+ self.nb_cache['virtual_machines_by_name'][key] = vm
263
+ for iface in self.nb_api.virtualization.interfaces.all():
264
+ vm_id = iface.virtual_machine.id
265
+ self.nb_cache['vm_interfaces'].setdefault(vm_id, {})[iface.name] = iface
266
+ for iface in self.nb_api.dcim.interfaces.all():
267
+ device_id = iface.device.id
268
+ self.nb_cache['device_interfaces'].setdefault(device_id, {})[iface.name] = iface
269
+ for mac in self.nb_api.dcim.mac_addresses.all():
270
+ if mac.mac_address:
271
+ self.nb_cache['mac_addresses'][mac.mac_address.lower()] = mac
272
+ for prefix in self.nb_api.ipam.prefixes.all():
273
+ self.nb_cache['prefixes'][prefix.prefix] = prefix
274
+ for ip_addr in self.nb_api.ipam.ip_addresses.all():
275
+ self.nb_cache['ip_addresses'][ip_addr.address] = ip_addr
276
+ for vlan in self.nb_api.ipam.vlans.all():
277
+ self.nb_cache['vlans'][str(vlan.vid)] = vlan
278
+ for disk in self.nb_api.virtualization.virtual_disks.all():
279
+ vm_id = disk.virtual_machine.id
280
+ self.nb_cache['vm_disks'].setdefault(vm_id, {})[disk.name] = disk
281
+ for tag in self.nb_api.extras.tags.all():
282
+ self.nb_cache['tags'][tag.name] = tag
283
+ for platform in self.nb_api.dcim.platforms.all():
284
+ self.nb_cache['platforms'][platform.name.lower()] = platform
285
+ for role in self.nb_api.dcim.device_roles.all():
286
+ self.nb_cache['roles'][role.name.lower()] = role
287
+ for cluster in self.nb_api.virtualization.clusters.all():
288
+ self.nb_cache['clusters'][str(cluster.id)] = cluster
289
+ for site in self.nb_api.dcim.sites.all():
290
+ self.nb_cache['sites'][site.name.lower()] = site
291
+ for manufacturer in self.nb_api.dcim.manufacturers.all():
292
+ self.nb_cache['manufacturers'][manufacturer.name.lower()] = manufacturer
293
+ for device_type in self.nb_api.dcim.device_types.all():
294
+ self.nb_cache['device_types'][device_type.model.lower()] = device_type
295
+ for device_role in self.nb_api.dcim.device_roles.all():
296
+ self.nb_cache['device_roles'][device_role.name.lower()] = device_role
297
+ for cluster_type in self.nb_api.virtualization.cluster_types.all():
298
+ self.nb_cache['cluster_types'][cluster_type.name.lower()] = cluster_type
299
+ elapsed = time.time() - start_time
300
+ print(f"✓ 預加載完成,耗時 {elapsed:.2f} 秒")
301
+ print(f" 設備: {len(self.nb_cache['devices'])} 個")
302
+ print(f" 虛擬機: {len(self.nb_cache['virtual_machines'])} 個")
303
+ print(f" 標籤: {len(self.nb_cache['tags'])} 個")
304
+ print(f" 角色: {len(self.nb_cache['roles'])} 個")
305
+
306
+ # ---------- PVE 數據加載 ----------
307
+ def load_pve_data(self):
308
+ print("\n加載 PVE 數據...")
309
+ start_time = time.time()
310
+ try:
311
+ self.pve_cache['nodes'] = self.pve_api.nodes.get()
312
+ except Exception as e:
313
+ print(f" 加載節點失敗: {e}")
314
+ self.pve_cache['nodes'] = []
315
+ try:
316
+ pools = self.pve_api.pools.get()
317
+ for pool in pools:
318
+ pool_id = pool['poolid']
319
+ try:
320
+ pool_detail = self.pve_api.pools(pool_id).get()
321
+ members = []
322
+ for member in pool_detail.get('members', []):
323
+ if 'vmid' in member:
324
+ members.append({
325
+ 'vmid': member['vmid'],
326
+ 'type': member.get('type', 'qemu'),
327
+ 'name': member.get('name', '')
328
+ })
329
+ self.pve_cache['pools'][pool_id] = {
330
+ 'name': pool_id,
331
+ 'comment': pool.get('comment', ''),
332
+ 'members': members
333
+ }
334
+ except Exception as e:
335
+ print(f" 獲取 pool {pool_id} 詳細資訊失敗: {e}")
336
+ except Exception as e:
337
+ print(f" 加載 Pools 失敗: {e}")
338
+ for node in self.pve_cache['nodes']:
339
+ node_name = node['node']
340
+ try:
341
+ qemu_vms = self.pve_api.nodes(node_name).qemu.get()
342
+ lxc_vms = self.pve_api.nodes(node_name).lxc.get()
343
+ all_vms = []
344
+ for vm in qemu_vms:
345
+ vm['type'] = 'qemu'
346
+ vm['node'] = node_name
347
+ all_vms.append(vm)
348
+ for vm in lxc_vms:
349
+ vm['type'] = 'lxc'
350
+ vm['node'] = node_name
351
+ all_vms.append(vm)
352
+ self.pve_cache['vms_by_node'][node_name] = all_vms
353
+ for vm in all_vms[:10]:
354
+ try:
355
+ if vm['type'] == 'qemu':
356
+ vm['config'] = self.pve_api.nodes(node_name).qemu(vm['vmid']).config.get()
357
+ else:
358
+ vm['config'] = self.pve_api.nodes(node_name).lxc(vm['vmid']).config.get()
359
+ except:
360
+ pass
361
+ except Exception as e:
362
+ print(f" 加載節點 {node_name} 的虛擬機失敗: {e}")
363
+ self.pve_cache['vms_by_node'][node_name] = []
364
+ elapsed = time.time() - start_time
365
+ print(f"✓ PVE 數據加載完成,耗時 {elapsed:.2f} 秒")
366
+ print(f" 節點: {len(self.pve_cache['nodes'])} 個")
367
+ print(f" Pools: {len(self.pve_cache['pools'])} 個")
368
+ total_vms = sum(len(vms) for vms in self.pve_cache['vms_by_node'].values())
369
+ print(f" 虛擬機: {total_vms} 個")
370
+
371
+ # ---------- Custom Fields ----------
372
+ def check_required_custom_fields(self):
373
+ print("\n檢查 custom fields...")
374
+ try:
375
+ existing_fields = list(self.nb_api.extras.custom_fields.all())
376
+ existing_names = [field.name for field in existing_fields]
377
+ required_fields = [
378
+ {'name': 'ha', 'label': 'Failover', 'type': 'boolean', 'content_types': ['virtualization.virtualmachine']},
379
+ {'name': 'qemu_agent', 'label': 'QemuAgent', 'type': 'boolean', 'content_types': ['virtualization.virtualmachine']},
380
+ {'name': 'search_domain', 'label': 'Search Domain', 'type': 'text', 'content_types': ['virtualization.virtualmachine']},
381
+ {'name': 'vm_id', 'label': 'VM ID', 'type': 'integer', 'content_types': ['virtualization.virtualmachine']},
382
+ {'name': 'replicated', 'label': 'Replicated', 'type': 'boolean', 'content_types': ['virtualization.virtualmachine']},
383
+ {'name': 'machine_type', 'label': '機型', 'type': 'text', 'content_types': ['virtualization.virtualmachine']},
384
+ {'name': 'host_cpu_cores', 'label': 'Host CPU Cores', 'type': 'integer', 'content_types': ['dcim.device']},
385
+ {'name': 'host_memory', 'label': 'Host Memory', 'type': 'text', 'content_types': ['dcim.device']},
386
+ {'name': 'host_disk_size', 'label': 'Host Disk Size', 'type': 'text', 'content_types': ['dcim.device']},
387
+ {'name': 'host_disk_free', 'label': 'Host Disk Free', 'type': 'text', 'content_types': ['dcim.device']},
388
+ ]
389
+ missing_fields = [f for f in required_fields if f['name'] not in existing_names]
390
+ if missing_fields:
391
+ print(f"⚠ 缺少 {len(missing_fields)} 個 custom fields,相關欄位將略過(同步繼續):")
392
+ for field in missing_fields:
393
+ print(f" - {field['name']} ({field['type']})")
394
+ missing_list = "\n".join([f"- {f['name']}" for f in missing_fields])
395
+ self.send_telegram_notification(
396
+ f"⚠️ <b>NetBox Custom Fields 缺失</b>\n\n以下欄位不存在,同步將略過這些欄位:\n\n{missing_list}\n\nNetBox 路徑: Extensions → Custom Fields"
397
+ )
398
+ self.custom_fields_created = False
399
+ else:
400
+ print("✓ 所有必要的 custom fields 都已存在")
401
+ self.custom_fields_created = True
402
+ return True
403
+ except Exception as e:
404
+ print(f"⚠ 檢查 custom fields 失敗: {e},custom field 同步將略過")
405
+ self.custom_fields_created = False
406
+ return True
407
+
408
+ # ---------- 輔助函數 ----------
409
+ def get_vm_pool(self, vm_id: int, vm_type: str) -> Optional[str]:
410
+ for pool_id, pool_info in self.pve_cache['pools'].items():
411
+ for member in pool_info['members']:
412
+ if member['vmid'] == vm_id and member['type'] == vm_type:
413
+ return pool_id
414
+ return None
415
+
416
+ def get_or_create_vm_role(self, role_name: str) -> Optional[int]:
417
+ if not role_name:
418
+ return None
419
+ role_key = role_name.lower()
420
+ if role_key in self.nb_cache['roles']:
421
+ return self.nb_cache['roles'][role_key].id
422
+ try:
423
+ roles = list(self.nb_api.dcim.device_roles.filter(name=role_name))
424
+ if roles:
425
+ role = roles[0]
426
+ self.nb_cache['roles'][role_key] = role
427
+ return role.id
428
+ slug = role_name.lower().replace(' ', '-').replace('(', '').replace(')', '').replace('/', '-')[:50]
429
+ color_hash = hashlib.md5(role_name.encode()).hexdigest()[:6]
430
+ role = self.nb_api.dcim.device_roles.create(
431
+ name=role_name, slug=slug, color=color_hash, vm_role=True,
432
+ description=f"Proxmox Pool: {role_name}"
433
+ )
434
+ self.nb_cache['roles'][role_key] = role
435
+ return role.id
436
+ except Exception as e:
437
+ print(f" 建立虛擬機角色失敗 {role_name}: {e}")
438
+ return None
439
+
440
+ def get_or_create_site(self, site_name: str = "Main Datacenter") -> Optional[int]:
441
+ site_key = site_name.lower()
442
+ if site_key in self.nb_cache['sites']:
443
+ return self.nb_cache['sites'][site_key].id
444
+ try:
445
+ sites = list(self.nb_api.dcim.sites.filter(name=site_name))
446
+ if sites:
447
+ site = sites[0]
448
+ self.nb_cache['sites'][site_key] = site
449
+ return site.id
450
+ site = self.nb_api.dcim.sites.create(
451
+ name=site_name, slug=site_name.lower().replace(' ', '-'), status='active'
452
+ )
453
+ self.nb_cache['sites'][site_key] = site
454
+ return site.id
455
+ except Exception as e:
456
+ print(f"處理站點失敗: {e}")
457
+ return None
458
+
459
+ def get_or_create_manufacturer(self, name: str = "Generic") -> Optional[int]:
460
+ key = name.lower()
461
+ if key in self.nb_cache['manufacturers']:
462
+ return self.nb_cache['manufacturers'][key].id
463
+ try:
464
+ manufacturers = list(self.nb_api.dcim.manufacturers.filter(name=name))
465
+ if manufacturers:
466
+ manufacturer = manufacturers[0]
467
+ self.nb_cache['manufacturers'][key] = manufacturer
468
+ return manufacturer.id
469
+ slug = name.lower().replace(' ', '-').replace('/', '-')[:50]
470
+ manufacturer = self.nb_api.dcim.manufacturers.create(name=name, slug=slug)
471
+ self.nb_cache['manufacturers'][key] = manufacturer
472
+ return manufacturer.id
473
+ except Exception as e:
474
+ print(f" 建立廠商失敗 {name}: {e}")
475
+ return None
476
+
477
+ def get_or_create_device_type(self, model_name: str = "Standard Server") -> Optional[int]:
478
+ key = model_name.lower()
479
+ if key in self.nb_cache['device_types']:
480
+ return self.nb_cache['device_types'][key].id
481
+ try:
482
+ device_types = list(self.nb_api.dcim.device_types.filter(model=model_name))
483
+ if device_types:
484
+ device_type = device_types[0]
485
+ self.nb_cache['device_types'][key] = device_type
486
+ return device_type.id
487
+ manufacturer_id = self.get_or_create_manufacturer("Generic")
488
+ if not manufacturer_id:
489
+ return None
490
+ slug = model_name.lower().replace(' ', '-').replace('/', '-')[:50]
491
+ device_type = self.nb_api.dcim.device_types.create(
492
+ model=model_name, slug=slug, manufacturer=manufacturer_id,
493
+ u_height=1, is_full_depth=False
494
+ )
495
+ self.nb_cache['device_types'][key] = device_type
496
+ return device_type.id
497
+ except Exception as e:
498
+ print(f" 建立設備類型失敗 {model_name}: {e}")
499
+ return None
500
+
501
+ def get_or_create_device_role(self, role_name: str = "PVE") -> Optional[int]:
502
+ key = role_name.lower()
503
+ if key in self.nb_cache['device_roles']:
504
+ return self.nb_cache['device_roles'][key].id
505
+ try:
506
+ roles = list(self.nb_api.dcim.device_roles.filter(name=role_name))
507
+ if roles:
508
+ role = roles[0]
509
+ self.nb_cache['device_roles'][key] = role
510
+ return role.id
511
+ slug = role_name.lower().replace(' ', '-').replace('/', '-')[:50]
512
+ role = self.nb_api.dcim.device_roles.create(
513
+ name=role_name, slug=slug, color="c0c0c0", vm_role=False
514
+ )
515
+ self.nb_cache['device_roles'][key] = role
516
+ return role.id
517
+ except Exception as e:
518
+ print(f" 建立設備角色失敗 {role_name}: {e}")
519
+ return None
520
+
521
+ def get_or_create_cluster_type(self, cluster_type_name: str = "Proxmox") -> Optional[int]:
522
+ key = cluster_type_name.lower()
523
+ if key in self.nb_cache['cluster_types']:
524
+ return self.nb_cache['cluster_types'][key].id
525
+ try:
526
+ types = list(self.nb_api.virtualization.cluster_types.filter(name=cluster_type_name))
527
+ if types:
528
+ ct = types[0]
529
+ self.nb_cache['cluster_types'][key] = ct
530
+ return ct.id
531
+ ct = self.nb_api.virtualization.cluster_types.create(
532
+ name=cluster_type_name, slug=cluster_type_name.lower().replace(' ', '-'),
533
+ description="Proxmox Virtual Environment Cluster"
534
+ )
535
+ self.nb_cache['cluster_types'][key] = ct
536
+ return ct.id
537
+ except Exception as e:
538
+ print(f"處理集群類型失敗: {e}")
539
+ return None
540
+
541
+ def get_or_create_cluster(self, cluster_name: str, site_id: int, cluster_type_id: int) -> Optional[Dict]:
542
+ try:
543
+ clusters = list(self.nb_api.virtualization.clusters.filter(name=cluster_name))
544
+ if clusters:
545
+ cluster = clusters[0]
546
+ return {'id': cluster.id, 'name': cluster.name}
547
+ cluster = self.nb_api.virtualization.clusters.create(
548
+ name=cluster_name, slug=cluster_name.lower().replace(' ', '-'),
549
+ site=site_id, type=cluster_type_id
550
+ )
551
+ self.nb_cache['clusters'][str(cluster.id)] = cluster
552
+ return {'id': cluster.id, 'name': cluster.name}
553
+ except Exception as e:
554
+ print(f"處理集群失敗: {e}")
555
+ return None
556
+
557
+ def check_qemu_agent(self, config: Dict[str, Any]) -> bool:
558
+ if 'agent' in config:
559
+ agent_value = str(config['agent']).strip()
560
+ agent_parts = [part.strip() for part in agent_value.split(',')]
561
+ for part in agent_parts:
562
+ if part == '1' or part == 'enabled=1' or 'fstrim_cloned_disks=1' in part:
563
+ return True
564
+ return False
565
+
566
+ def parse_network_config(self, config_value: str) -> Dict:
567
+ result = {}
568
+ for item in config_value.split(','):
569
+ if '=' in item:
570
+ key, value = item.split('=', 1)
571
+ result[key] = value
572
+ return result
573
+
574
+ def get_unique_vm_name(self, vm_name: str, vm_id: str, cluster_id: int) -> str:
575
+ key = f"{vm_name.lower()}::{cluster_id}"
576
+ existing_vm = self.nb_cache['virtual_machines_by_name'].get(key)
577
+ if existing_vm:
578
+ if existing_vm.serial and str(existing_vm.serial) == vm_id:
579
+ return vm_name
580
+ unique_name = f"{vm_name}-{vm_id}"
581
+ print(f" 名稱衝突: {vm_name} 已存在,使用新名稱: {unique_name}")
582
+ return unique_name
583
+ for vm in self.nb_cache['virtual_machines_by_serial'].values():
584
+ if vm.serial and str(vm.serial) == vm_id and vm.cluster and vm.cluster.id == cluster_id:
585
+ if vm.name != vm_name:
586
+ new_key = f"{vm_name.lower()}::{cluster_id}"
587
+ if new_key not in self.nb_cache['virtual_machines_by_name']:
588
+ return vm_name
589
+ else:
590
+ unique_name = f"{vm_name}-{vm_id}"
591
+ print(f" 名稱衝突,使用新名稱: {unique_name}")
592
+ return unique_name
593
+ return vm_name
594
+ return vm_name
595
+
596
+ # ---------- 節點硬體資訊 ----------
597
+ def fetch_node_hardware_info(self, node_name: str) -> Dict[str, Any]:
598
+ try:
599
+ status = self.pve_api.nodes(node_name).status.get()
600
+ cpuinfo = status.get('cpuinfo', {})
601
+ memory = status.get('memory', {})
602
+ rootfs = status.get('rootfs', {}) or status.get('root', {})
603
+ disk_total = rootfs.get('total', 0)
604
+ disk_used = rootfs.get('used', 0)
605
+ disk_free = rootfs.get('avail', disk_total - disk_used)
606
+ return {
607
+ 'cpu_cores': cpuinfo.get('cpus', 0),
608
+ 'memory_total': memory.get('total', 0),
609
+ 'disk_total': disk_total,
610
+ 'disk_free': disk_free,
611
+ }
612
+ except Exception as e:
613
+ print(f" 獲取節點硬體資訊失敗 {node_name}: {e}")
614
+ return {}
615
+
616
+ # ---------- 設備介面獲取/建立 ----------
617
+ def get_or_create_device_interface(self, device_id: int, iface_name: str, iface_type: str = 'eth',
618
+ mac_address: str = None, enabled: bool = True) -> Optional[Any]:
619
+ device_interfaces = self.nb_cache['device_interfaces'].get(device_id, {})
620
+ if iface_name in device_interfaces:
621
+ return device_interfaces[iface_name]
622
+ try:
623
+ iface_data = {
624
+ 'device': device_id, 'name': iface_name, 'enabled': enabled, 'type': iface_type
625
+ }
626
+ if mac_address:
627
+ iface_data['mac_address'] = mac_address
628
+ iface = self.nb_api.dcim.interfaces.create(**iface_data)
629
+ self.nb_cache['device_interfaces'].setdefault(device_id, {})[iface_name] = iface
630
+ return iface
631
+ except Exception as e:
632
+ print(f" 建立底層介面失敗 {iface_name}: {e}")
633
+ return None
634
+
635
+ # ---------- IP 分配(改進版) ----------
636
+ def assign_ip_to_interface(self, interface, ip_address: str, dns_name: str = None, is_vm_interface: bool = False) -> Optional[Any]:
637
+ try:
638
+ if '/' not in ip_address:
639
+ ip_with_prefix = f"{ip_address}/24"
640
+ else:
641
+ ip_with_prefix = ip_address
642
+
643
+ assigned_object_type = 'virtualization.vminterface' if is_vm_interface else 'dcim.interface'
644
+
645
+ # 檢查是否已存在
646
+ existing_ip = self.nb_cache['ip_addresses'].get(ip_with_prefix)
647
+ if not existing_ip:
648
+ # 嘗試查找可能存在的同IP但不同前綴的位址
649
+ ip_only = ip_with_prefix.split('/')[0]
650
+ for addr, ip_obj in self.nb_cache['ip_addresses'].items():
651
+ if addr.startswith(ip_only + '/'):
652
+ existing_ip = ip_obj
653
+ break
654
+
655
+ if existing_ip:
656
+ # 更新已存在的IP
657
+ existing_ip.address = ip_with_prefix # 更新前綴長度
658
+ existing_ip.assigned_object_type = assigned_object_type
659
+ existing_ip.assigned_object_id = interface.id
660
+ if dns_name:
661
+ existing_ip.dns_name = dns_name
662
+ existing_ip.save()
663
+ # 更新快取
664
+ self.nb_cache['ip_addresses'][ip_with_prefix] = existing_ip
665
+ return existing_ip
666
+
667
+ # 建立新IP
668
+ ip_data = {
669
+ 'address': ip_with_prefix,
670
+ 'assigned_object_type': assigned_object_type,
671
+ 'assigned_object_id': interface.id,
672
+ 'status': 'active'
673
+ }
674
+ if dns_name:
675
+ ip_data['dns_name'] = dns_name
676
+
677
+ ip_obj = self.nb_api.ipam.ip_addresses.create(**ip_data)
678
+ self.nb_cache['ip_addresses'][ip_with_prefix] = ip_obj
679
+ return ip_obj
680
+
681
+ except Exception as e:
682
+ error_msg = str(e)
683
+ print(f" 分配 IP 位址失敗 {ip_address}: {error_msg}")
684
+ if "Cannot reassign IP address while it is designated as the primary IP" in error_msg:
685
+ vm_name = "Unknown"
686
+ try:
687
+ if is_vm_interface:
688
+ vm_info = self.nb_api.virtualization.interfaces.get(interface.id)
689
+ if vm_info and vm_info.virtual_machine:
690
+ vm_name = vm_info.virtual_machine.name
691
+ except:
692
+ pass
693
+ self.log_ip_conflict_error(vm_name, ip_address, error_msg)
694
+ return None
695
+
696
+ # ---------- 節點網路介面同步 ----------
697
+ def sync_node_network_interfaces(self, device, node_name: str, network_data: List[Dict]):
698
+ print(f" 正在同步節點網路介面: {node_name}")
699
+ device_id = device.id
700
+ device_interfaces = self.nb_cache['device_interfaces'].get(device_id, {})
701
+ for iface_data in network_data:
702
+ iface_name = iface_data.get('iface')
703
+ if not iface_name:
704
+ continue
705
+ iface_type = iface_data.get('type', '').lower()
706
+ if iface_type not in ['eth', 'bridge', 'bond']:
707
+ continue
708
+ mac_address = iface_data.get('address', '').lower() or None
709
+ enabled = iface_data.get('active', 0) == 1
710
+ nb_iface_data = {
711
+ 'device': device_id, 'name': iface_name, 'mac_address': mac_address, 'enabled': enabled
712
+ }
713
+ if iface_type == 'eth':
714
+ nb_iface_data['type'] = '1000base-t'
715
+ elif iface_type == 'bridge':
716
+ nb_iface_data['type'] = 'bridge'
717
+ bridge_ports = iface_data.get('bridge_ports', '')
718
+ if bridge_ports:
719
+ first_port = bridge_ports.split(',')[0].strip()
720
+ port_iface = self.get_or_create_device_interface(
721
+ device_id, first_port, iface_type='eth', mac_address=None, enabled=True
722
+ )
723
+ if port_iface:
724
+ nb_iface_data['bridge'] = port_iface.id
725
+ elif iface_type == 'bond':
726
+ nb_iface_data['type'] = 'bond'
727
+ gateway = iface_data.get('gateway')
728
+ if gateway:
729
+ nb_iface_data['gateway'] = gateway
730
+ if iface_name in device_interfaces:
731
+ nb_iface = device_interfaces[iface_name]
732
+ for key, value in nb_iface_data.items():
733
+ if key != 'device':
734
+ setattr(nb_iface, key, value)
735
+ try:
736
+ nb_iface.save()
737
+ except Exception as e:
738
+ print(f" 更新設備介面失敗 {iface_name}: {e}")
739
+ continue
740
+ else:
741
+ try:
742
+ nb_iface = self.nb_api.dcim.interfaces.create(**nb_iface_data)
743
+ self.nb_cache['device_interfaces'].setdefault(device_id, {})[iface_name] = nb_iface
744
+ except Exception as e:
745
+ print(f" 建立設備介面失敗 {iface_name}: {e}")
746
+ continue
747
+ cidr = iface_data.get('cidr')
748
+ if cidr:
749
+ self.assign_ip_to_interface(nb_iface, cidr, dns_name=f"{node_name}.local", is_vm_interface=False)
750
+
751
+ # ---------- 節點同步 ----------
752
+ def sync_pve_nodes_to_netbox(self) -> Tuple[bool, Dict[str, Any], Dict]:
753
+ print("\n開始同步 PVE 節點...")
754
+ settings = self.cluster_config.get('settings', {}) if self.cluster_config else {}
755
+ site_name = settings.get('site_name', 'Main Datacenter')
756
+ cluster_type_name = settings.get('cluster_type', 'Proxmox')
757
+ netbox_cluster_name = settings.get('cluster_name', 'Proxmox Cluster')
758
+
759
+ site_id = self.get_or_create_site(site_name)
760
+ if not site_id:
761
+ print("✗ 無法獲取或建立站點")
762
+ return False, {}, {}
763
+ cluster_type_id = self.get_or_create_cluster_type(cluster_type_name)
764
+ if not cluster_type_id:
765
+ print("✗ 無法獲取或建立集群類型")
766
+ return False, {}, {}
767
+ cluster = self.get_or_create_cluster(netbox_cluster_name, site_id, cluster_type_id)
768
+ if not cluster:
769
+ print("✗ 無法獲取或建立集群")
770
+ return False, {}, {}
771
+ print(f" 使用集群: {cluster['name']} (ID: {cluster['id']})")
772
+ default_device_role = self.sync_config.get('default_node_role', 'PVE')
773
+ default_device_type = self.sync_config.get('default_node_type', 'Standard Server')
774
+ devices = {}
775
+ success_count = 0
776
+ for node in self.pve_cache['nodes']:
777
+ pve_node_name = node['node'] # original PVE node name (used as devices dict key)
778
+ node_name = pve_node_name # may be overridden below if name collision
779
+ node_status = node.get('status', 'unknown')
780
+ # Prefer cluster-scoped lookup so same-named nodes in different clusters
781
+ # don't collide (e.g. both clusters having a node named 'pve').
782
+ # Also check the collision-fallback name ({node}-{cluster_name}) in case a
783
+ # previous sync had to rename due to a (name, site) uniqueness conflict.
784
+ collision_name = f"{node_name}-{self.cluster_name}".lower()
785
+ device = (
786
+ self.nb_cache['devices'].get(f"{node_name.lower()}::{cluster['id']}")
787
+ or self.nb_cache['devices'].get(f"{collision_name}::{cluster['id']}")
788
+ or self.nb_cache['devices'].get(node_name.lower())
789
+ )
790
+ # Reject match if it belongs to a different cluster
791
+ if device and getattr(getattr(device, 'cluster', None), 'id', None) != cluster['id']:
792
+ device = None
793
+ if not device:
794
+ print(f" → 設備 {node_name} 不存在,嘗試自動建立...")
795
+ device_role_id = self.get_or_create_device_role(default_device_role)
796
+ if not device_role_id:
797
+ print(f" ✗ 無法取得設備角色,跳過節點 {node_name}")
798
+ continue
799
+ device_type_id = self.get_or_create_device_type(default_device_type)
800
+ if not device_type_id:
801
+ print(f" ✗ 無法取得設備類型,跳過節點 {node_name}")
802
+ continue
803
+ try:
804
+ device = self.nb_api.dcim.devices.create(
805
+ name=node_name,
806
+ role=device_role_id,
807
+ device_type=device_type_id,
808
+ site=site_id,
809
+ status='active' if node_status == 'online' else 'offline',
810
+ cluster=cluster['id'],
811
+ )
812
+ except Exception as e:
813
+ # Fallback: pynetbox may fail on NetBox 4.6+ 'location' bug;
814
+ # use Django ORM directly which bypasses the API validation quirk.
815
+ try:
816
+ from dcim.models import Device
817
+ create_name = node_name
818
+ try:
819
+ device_orm = Device.objects.create(
820
+ name=create_name,
821
+ role_id=device_role_id,
822
+ device_type_id=device_type_id,
823
+ site_id=site_id,
824
+ status='active' if node_status == 'online' else 'offline',
825
+ cluster_id=cluster['id'],
826
+ )
827
+ except Exception:
828
+ # (name, site) unique constraint — suffix with cluster name
829
+ create_name = f"{node_name}-{self.cluster_name}"
830
+ device_orm = Device.objects.create(
831
+ name=create_name,
832
+ role_id=device_role_id,
833
+ device_type_id=device_type_id,
834
+ site_id=site_id,
835
+ status='active' if node_status == 'online' else 'offline',
836
+ cluster_id=cluster['id'],
837
+ )
838
+ print(f" ⚠ 名稱衝突,改用: {create_name}")
839
+ # Fetch back via API so we get a pynetbox object
840
+ device = self.nb_api.dcim.devices.get(device_orm.pk)
841
+ node_name = create_name # update local var so cache uses correct key
842
+ except Exception as e2:
843
+ print(f" ✗ 建立設備失敗 {node_name}: {e} / {e2}")
844
+ continue
845
+ self.nb_cache['devices'][node_name.lower()] = device
846
+ self.nb_cache['devices'][f"{node_name.lower()}::{cluster['id']}"] = device
847
+ self._newly_created_nodes.add(node_name.lower())
848
+ print(f" ✓ 成功建立設備: {node_name} (ID: {device.id})")
849
+ # 更新設備狀態(跳過剛建立的節點;正確比對 pynetbox Status 物件)
850
+ if node_name.lower() not in self._newly_created_nodes:
851
+ try:
852
+ old_val = getattr(device.status, 'value', str(device.status))
853
+ new_val = 'active' if node_status == 'online' else 'offline'
854
+ if old_val != new_val:
855
+ device.status = new_val
856
+ device.save()
857
+ print(f" 設備狀態更新: {node_name} {old_val} → {new_val}")
858
+ except Exception as e:
859
+ print(f" 更新設備狀態失敗: {e}")
860
+ # 同步硬體資訊
861
+ hw_info = self.fetch_node_hardware_info(node_name)
862
+ if hw_info:
863
+ try:
864
+ comments = device.comments or ""
865
+ sync_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
866
+ hw_line = f"\n### 硬體資訊 (最後同步: {sync_time})\n- CPU核心: {hw_info['cpu_cores']}\n- 記憶體: {hw_info['memory_total'] / (1024**3):.1f} GB\n- 磁碟總容量: {hw_info['disk_total'] / (1024**3):.1f} GB\n- 磁碟可用: {hw_info['disk_free'] / (1024**3):.1f} GB"
867
+ if "### 硬體資訊" in comments:
868
+ parts = comments.split("### 硬體資訊")
869
+ comments = parts[0] + hw_line
870
+ else:
871
+ comments += hw_line
872
+ device.comments = comments
873
+ if self.custom_fields_created:
874
+ cf = dict(device.custom_fields or {})
875
+ cf.update({
876
+ 'host_cpu_cores': hw_info['cpu_cores'],
877
+ 'host_memory': f"{hw_info['memory_total'] / (1024**3):.1f} GB",
878
+ 'host_disk_size': f"{hw_info['disk_total'] / (1024**3):.1f} GB",
879
+ 'host_disk_free': f"{hw_info['disk_free'] / (1024**3):.1f} GB",
880
+ })
881
+ device.custom_fields = cf
882
+ device.save()
883
+ except Exception as e:
884
+ print(f" 更新設備硬體資訊失敗 {node_name}: {e}")
885
+ # 同步網路介面
886
+ try:
887
+ network_data = self.pve_api.nodes(pve_node_name).network.get()
888
+ if network_data:
889
+ self.sync_node_network_interfaces(device, pve_node_name, network_data)
890
+ except Exception as e:
891
+ print(f" 獲取節點網路資訊失敗: {e}")
892
+ # Always key by original PVE node name so VM sync can look up the device
893
+ devices[pve_node_name.lower()] = device
894
+ success_count += 1
895
+ print(f" 節點同步完成: {success_count}/{len(self.pve_cache['nodes'])} 個節點")
896
+ if success_count > 0:
897
+ return True, devices, cluster
898
+ else:
899
+ return False, {}, {}
900
+
901
+ # ---------- VM Agent 網路 ----------
902
+ def get_vm_agent_network_info(self, node_name: str, vm_id: int, vm_type: str = 'qemu') -> Tuple[Dict[str, List[Dict]], Dict[str, str]]:
903
+ try:
904
+ if vm_type == 'qemu':
905
+ interfaces = self.pve_api.nodes(node_name).qemu(vm_id).agent('network-get-interfaces').get()
906
+ else:
907
+ return {}, {}
908
+ interface_data = {}
909
+ mac_to_interface = {}
910
+ for iface in interfaces.get('result', []):
911
+ if 'name' in iface and 'hardware-address' in iface:
912
+ iface_name = iface['name']
913
+ mac_address = iface['hardware-address'].lower()
914
+ mac_to_interface[mac_address] = iface_name
915
+ interface_data[iface_name] = iface.get('ip-addresses', [])
916
+ return interface_data, mac_to_interface
917
+ except ResourceException as e:
918
+ if "is not running" in str(e):
919
+ pass
920
+ except Exception:
921
+ pass
922
+ return {}, {}
923
+
924
+ # ---------- MAC 位址設定(NetBox 4.6+ 使用獨立 MACAddress 物件) ----------
925
+ def set_vm_interface_mac(self, vm_interface, mac_address: str):
926
+ """Create/update a dcim.MACAddress for the VMInterface and set it as primary."""
927
+ mac_lower = mac_address.lower()
928
+ existing_mac = self.nb_cache['mac_addresses'].get(mac_lower)
929
+ if existing_mac:
930
+ # Already exists — ensure it's assigned to this interface and set as primary
931
+ needs_save = False
932
+ if getattr(existing_mac, 'assigned_object_id', None) != vm_interface.id:
933
+ existing_mac.assigned_object_type = 'virtualization.vminterface'
934
+ existing_mac.assigned_object_id = vm_interface.id
935
+ existing_mac.save()
936
+ # Set primary_mac_address on the interface if not already set
937
+ current_primary = getattr(vm_interface, 'primary_mac_address', None)
938
+ if not current_primary or (hasattr(current_primary, 'id') and current_primary.id != existing_mac.id):
939
+ vm_interface.primary_mac_address = existing_mac.id
940
+ vm_interface.save()
941
+ else:
942
+ # Create new MACAddress assigned to this VMInterface
943
+ mac_obj = self.nb_api.dcim.mac_addresses.create(
944
+ mac_address=mac_address,
945
+ assigned_object_type='virtualization.vminterface',
946
+ assigned_object_id=vm_interface.id,
947
+ )
948
+ self.nb_cache['mac_addresses'][mac_lower] = mac_obj
949
+ # Set it as primary
950
+ vm_interface.primary_mac_address = mac_obj.id
951
+ vm_interface.save()
952
+
953
+ # ---------- VM 介面處理 ----------
954
+ def process_vm_interfaces(self, vm, vm_config: Dict, agent_interfaces: Dict, mac_to_interface: Dict,
955
+ device) -> Tuple[int, Optional[Any], List[Dict]]:
956
+ interface_count = 0
957
+ primary_ip = None
958
+ interfaces_data = []
959
+ for config_key, config_value in vm_config.items():
960
+ if not config_key.startswith('net'):
961
+ continue
962
+ interface_count += 1
963
+ try:
964
+ config = self.parse_network_config(config_value)
965
+ mac_address = None
966
+ for model in ['virtio', 'e1000', 'vmxnet3', 'rtl8139']:
967
+ if model in config:
968
+ mac_address = config[model]
969
+ break
970
+ # link_down=1 in PVE config means the vNIC cable is disconnected
971
+ enabled = config.get('link_down', '0') != '1'
972
+ vm_interfaces = self.nb_cache['vm_interfaces'].get(vm.id, {})
973
+ bridge_name = config.get('bridge', '')
974
+ # Create the bridge interface on the node device so it's visible in the node's interface list.
975
+ # VMInterface.bridge is a self-referential FK (VMInterface→VMInterface) so we must NOT
976
+ # pass the dcim.Interface id here — just create the node bridge and leave VM bridge unset.
977
+ if bridge_name:
978
+ self.get_or_create_device_interface(
979
+ device.id, bridge_name, iface_type='bridge', mac_address=None, enabled=True
980
+ )
981
+ if config_key in vm_interfaces:
982
+ vm_interface = vm_interfaces[config_key]
983
+ enabled_changed = vm_interface.enabled != enabled
984
+ if enabled_changed:
985
+ vm_interface.enabled = enabled
986
+ if mac_address:
987
+ self.set_vm_interface_mac(vm_interface, mac_address)
988
+ # set_vm_interface_mac only saves the interface in some branches;
989
+ # ensure the enabled change is always persisted
990
+ if enabled_changed:
991
+ vm_interface.save()
992
+ else:
993
+ vm_interface.save()
994
+ else:
995
+ iface_create_data = {
996
+ 'virtual_machine': vm.id, 'name': config_key, 'enabled': enabled
997
+ }
998
+ vm_interface = self.nb_api.virtualization.interfaces.create(**iface_create_data)
999
+ self.nb_cache['vm_interfaces'].setdefault(vm.id, {})[config_key] = vm_interface
1000
+ if mac_address:
1001
+ self.set_vm_interface_mac(vm_interface, mac_address)
1002
+ iface_data = {
1003
+ 'name': config_key, 'mac_address': mac_address or '', 'bridge': bridge_name
1004
+ }
1005
+ interfaces_data.append(iface_data)
1006
+ agent_iface_name = mac_to_interface.get(mac_address.lower()) if mac_address else None
1007
+ if agent_iface_name and agent_iface_name in agent_interfaces:
1008
+ ip_addresses = agent_interfaces[agent_iface_name]
1009
+ for ip_info in ip_addresses:
1010
+ if ip_info['ip-address-type'] == 'ipv4' and not ip_info['ip-address'].startswith('127.'):
1011
+ ip_addr = ip_info['ip-address']
1012
+ prefix_len = ip_info['prefix']
1013
+ full_addr = f"{ip_addr}/{prefix_len}"
1014
+ ip_obj = self.assign_ip_to_interface(
1015
+ vm_interface, full_addr, f"{vm.name}.local", is_vm_interface=True
1016
+ )
1017
+ if ip_obj and not primary_ip:
1018
+ primary_ip = ip_obj
1019
+ except Exception as e:
1020
+ print(f" 處理介面失敗 {config_key}: {e}")
1021
+ return interface_count, primary_ip, interfaces_data
1022
+
1023
+ # ---------- VM 磁碟處理 ----------
1024
+ def process_vm_disks(self, vm, vm_config: Dict) -> Tuple[int, int]:
1025
+ disk_count = 0
1026
+ disk_size = 0
1027
+ for config_key, config_value in vm_config.items():
1028
+ if config_key.startswith(('scsi', 'virtio', 'sata', 'ide', 'efidisk', 'rootfs')):
1029
+ if 'media=cdrom' not in str(config_value):
1030
+ success, size_mb = self.create_virtual_disk(vm, config_key, config_value)
1031
+ if success:
1032
+ disk_count += 1
1033
+ disk_size += size_mb
1034
+ return disk_count, disk_size
1035
+
1036
+ def create_virtual_disk(self, vm, disk_name: str, disk_config: str) -> Tuple[bool, int]:
1037
+ try:
1038
+ config = self.parse_network_config(disk_config)
1039
+ size_str = config.get('size', '0')
1040
+ size_mb = 0
1041
+ try:
1042
+ if size_str.endswith('G'):
1043
+ size_mb = int(size_str[:-1]) * 1024
1044
+ elif size_str.endswith('T'):
1045
+ size_mb = int(size_str[:-1]) * 1024 * 1024
1046
+ elif size_str.endswith('M'):
1047
+ size_mb = int(size_str[:-1])
1048
+ elif size_str.endswith('K'):
1049
+ size_mb = int(size_str[:-1]) // 1024
1050
+ else:
1051
+ size_mb = int(size_str) // (1024 * 1024)
1052
+ except (ValueError, AttributeError):
1053
+ pass
1054
+ if size_mb <= 0:
1055
+ return False, 0
1056
+ vm_disks = self.nb_cache['vm_disks'].get(vm.id, {})
1057
+ if disk_name in vm_disks:
1058
+ disk = vm_disks[disk_name]
1059
+ disk.size = size_mb
1060
+ disk.save()
1061
+ else:
1062
+ disk = self.nb_api.virtualization.virtual_disks.create(
1063
+ virtual_machine=vm.id, name=disk_name, size=size_mb,
1064
+ description=f"Proxmox disk: {disk_name}"
1065
+ )
1066
+ self.nb_cache['vm_disks'].setdefault(vm.id, {})[disk_name] = disk
1067
+ return True, size_mb
1068
+ except Exception as e:
1069
+ print(f" 建立虛擬磁碟失敗 {disk_name}: {e}")
1070
+ return False, 0
1071
+
1072
+ # ---------- 增量同步相關 ----------
1073
+
1074
+ def _parse_disk_summary(self, vm_config: Dict[str, Any]) -> Dict[str, str]:
1075
+ """從 vm_config 提取磁碟 key → size 對應表。"""
1076
+ import re
1077
+ disks = {}
1078
+ for key, value in vm_config.items():
1079
+ if key.startswith(('scsi', 'virtio', 'ide', 'sata', 'efidisk', 'tpmstate')):
1080
+ val_str = str(value)
1081
+ m = re.search(r'size=([^,]+)', val_str)
1082
+ disks[key] = m.group(1) if m else val_str[:80]
1083
+ return disks
1084
+
1085
+ def _write_drift_event(self, vm_name: str, vmid: int, drift_type: str,
1086
+ field_name: str, old_value: str, new_value: str,
1087
+ notified: bool = False):
1088
+ """Write a PveDriftEvent record via Django ORM. No-op outside Django context."""
1089
+ try:
1090
+ from pve_sync_plugin.models import PveDriftEvent
1091
+ kwargs = dict(
1092
+ vm_name=vm_name,
1093
+ vmid=vmid,
1094
+ cluster_name=self.cluster_name,
1095
+ drift_type=drift_type,
1096
+ field_name=field_name,
1097
+ old_value=str(old_value),
1098
+ new_value=str(new_value),
1099
+ notified_telegram=notified,
1100
+ )
1101
+ if self.job_id:
1102
+ kwargs['sync_job_id'] = self.job_id
1103
+ PveDriftEvent.objects.create(**kwargs)
1104
+ except Exception as e:
1105
+ print(f" ⚠ 無法寫入漂移事件記錄: {e}")
1106
+
1107
+ def detect_new_vm(self, vm_id: int, vm_name: str, node_name: str):
1108
+ """若此 vmid 在先前同步中不存在(但叢集有先前記錄),視為新增 VM。"""
1109
+ if not self.enhanced_mode or not self.state_db:
1110
+ return
1111
+ if not self._known_vmids:
1112
+ return # 首次同步,無先前記錄,不觸發
1113
+ if self.state_db.get_last_vm_config(vm_id, self.cluster_name):
1114
+ return # 已有記錄,不是新增
1115
+ print(f"🆕 VM {vm_name} 新增偵測 (ID: {vm_id}, 節點: {node_name})")
1116
+ self.stats['config_drifts_detected'] += 1
1117
+ new_msg = (
1118
+ f"🆕 <b>VM 新增偵測</b>\n\n"
1119
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1120
+ f"🔀 叢集: {self.cluster_name}\n"
1121
+ f"📌 節點: <code>{node_name}</code>\n"
1122
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}"
1123
+ )
1124
+ self.send_telegram_notification(new_msg)
1125
+ self._write_drift_event(vm_name, vm_id, 'vm_created', 'vmid', '', str(vm_id), notified=True)
1126
+
1127
+ def detect_deleted_vms(self, seen_vmids: set):
1128
+ """比對先前已知 vmid 與本次同步的 vmid,偵測被刪除的 VM。"""
1129
+ if not self.enhanced_mode or not self.state_db:
1130
+ return
1131
+ if not self._known_vmids:
1132
+ return
1133
+ deleted_vmids = set(self._known_vmids.keys()) - seen_vmids
1134
+ if not deleted_vmids:
1135
+ return
1136
+ for vmid in deleted_vmids:
1137
+ info = self._known_vmids[vmid]
1138
+ vm_name = info.get('vm_name') or f"VM-{vmid}"
1139
+ node_name = info.get('node') or 'unknown'
1140
+ # 避免重複通知:若已有此 vmid 的 vm_deleted 記錄則跳過
1141
+ try:
1142
+ from pve_sync_plugin.models import PveDriftEvent
1143
+ if PveDriftEvent.objects.filter(
1144
+ vmid=vmid, cluster_name=self.cluster_name, drift_type='vm_deleted'
1145
+ ).exists():
1146
+ continue
1147
+ except Exception:
1148
+ pass
1149
+ print(f"🗑️ VM {vm_name} 刪除偵測 (ID: {vmid}, 上次節點: {node_name})")
1150
+ self.stats['config_drifts_detected'] += 1
1151
+ del_msg = (
1152
+ f"🗑️ <b>VM 刪除偵測</b>\n\n"
1153
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vmid})\n"
1154
+ f"🔀 叢集: {self.cluster_name}\n"
1155
+ f"📌 上次節點: <code>{node_name}</code>\n"
1156
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1157
+ f"⚠️ 此 VM 已從 Proxmox 中移除。"
1158
+ )
1159
+ self.send_telegram_notification(del_msg)
1160
+ self._write_drift_event(vm_name, vmid, 'vm_deleted', 'vmid', str(vmid), '', notified=True)
1161
+
1162
+ def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1163
+ current_tags: List[str], network_interfaces: List[Dict] = None,
1164
+ current_node: str = None) -> List[Dict]:
1165
+ if not self.enhanced_mode or not self.state_db:
1166
+ return []
1167
+ drifts = []
1168
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1169
+ if not last_config:
1170
+ return drifts
1171
+ current_memory = int(vm_config.get('memory', 0))
1172
+ current_vcpus = int(vm_config.get('vcpus',
1173
+ int(vm_config.get('cores', 1)) * int(vm_config.get('sockets', 1))))
1174
+ old_memory = last_config.get('memory', 0)
1175
+ old_vcpus = last_config.get('vcpus', 0)
1176
+ if current_memory != old_memory:
1177
+ drifts.append({'field': 'memory', 'old': old_memory, 'new': current_memory, 'unit': 'MB'})
1178
+ if current_vcpus != old_vcpus:
1179
+ drifts.append({'field': 'vcpus', 'old': old_vcpus, 'new': current_vcpus, 'unit': 'core'})
1180
+
1181
+ # VM 更名偵測
1182
+ old_name = last_config.get('vm_name')
1183
+ if old_name and old_name != vm_name:
1184
+ print(f"✏️ VM 更名偵測: {old_name} → {vm_name} (ID: {vm_id})")
1185
+ self.stats['config_drifts_detected'] += 1
1186
+ rename_msg = (
1187
+ f"✏️ <b>VM 更名偵測</b>\n\n"
1188
+ f"🖥️ 原名稱: <b>{old_name}</b> → <b>{vm_name}</b>\n"
1189
+ f"🔢 ID: {vm_id}\n"
1190
+ f"🔀 叢集: {self.cluster_name}\n"
1191
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}"
1192
+ )
1193
+ self.send_telegram_notification(rename_msg)
1194
+ self._write_drift_event(vm_name, vm_id, 'vm_renamed', 'name', old_name, vm_name, notified=True)
1195
+
1196
+ # VM 遷移偵測(節點變更)
1197
+ old_node = last_config.get('node')
1198
+ if old_node and current_node and old_node != current_node:
1199
+ print(f"🚚 VM {vm_name} 遷移偵測: {old_node} → {current_node}")
1200
+ self.stats['config_drifts_detected'] += 1
1201
+ migration_msg = (
1202
+ f"🚚 <b>VM 遷移偵測</b>\n\n"
1203
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1204
+ f"🔀 叢集: {self.cluster_name}\n"
1205
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1206
+ f"📤 來源節點: <code>{old_node}</code>\n"
1207
+ f"📥 目標節點: <code>{current_node}</code>"
1208
+ )
1209
+ self.send_telegram_notification(migration_msg)
1210
+ self._write_drift_event(vm_name, vm_id, 'migration', 'node', old_node, current_node, notified=True)
1211
+
1212
+ # 磁碟配置變更偵測
1213
+ current_disks = self._parse_disk_summary(vm_config)
1214
+ old_disks = last_config.get('disk_summary') or {}
1215
+ if old_disks: # 只在有先前記錄時比對
1216
+ added_disks = {k: v for k, v in current_disks.items() if k not in old_disks}
1217
+ removed_disks = {k: v for k, v in old_disks.items() if k not in current_disks}
1218
+ resized_disks = {
1219
+ k: (old_disks[k], current_disks[k])
1220
+ for k in current_disks
1221
+ if k in old_disks and old_disks[k] != current_disks[k]
1222
+ }
1223
+ if added_disks or removed_disks or resized_disks:
1224
+ self.stats['config_drifts_detected'] += 1
1225
+ detail = ""
1226
+ for k, v in added_disks.items():
1227
+ detail += f"➕ 新增磁碟: <code>{k}</code> ({v})\n"
1228
+ for k, v in removed_disks.items():
1229
+ detail += f"➖ 移除磁碟: <code>{k}</code> (原 {v})\n"
1230
+ for k, (old_v, new_v) in resized_disks.items():
1231
+ detail += f"🔄 磁碟調整: <code>{k}</code> {old_v} → {new_v}\n"
1232
+ print(f"💾 VM {vm_name} 磁碟配置變更")
1233
+ disk_msg = (
1234
+ f"💾 <b>VM 磁碟配置變更</b>\n\n"
1235
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1236
+ f"🔀 叢集: {self.cluster_name}\n"
1237
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1238
+ f"{detail}"
1239
+ )
1240
+ self.send_telegram_notification(disk_msg)
1241
+ for k, v in added_disks.items():
1242
+ self._write_drift_event(vm_name, vm_id, 'disk_change', k, '', v, notified=True)
1243
+ for k, v in removed_disks.items():
1244
+ self._write_drift_event(vm_name, vm_id, 'disk_change', k, v, '', notified=True)
1245
+ for k, (old_v, new_v) in resized_disks.items():
1246
+ self._write_drift_event(vm_name, vm_id, 'disk_change', k, old_v, new_v, notified=True)
1247
+
1248
+ # VM 描述變更偵測
1249
+ current_desc = (vm_config.get('description') or '').strip()[:500]
1250
+ old_desc = (last_config.get('description') or '').strip()
1251
+ if old_desc and old_desc != current_desc:
1252
+ self.stats['config_drifts_detected'] += 1
1253
+ print(f"📝 VM {vm_name} 描述變更")
1254
+ desc_preview = current_desc[:100] + ('…' if len(current_desc) > 100 else '')
1255
+ desc_msg = (
1256
+ f"📝 <b>VM 描述變更</b>\n\n"
1257
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1258
+ f"🔀 叢集: {self.cluster_name}\n"
1259
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1260
+ f"📄 新描述: <code>{desc_preview}</code>"
1261
+ )
1262
+ self.send_telegram_notification(desc_msg)
1263
+ self._write_drift_event(vm_name, vm_id, 'description_change', 'description',
1264
+ old_desc[:200], current_desc[:200], notified=True)
1265
+
1266
+ if drifts:
1267
+ self.stats['config_drifts_detected'] += len(drifts)
1268
+ print(f"🔄 VM {vm_name} 硬體配置漂移: {len(drifts)} 處變更")
1269
+ detail_lines = ""
1270
+ for drift in drifts:
1271
+ detail_lines += f"• {drift['field']}: {drift['old']} → {drift['new']} {drift.get('unit', '')}\n"
1272
+ hw_msg = (
1273
+ f"🔄 <b>VM 硬體配置變更</b>\n\n"
1274
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1275
+ f"🔀 叢集: {self.cluster_name}\n"
1276
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1277
+ f"{detail_lines}\n請確認是否為預期變更。"
1278
+ )
1279
+ self.send_telegram_notification(hw_msg)
1280
+ for drift in drifts:
1281
+ self._write_drift_event(
1282
+ vm_name, vm_id, 'hardware', drift['field'],
1283
+ f"{drift['old']} {drift.get('unit', '')}".strip(),
1284
+ f"{drift['new']} {drift.get('unit', '')}".strip(),
1285
+ notified=True,
1286
+ )
1287
+ return drifts
1288
+
1289
+ def should_sync_vm(self, vm_id: int, vm_config: Dict[str, Any], tags: List[str],
1290
+ network_interfaces: List[Dict] = None) -> bool:
1291
+ if not self.sync_config.get('incremental', True):
1292
+ return True
1293
+ if self.sync_config.get('force_full_sync', False):
1294
+ return True
1295
+ current_hash = compute_config_hash(vm_config, tags, network_interfaces)
1296
+ if not self.state_db:
1297
+ return True
1298
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1299
+ if not last_config:
1300
+ return True
1301
+ if current_hash != last_config.get('config_hash'):
1302
+ return True
1303
+ return False
1304
+
1305
+ def detect_tag_changes(self, vm_id: int, vm_name: str, current_tags: List[str]) -> List[Dict]:
1306
+ if not self.enhanced_mode or not self.state_db:
1307
+ return []
1308
+ changes = []
1309
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1310
+ if not last_config:
1311
+ return changes
1312
+ old_tags = set(last_config.get('tags', []))
1313
+ new_tags = set(current_tags)
1314
+ added = new_tags - old_tags
1315
+ removed = old_tags - new_tags
1316
+ for tag in added:
1317
+ changes.append({'type': 'added', 'tag': tag})
1318
+ self.stats['tag_changes'] += 1
1319
+ print(f"🏷️ VM {vm_name} 新增標籤: {tag}")
1320
+ self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', '', tag, notified=False)
1321
+ for tag in removed:
1322
+ changes.append({'type': 'removed', 'tag': tag})
1323
+ self.stats['tag_changes'] += 1
1324
+ print(f"🏷️ VM {vm_name} 移除標籤: {tag}")
1325
+ self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', tag, '', notified=False)
1326
+ if changes:
1327
+ added_list = [c['tag'] for c in changes if c['type'] == 'added']
1328
+ removed_list = [c['tag'] for c in changes if c['type'] == 'removed']
1329
+ detail = ""
1330
+ if added_list:
1331
+ detail += f"➕ 新增標籤: <code>{', '.join(added_list)}</code>\n"
1332
+ if removed_list:
1333
+ detail += f"➖ 移除標籤: <code>{', '.join(removed_list)}</code>\n"
1334
+ tag_msg = (
1335
+ f"🏷️ <b>VM 標籤變更</b>\n\n"
1336
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1337
+ f"🔀 叢集: {self.cluster_name}\n"
1338
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1339
+ f"{detail}\n請確認是否為預期變更。"
1340
+ )
1341
+ self.send_telegram_notification(tag_msg)
1342
+ return changes
1343
+
1344
+ def detect_ip_change(self, vm_id: int, vm_name: str, current_ip: str):
1345
+ """Compare current primary IP to state_db snapshot; notify and record if changed."""
1346
+ if not self.enhanced_mode or not self.state_db:
1347
+ return
1348
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1349
+ if not last_config:
1350
+ return
1351
+ old_ip = last_config.get('primary_ip') or ''
1352
+ if not old_ip or old_ip == current_ip:
1353
+ return
1354
+ print(f"🌐 VM {vm_name} IP 變更: {old_ip} → {current_ip}")
1355
+ self.stats['config_drifts_detected'] += 1
1356
+ ip_msg = (
1357
+ f"🌐 <b>VM IP 位址變更</b>\n\n"
1358
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1359
+ f"🔀 叢集: {self.cluster_name}\n"
1360
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1361
+ f"🔄 舊 IP: <code>{old_ip}</code>\n"
1362
+ f"🔄 新 IP: <code>{current_ip}</code>\n\n"
1363
+ f"請確認是否為預期變更。"
1364
+ )
1365
+ self.send_telegram_notification(ip_msg)
1366
+ self._write_drift_event(vm_name, vm_id, 'ip_change', 'primary_ip', old_ip, current_ip, notified=True)
1367
+
1368
+ # ---------- 虛擬機處理主邏輯 ----------
1369
+ def process_virtual_machine(self, vm_data: Dict, device, cluster: Dict, force: bool = False) -> bool:
1370
+ vm_id = str(vm_data['vmid'])
1371
+ original_vm_name = vm_data['name']
1372
+ vm_type = vm_data.get('type', 'qemu')
1373
+ node_name = vm_data['node']
1374
+ vm_name = self.get_unique_vm_name(original_vm_name, vm_id, cluster['id'])
1375
+ print(f"處理虛擬機: {original_vm_name} (ID: {vm_id}, 類型: {vm_type.upper()}) -> {vm_name}")
1376
+ try:
1377
+ if 'config' in vm_data:
1378
+ vm_config = vm_data['config']
1379
+ else:
1380
+ if vm_type == 'qemu':
1381
+ vm_config = self.pve_api.nodes(node_name).qemu(vm_data['vmid']).config.get()
1382
+ else:
1383
+ vm_config = self.pve_api.nodes(node_name).lxc(vm_data['vmid']).config.get()
1384
+ except Exception as e:
1385
+ print(f" 獲取 VM 配置失敗: {e}")
1386
+ return False
1387
+ is_template = vm_data.get('is_template', False) or vm_config.get('template', 0) == 1
1388
+ boot_choice = 'on' if vm_config.get('onboot', 0) == 1 else 'off'
1389
+ tag_ids = []
1390
+ tag_names = []
1391
+ if 'tags' in vm_config and vm_config['tags']:
1392
+ tag_list = vm_config['tags'].split(';')
1393
+ for tag_name in tag_list:
1394
+ tag_name = tag_name.strip()
1395
+ if tag_name:
1396
+ tag_names.append(tag_name)
1397
+ if tag_name in self.nb_cache['tags']:
1398
+ tag_ids.append(self.nb_cache['tags'][tag_name].id)
1399
+ else:
1400
+ try:
1401
+ slug = tag_name.lower().replace(' ', '-').replace('/', '-')
1402
+ tag = self.nb_api.extras.tags.create(
1403
+ name=tag_name, slug=slug[:50], description=f"Proxmox tag: {tag_name}"
1404
+ )
1405
+ self.nb_cache['tags'][tag_name] = tag
1406
+ tag_ids.append(tag.id)
1407
+ except Exception as e:
1408
+ print(f" 建立標籤失敗 {tag_name}: {e}")
1409
+ if self.enhanced_mode:
1410
+ self.detect_new_vm(int(vm_id), original_vm_name, node_name)
1411
+ if self.enhanced_mode and tag_names:
1412
+ self.detect_tag_changes(int(vm_id), original_vm_name, tag_names)
1413
+ vm_status = vm_data.get('status', '')
1414
+ if tag_names and vm_status in ('running', 'stopped'):
1415
+ first_tag = tag_names[0].lower()
1416
+ node_name = vm_data.get('node', 'unknown')
1417
+ if first_tag == '0n' and vm_status == 'stopped':
1418
+ message = f"""
1419
+ ⚠️ <b>虛擬機意外關機</b>
1420
+
1421
+ 🖥️ 名稱: <b>{original_vm_name}</b> (ID: {vm_id})
1422
+ 📌 節點: {node_name}
1423
+ 🏷️ 期望狀態: <code>ON</code>
1424
+ 🔴 目前狀態: <b>stopped</b>
1425
+
1426
+ ❌ 虛擬機應處於開機狀態,請立即檢查。
1427
+ """
1428
+ self.send_telegram_notification(message)
1429
+ elif first_tag == '0ff' and vm_status == 'running':
1430
+ message = f"""
1431
+ ⚠️ <b>虛擬機意外開機</b>
1432
+
1433
+ 🖥️ 名稱: <b>{original_vm_name}</b> (ID: {vm_id})
1434
+ 📌 節點: {node_name}
1435
+ 🏷️ 期望狀態: <code>OFF</code>
1436
+ 🟢 目前狀態: <b>running</b>
1437
+
1438
+ ⚠️ 虛擬機應處於關機狀態,請確認開機原因。
1439
+ """
1440
+ self.send_telegram_notification(message)
1441
+ role_id = None
1442
+ vm_pool = self.get_vm_pool(vm_data['vmid'], vm_type)
1443
+ if vm_pool:
1444
+ role_id = self.get_or_create_vm_role(vm_pool)
1445
+ print(f" 使用 PVE Pool 作為角色: {vm_pool}")
1446
+ else:
1447
+ role_id = self.get_or_create_vm_role('Virtual Machine' if vm_type == 'qemu' else 'Container')
1448
+ agent_interfaces = {}
1449
+ mac_to_interface = {}
1450
+ if vm_type == 'qemu' and not is_template and vm_data.get('status') == 'running':
1451
+ agent_interfaces, mac_to_interface = self.get_vm_agent_network_info(node_name, vm_data['vmid'], vm_type)
1452
+ platform_id = None
1453
+ ostype = vm_config.get('ostype')
1454
+ if ostype:
1455
+ platform_key = ostype.lower()
1456
+ if platform_key in self.nb_cache['platforms']:
1457
+ platform_id = self.nb_cache['platforms'][platform_key].id
1458
+ else:
1459
+ try:
1460
+ platform = self.nb_api.dcim.platforms.create(
1461
+ name=ostype, slug=ostype.lower().replace(' ', '-').replace('/', '-')[:50],
1462
+ description=f"Proxmox OS Type: {ostype}"
1463
+ )
1464
+ self.nb_cache['platforms'][platform_key] = platform
1465
+ platform_id = platform.id
1466
+ except Exception as e:
1467
+ print(f" 建立平台失敗 {ostype}: {e}")
1468
+ if 'vcpus' in vm_config:
1469
+ vcpus = int(vm_config['vcpus'])
1470
+ else:
1471
+ cores = int(vm_config.get('cores', 1))
1472
+ sockets = int(vm_config.get('sockets', 1))
1473
+ vcpus = cores * sockets
1474
+ if is_template:
1475
+ status = 'staged'
1476
+ elif vm_data.get('status') == 'running':
1477
+ status = 'active'
1478
+ else:
1479
+ status = 'offline'
1480
+ qemu_agent_enabled = False
1481
+ if vm_type == 'qemu' and 'agent' in vm_config:
1482
+ qemu_agent_enabled = self.check_qemu_agent(vm_config)
1483
+ network_interfaces = []
1484
+ for key, value in vm_config.items():
1485
+ if key.startswith('net'):
1486
+ net_cfg = self.parse_network_config(value)
1487
+ network_interfaces.append({
1488
+ 'name': key,
1489
+ 'mac_address': net_cfg.get('virtio') or net_cfg.get('e1000') or net_cfg.get('vmxnet3') or net_cfg.get('rtl8139') or '',
1490
+ 'bridge': net_cfg.get('bridge', ''),
1491
+ 'gateway': ''
1492
+ })
1493
+ if self.enhanced_mode:
1494
+ self.detect_config_drift(int(vm_id), original_vm_name, vm_config, tag_names,
1495
+ network_interfaces, current_node=node_name)
1496
+ if not force and not self.should_sync_vm(int(vm_id), vm_config, tag_names, network_interfaces):
1497
+ cached_vm = self.nb_cache['virtual_machines_by_serial'].get(f"{vm_id}::{cluster['id']}")
1498
+ if cached_vm is None:
1499
+ # VM 在 NetBox 中不存在(例如隨節點被連帶刪除),
1500
+ # 但 state_db 仍有舊 hash 導致增量跳過。強制往下完整同步。
1501
+ print(f" ⚠️ VM {original_vm_name} 不在 NetBox 中,忽略增量記錄強制建立")
1502
+ else:
1503
+ # VM 存在;確保 device 關聯指向正確節點
1504
+ current_device_id = getattr(getattr(cached_vm, 'device', None), 'id', None)
1505
+ current_cluster_id = getattr(getattr(cached_vm, 'cluster', None), 'id', None)
1506
+ fix = {}
1507
+ if current_device_id != device.id:
1508
+ fix['device'] = device.id
1509
+ if current_cluster_id != cluster['id']:
1510
+ fix['cluster'] = cluster['id']
1511
+ if fix:
1512
+ try:
1513
+ cached_vm.update(fix)
1514
+ if 'cluster' in fix:
1515
+ print(f" ✓ VM {original_vm_name} 叢集關聯已修正: → {cluster['name']}")
1516
+ if 'device' in fix:
1517
+ print(f" ✓ VM {original_vm_name} 設備關聯已修正: → {device.name}")
1518
+ except Exception as e:
1519
+ print(f" ✗ 更新 VM {original_vm_name} 關聯失敗: {e}")
1520
+ print(f" ℹ️ VM {original_vm_name} 無配置變更,跳過同步")
1521
+ if self.state_db:
1522
+ try:
1523
+ config_hash = compute_config_hash(vm_config, tag_names, network_interfaces)
1524
+ memory = int(vm_config.get('memory', 0))
1525
+ vcpus_save = int(vm_config.get('vcpus', vcpus))
1526
+ cur_ip = getattr(getattr(cached_vm, 'primary_ip4', None), 'address', None) or ''
1527
+ self.state_db.save_vm_config_snapshot(
1528
+ vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1529
+ memory=memory, vcpus=vcpus_save, tags=tag_names,
1530
+ node=node_name, primary_ip=cur_ip, vm_name=original_vm_name,
1531
+ description=(vm_config.get('description') or '').strip()[:500],
1532
+ disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1533
+ )
1534
+ except Exception as _snap_err:
1535
+ print(f" ⚠ 跳過快照更新失敗: {_snap_err}")
1536
+ return True
1537
+ # cached_vm is None → 繼續往下完整建立此 VM
1538
+ custom_fields = {}
1539
+ if self.custom_fields_created:
1540
+ # 清理衝突的 vm_id
1541
+ conflicting_vms = []
1542
+ for nb_vm in self.nb_cache['virtual_machines'].values():
1543
+ if hasattr(nb_vm, 'custom_fields') and nb_vm.custom_fields:
1544
+ existing_vm_id = nb_vm.custom_fields.get('vm_id')
1545
+ if existing_vm_id == vm_data['vmid']:
1546
+ is_same_vm = False
1547
+ if nb_vm.serial and str(nb_vm.serial) == vm_id:
1548
+ is_same_vm = True
1549
+ elif nb_vm.name == vm_name or nb_vm.name == original_vm_name:
1550
+ is_same_vm = True
1551
+ if not is_same_vm:
1552
+ conflicting_vms.append(nb_vm)
1553
+ if conflicting_vms:
1554
+ print(f" 警告: 發現 {len(conflicting_vms)} 個其他虛擬機使用了相同的 vm_id ({vm_id}):")
1555
+ for conflict_vm in conflicting_vms:
1556
+ print(f" - {conflict_vm.name} (ID: {conflict_vm.id})")
1557
+ for conflict_vm in conflicting_vms:
1558
+ try:
1559
+ current_custom_fields = conflict_vm.custom_fields.copy() if conflict_vm.custom_fields else {}
1560
+ if 'vm_id' in current_custom_fields:
1561
+ del current_custom_fields['vm_id']
1562
+ conflict_vm.custom_fields = current_custom_fields
1563
+ conflict_vm.save()
1564
+ print(f" 已從虛擬機 {conflict_vm.name} 中清除 vm_id")
1565
+ except Exception as e:
1566
+ print(f" 清除虛擬機 {conflict_vm.name} 的 vm_id 失敗: {e}")
1567
+ custom_fields = {
1568
+ 'vm_id': vm_data['vmid'],
1569
+ 'qemu_agent': qemu_agent_enabled,
1570
+ 'ha': False,
1571
+ 'replicated': False,
1572
+ 'machine_type': vm_config.get('machine', '')
1573
+ }
1574
+ try:
1575
+ existing_vm = None
1576
+ serial_key = f"{vm_id}::{cluster['id']}"
1577
+ if serial_key in self.nb_cache['virtual_machines_by_serial']:
1578
+ existing_vm = self.nb_cache['virtual_machines_by_serial'][serial_key]
1579
+ if existing_vm is None:
1580
+ key = f"{vm_name.lower()}::{cluster['id']}"
1581
+ if key in self.nb_cache['virtual_machines_by_name']:
1582
+ existing_vm = self.nb_cache['virtual_machines_by_name'][key]
1583
+ if existing_vm:
1584
+ print(f" 更新現有虛擬機: {existing_vm.name}")
1585
+ update_data = {
1586
+ 'name': vm_name, 'cluster': cluster['id'], 'device': device.id, 'role': role_id,
1587
+ 'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)), 'status': status,
1588
+ 'description': (vm_config.get('description') or '')[:200], 'platform': platform_id,
1589
+ 'start_on_boot': boot_choice
1590
+ }
1591
+ if tag_ids:
1592
+ update_data['tags'] = tag_ids
1593
+ if custom_fields:
1594
+ update_data['custom_fields'] = custom_fields
1595
+ existing_vm.update(update_data)
1596
+ vm_obj = existing_vm
1597
+ if existing_vm.name != vm_name:
1598
+ old_key = f"{existing_vm.name.lower()}::{cluster['id']}"
1599
+ if old_key in self.nb_cache['virtual_machines_by_name']:
1600
+ del self.nb_cache['virtual_machines_by_name'][old_key]
1601
+ new_key = f"{vm_name.lower()}::{cluster['id']}"
1602
+ self.nb_cache['virtual_machines_by_name'][new_key] = existing_vm
1603
+ else:
1604
+ vm_data_dict = {
1605
+ 'serial': vm_id, 'name': vm_name, 'cluster': cluster['id'], 'device': device.id,
1606
+ 'role': role_id, 'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)),
1607
+ 'status': status, 'description': (vm_config.get('description') or '')[:200],
1608
+ 'platform': platform_id, 'start_on_boot': boot_choice
1609
+ }
1610
+ if tag_ids:
1611
+ vm_data_dict['tags'] = tag_ids
1612
+ if custom_fields:
1613
+ vm_data_dict['custom_fields'] = custom_fields
1614
+ vm_obj = self.nb_api.virtualization.virtual_machines.create(**vm_data_dict)
1615
+ self.nb_cache['virtual_machines'][vm_obj.id] = vm_obj
1616
+ self.nb_cache['virtual_machines_by_serial'][f"{vm_id}::{cluster['id']}"] = vm_obj
1617
+ key = f"{vm_name.lower()}::{cluster['id']}"
1618
+ self.nb_cache['virtual_machines_by_name'][key] = vm_obj
1619
+ print(f" 建立虛擬機: {vm_name}")
1620
+ # 處理介面(傳入device)
1621
+ interface_count, primary_ip, interfaces_data = self.process_vm_interfaces(
1622
+ vm_obj, vm_config, agent_interfaces, mac_to_interface, device
1623
+ )
1624
+ disk_count, disk_size = self.process_vm_disks(vm_obj, vm_config)
1625
+ primary_ip_str = ''
1626
+ if primary_ip:
1627
+ try:
1628
+ vm_obj.primary_ip4 = primary_ip.id
1629
+ vm_obj.save()
1630
+ primary_ip_str = getattr(primary_ip, 'address', '') or ''
1631
+ except Exception as e:
1632
+ print(f" 設定 VM 主 IP 失敗: {e}")
1633
+ if self.enhanced_mode and primary_ip_str:
1634
+ self.detect_ip_change(int(vm_id), original_vm_name, primary_ip_str)
1635
+ print(f" 標籤: {len(tag_ids)}個, 介面: {interface_count}個, 磁碟: {disk_count}個, 大小: {disk_size}MB")
1636
+ if self.enhanced_mode and self.state_db:
1637
+ config_hash = compute_config_hash(vm_config, tag_names, network_interfaces)
1638
+ memory = int(vm_config.get('memory', 0))
1639
+ vcpus_save = int(vm_config.get('vcpus', vcpus))
1640
+ self.state_db.save_vm_config_snapshot(
1641
+ vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1642
+ memory=memory, vcpus=vcpus_save, tags=tag_names,
1643
+ node=node_name, primary_ip=primary_ip_str, vm_name=original_vm_name,
1644
+ description=(vm_config.get('description') or '').strip()[:500],
1645
+ disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1646
+ )
1647
+ return True
1648
+ except Exception as e:
1649
+ error_msg = str(e)
1650
+ print(f" 處理虛擬機失敗: {error_msg}")
1651
+ # 即使 NetBox 同步失敗,仍需更新 snapshot 的非 hash 欄位(vm_name, node,
1652
+ # disk_summary, description, tags, memory, vcpus),並保留舊 config_hash,
1653
+ # 使下一輪不重複發送漂移通知,同時 should_sync_vm() 仍會返回 True 繼續重試。
1654
+ if self.enhanced_mode and self.state_db:
1655
+ try:
1656
+ _lc = self.state_db.get_last_vm_config(int(vm_id), self.cluster_name)
1657
+ if _lc:
1658
+ _ip = locals().get('primary_ip_str') or _lc.get('primary_ip') or ''
1659
+ self.state_db.save_vm_config_snapshot(
1660
+ vm_id=int(vm_id),
1661
+ cluster_name=self.cluster_name,
1662
+ config_hash=_lc.get('config_hash'),
1663
+ memory=int(vm_config.get('memory', 0)),
1664
+ vcpus=int(vm_config.get('vcpus', vcpus)),
1665
+ tags=tag_names,
1666
+ node=node_name,
1667
+ primary_ip=_ip,
1668
+ vm_name=original_vm_name,
1669
+ description=(vm_config.get('description') or '').strip()[:500],
1670
+ disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1671
+ )
1672
+ except Exception:
1673
+ pass
1674
+ return False
1675
+
1676
+ # ---------- 批量同步VM ----------
1677
+ def sync_pve_virtual_machines(self, devices: Dict[str, Any], cluster: Dict) -> Tuple[bool, int, int]:
1678
+ print("\n開始同步虛擬機...")
1679
+ success_count = 0
1680
+ total_count = 0
1681
+ # 載入先前已知 vmid,用於偵測新增/刪除
1682
+ if self.enhanced_mode and self.state_db:
1683
+ self._known_vmids = self.state_db.get_known_vmids(self.cluster_name)
1684
+ seen_vmids: set = set()
1685
+ for node_name, vms in self.pve_cache['vms_by_node'].items():
1686
+ print(f"\n處理節點 {node_name} 的虛擬機:")
1687
+ print(f" 發現 {len(vms)} 個虛擬機")
1688
+ device = devices.get(node_name.lower())
1689
+ if not device:
1690
+ print(f" ✗ 找不到對應的設備: {node_name}")
1691
+ continue
1692
+ node_is_new = node_name.lower() in self._newly_created_nodes
1693
+ if node_is_new:
1694
+ print(f" ℹ️ 節點 {node_name} 為新建,強制全量同步所有 VM")
1695
+ for vm in vms:
1696
+ total_count += 1
1697
+ vm['node'] = node_name
1698
+ seen_vmids.add(int(vm['vmid']))
1699
+ if self.process_virtual_machine(vm, device, cluster, force=node_is_new):
1700
+ success_count += 1
1701
+ # 偵測本次同步消失的 VM(已從 PVE 刪除)
1702
+ self.detect_deleted_vms(seen_vmids)
1703
+ print(f"\n虛擬機同步完成: {success_count}/{total_count} 個虛擬機")
1704
+ return success_count > 0, success_count, total_count
1705
+
1706
+ # ---------- 主同步 ----------
1707
+ def sync(self):
1708
+ print("開始優化的 PVE 到 NetBox 同步")
1709
+ print("=" * 50)
1710
+ if self.enhanced_mode and self.state_db:
1711
+ self.sync_log_id = self.state_db.start_sync_log(self.cluster_name)
1712
+ sync_mode = "增量" if self.sync_config.get('incremental', True) else "全量"
1713
+ start_message = f"""
1714
+ 🔄 <b>PVE-NetBox 同步開始</b>
1715
+
1716
+ 📅 時間: {time.strftime("%Y-%m-%d %H:%M:%S")}
1717
+ 🚀 集群: {self.cluster_name}
1718
+ ⏳ 模式: {sync_mode}
1719
+ """
1720
+ self.send_telegram_notification(start_message)
1721
+ start_time = time.time()
1722
+ self.connect_pve()
1723
+ if not self.connect_netbox():
1724
+ return
1725
+ self.load_all_netbox_objects()
1726
+ if not self.check_required_custom_fields():
1727
+ print("\n同步中止。")
1728
+ return
1729
+ self.load_pve_data()
1730
+ self.show_summary()
1731
+ nodes_success, devices, cluster = self.sync_pve_nodes_to_netbox()
1732
+ if nodes_success and devices and cluster:
1733
+ print("\n" + "=" * 50)
1734
+ print("✓ 節點同步成功")
1735
+ vms_success, success_count, total_count = self.sync_pve_virtual_machines(devices, cluster)
1736
+ elapsed = time.time() - start_time
1737
+ self.stats['elapsed_time'] = elapsed
1738
+ self.stats['total_vms'] = total_count
1739
+ self.stats['success_vms'] = success_count
1740
+ if self.enhanced_mode and self.state_db and hasattr(self, 'sync_log_id'):
1741
+ status = 'success' if vms_success else 'partial'
1742
+ self.state_db.update_sync_log(self.sync_log_id, success_count, total_count, status)
1743
+ self.send_enhanced_summary()
1744
+ if vms_success:
1745
+ print("✓ 虛擬機同步成功")
1746
+ else:
1747
+ print("⚠ 虛擬機同步部分失敗")
1748
+ else:
1749
+ elapsed = time.time() - start_time
1750
+ print(f"\n同步失敗,總耗時: {elapsed:.2f} 秒")
1751
+ print("✗ 節點同步失敗")
1752
+ failure_message = f"""
1753
+ ❌ <b>PVE-NetBox 同步失敗</b>
1754
+
1755
+ 📅 時間: {time.strftime("%Y-%m-%d %H:%M:%S")}
1756
+ ⏱️ 耗時: {elapsed:.2f} 秒
1757
+ ❌ 原因: 節點同步失敗
1758
+ """
1759
+ self.send_telegram_notification(failure_message)
1760
+
1761
+ def show_summary(self):
1762
+ print("\n" + "="*50)
1763
+ print("同步摘要")
1764
+ print("="*50)
1765
+ print(f"PVE 節點: {len(self.pve_cache['nodes'])} 個")
1766
+ print(f"PVE Pools: {len(self.pve_cache['pools'])} 個")
1767
+ total_vms = sum(len(vms) for vms in self.pve_cache['vms_by_node'].values())
1768
+ qemu_count = 0
1769
+ lxc_count = 0
1770
+ for node_name, vms in self.pve_cache['vms_by_node'].items():
1771
+ for vm in vms:
1772
+ if vm.get('type') == 'qemu':
1773
+ qemu_count += 1
1774
+ elif vm.get('type') == 'lxc':
1775
+ lxc_count += 1
1776
+ print(f"PVE 虛擬機: {total_vms} 個 (QEMU: {qemu_count}, LXC: {lxc_count})")
1777
+ print(f"\nNetBox 快取統計:")
1778
+ print(f" 設備: {len(self.nb_cache['devices'])} 個")
1779
+ print(f" 虛擬機: {len(self.nb_cache['virtual_machines'])} 個")
1780
+ print(f" 標籤: {len(self.nb_cache['tags'])} 個")
1781
+ print(f" 角色: {len(self.nb_cache['roles'])} 個")
1782
+ print(f" IP位址: {len(self.nb_cache['ip_addresses'])} 個")
1783
+ print("="*50)
1784
+
1785
+
1786
+ def main():
1787
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
1788
+ required_env_vars = [
1789
+ 'PVE_API_HOST', 'PVE_API_USER', 'PVE_API_TOKEN', 'PVE_API_SECRET',
1790
+ 'NB_API_URL', 'NB_API_TOKEN', 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID',
1791
+ ]
1792
+ missing_vars = [var for var in required_env_vars if var not in os.environ]
1793
+ if missing_vars:
1794
+ print(f"✗ 缺少必要環境變數: {', '.join(missing_vars)}")
1795
+ print("請設定以下環境變數:")
1796
+ for var in required_env_vars:
1797
+ print(f" export {var}=value")
1798
+ sys.exit(1)
1799
+ sync = OptimizedPVEToNetBoxSync()
1800
+ sync.sync()
1801
+
1802
+
1803
+ if __name__ == '__main__':
1804
+ main()