proxsync 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- config.py +337 -0
- pbs_sync.py +244 -0
- proxsync-2.0.0.dist-info/METADATA +468 -0
- proxsync-2.0.0.dist-info/RECORD +59 -0
- proxsync-2.0.0.dist-info/WHEEL +5 -0
- proxsync-2.0.0.dist-info/top_level.txt +5 -0
- pve_sync.py +1804 -0
- pve_sync_plugin/__init__.py +46 -0
- pve_sync_plugin/api/__init__.py +1 -0
- pve_sync_plugin/api/serializers.py +161 -0
- pve_sync_plugin/api/urls.py +27 -0
- pve_sync_plugin/api/views.py +94 -0
- pve_sync_plugin/apps.py +5 -0
- pve_sync_plugin/choices.py +57 -0
- pve_sync_plugin/filtersets.py +137 -0
- pve_sync_plugin/forms.py +219 -0
- pve_sync_plugin/management/__init__.py +1 -0
- pve_sync_plugin/management/commands/__init__.py +1 -0
- pve_sync_plugin/management/commands/pve_sync.py +182 -0
- pve_sync_plugin/management/commands/run_scheduled_syncs.py +109 -0
- pve_sync_plugin/migrations/0001_initial.py +234 -0
- pve_sync_plugin/migrations/0002_pvepluginsettings.py +42 -0
- pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +148 -0
- pve_sync_plugin/migrations/0004_model_verbose_names.py +34 -0
- pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +111 -0
- pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +124 -0
- pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +26 -0
- pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +26 -0
- pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +39 -0
- pve_sync_plugin/migrations/0010_pvedriftevent.py +62 -0
- pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +19 -0
- pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +39 -0
- pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +19 -0
- pve_sync_plugin/migrations/__init__.py +1 -0
- pve_sync_plugin/models.py +461 -0
- pve_sync_plugin/navigation.py +94 -0
- pve_sync_plugin/signals.py +24 -0
- pve_sync_plugin/sync/__init__.py +8 -0
- pve_sync_plugin/sync/config_bridge.py +221 -0
- pve_sync_plugin/sync/engine.py +50 -0
- pve_sync_plugin/tables.py +278 -0
- pve_sync_plugin/tasks.py +440 -0
- pve_sync_plugin/template_content.py +44 -0
- pve_sync_plugin/templates/pve_sync/dashboard.html +400 -0
- pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +7 -0
- pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +69 -0
- pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +27 -0
- pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +52 -0
- pve_sync_plugin/templates/pve_sync/pvedriftevent.html +89 -0
- pve_sync_plugin/templates/pve_sync/pvesyncjob.html +197 -0
- pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +42 -0
- pve_sync_plugin/templates/pve_sync/settings.html +79 -0
- pve_sync_plugin/templatetags/__init__.py +1 -0
- pve_sync_plugin/templatetags/pve_sync_tags.py +86 -0
- pve_sync_plugin/urls.py +186 -0
- pve_sync_plugin/utils.py +84 -0
- pve_sync_plugin/view_helpers.py +98 -0
- pve_sync_plugin/views.py +535 -0
- state_db.py +543 -0
pve_sync.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()
|