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
config.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
配置管理模块 (Config Manager)
|
|
3
|
+
支持 YAML 配置文件、环境变量覆盖、热重载
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import yaml
|
|
8
|
+
import threading
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Any, Optional, List, Union
|
|
12
|
+
from watchdog.observers import Observer
|
|
13
|
+
from watchdog.events import FileSystemEventHandler
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigChangeHandler(FileSystemEventHandler):
|
|
18
|
+
"""配置文件变更处理器"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config_manager):
|
|
21
|
+
self.config_manager = config_manager
|
|
22
|
+
|
|
23
|
+
def on_modified(self, event):
|
|
24
|
+
if not event.is_directory and event.src_path == self.config_manager.config_file:
|
|
25
|
+
print(f"🔄 配置文件已变更: {event.src_path}")
|
|
26
|
+
self.config_manager.reload()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigManager:
|
|
30
|
+
"""配置管理器 - 支持热重载"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config_file: str = "/etc/pve-sync/config.yaml"):
|
|
33
|
+
"""
|
|
34
|
+
初始化配置管理器
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
config_file: YAML 配置文件路径
|
|
38
|
+
"""
|
|
39
|
+
self.config_file = Path(config_file)
|
|
40
|
+
self.config: Dict[str, Any] = {}
|
|
41
|
+
self._lock = threading.RLock()
|
|
42
|
+
self._observer = None
|
|
43
|
+
self._enabled = True # 控制热重载是否启用
|
|
44
|
+
|
|
45
|
+
# 首次加载
|
|
46
|
+
self._load_config()
|
|
47
|
+
self._start_watcher()
|
|
48
|
+
|
|
49
|
+
def _load_config(self):
|
|
50
|
+
"""加载配置文件(线程安全)"""
|
|
51
|
+
with self._lock:
|
|
52
|
+
if not self.config_file.exists():
|
|
53
|
+
# 如果配置文件不存在,使用默认值 + 环境变量
|
|
54
|
+
self.config = self._build_default_config()
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(self.config_file, 'r', encoding='utf-8') as f:
|
|
59
|
+
yaml_config = yaml.safe_load(f) or {}
|
|
60
|
+
|
|
61
|
+
# 环境变量覆盖机制
|
|
62
|
+
self.config = self._apply_env_overrides(yaml_config)
|
|
63
|
+
print(f"✓ 配置已加载: {self.config_file}")
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"✗ 配置加载失败: {e}")
|
|
67
|
+
# 保留旧配置(如果存在)
|
|
68
|
+
if not self.config:
|
|
69
|
+
self.config = self._build_default_config()
|
|
70
|
+
|
|
71
|
+
def _build_default_config(self) -> Dict[str, Any]:
|
|
72
|
+
"""从环境变量构建默认配置"""
|
|
73
|
+
return {
|
|
74
|
+
'clusters': [
|
|
75
|
+
{
|
|
76
|
+
'name': 'default',
|
|
77
|
+
'pve': {
|
|
78
|
+
'host': os.getenv('PVE_API_HOST', ''),
|
|
79
|
+
'user': os.getenv('PVE_API_USER', ''),
|
|
80
|
+
'token': os.getenv('PVE_API_TOKEN', ''),
|
|
81
|
+
'secret': os.getenv('PVE_API_SECRET', ''),
|
|
82
|
+
'verify_ssl': os.getenv('PVE_API_VERIFY_SSL', 'false').lower() == 'true'
|
|
83
|
+
},
|
|
84
|
+
'netbox': {
|
|
85
|
+
'url': os.getenv('NB_API_URL', ''),
|
|
86
|
+
'token': os.getenv('NB_API_TOKEN', '')
|
|
87
|
+
},
|
|
88
|
+
'settings': {
|
|
89
|
+
'cluster_name': os.getenv('NB_CLUSTER_NAME', 'Proxmox Cluster'),
|
|
90
|
+
'site_name': os.getenv('NB_SITE_NAME', 'Main Datacenter'),
|
|
91
|
+
'cluster_type': os.getenv('NB_CLUSTER_TYPE', 'Proxmox')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
'telegram': {
|
|
96
|
+
'enabled': bool(os.getenv('TELEGRAM_BOT_TOKEN') and os.getenv('TELEGRAM_CHAT_ID')),
|
|
97
|
+
'bot_token': os.getenv('TELEGRAM_BOT_TOKEN', ''),
|
|
98
|
+
'chat_id': os.getenv('TELEGRAM_CHAT_ID', '')
|
|
99
|
+
},
|
|
100
|
+
'monitoring': {
|
|
101
|
+
'node_offline_alert': True,
|
|
102
|
+
'config_drift_alert': True,
|
|
103
|
+
'resource_alert': {
|
|
104
|
+
'enabled': True,
|
|
105
|
+
'memory_threshold': 85, # %
|
|
106
|
+
'cpu_threshold': 90, # %
|
|
107
|
+
'disk_threshold': 10, # % free space
|
|
108
|
+
'check_interval_hours': 6
|
|
109
|
+
},
|
|
110
|
+
'tag_change_alert': True
|
|
111
|
+
},
|
|
112
|
+
'sync': {
|
|
113
|
+
'incremental': True,
|
|
114
|
+
'force_full_sync': False,
|
|
115
|
+
'batch_size': 50, # 批量处理 VM 的数量
|
|
116
|
+
'node_batch_size': 10 # 预加载每个节点的 VM 配置数量
|
|
117
|
+
},
|
|
118
|
+
'webhook': {
|
|
119
|
+
'enabled': False,
|
|
120
|
+
'host': '0.0.0.0',
|
|
121
|
+
'port': 8080,
|
|
122
|
+
'secret': os.getenv('WEBHOOK_SECRET', ''), # 签名验证密钥
|
|
123
|
+
'rate_limit': 10 # 每秒最多处理的事件数
|
|
124
|
+
},
|
|
125
|
+
'plugins': {
|
|
126
|
+
'enabled': False,
|
|
127
|
+
'netbox_integration': True, # 是否作为 NetBox 插件运行
|
|
128
|
+
'plugin_slug': 'pve-netbox-sync'
|
|
129
|
+
},
|
|
130
|
+
'state_db': {
|
|
131
|
+
'path': '/var/lib/pve-sync/state.db',
|
|
132
|
+
'cleanup_days': 90
|
|
133
|
+
},
|
|
134
|
+
'logging': {
|
|
135
|
+
'level': 'INFO',
|
|
136
|
+
'directory': '/var/log/pve-sync',
|
|
137
|
+
'max_files': 30
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def _apply_env_overrides(self, yaml_config: Dict[str, Any]) -> Dict[str, Any]:
|
|
142
|
+
"""应用环境变量覆盖(环境变量优先级最高)"""
|
|
143
|
+
# 环境变量映射表
|
|
144
|
+
env_mappings = {
|
|
145
|
+
'PVE_API_HOST': ('clusters', 0, 'pve', 'host'),
|
|
146
|
+
'PVE_API_USER': ('clusters', 0, 'pve', 'user'),
|
|
147
|
+
'PVE_API_TOKEN': ('clusters', 0, 'pve', 'token'),
|
|
148
|
+
'PVE_API_SECRET': ('clusters', 0, 'pve', 'secret'),
|
|
149
|
+
'PVE_API_VERIFY_SSL': ('clusters', 0, 'pve', 'verify_ssl'),
|
|
150
|
+
'NB_API_URL': ('clusters', 0, 'netbox', 'url'),
|
|
151
|
+
'NB_API_TOKEN': ('clusters', 0, 'netbox', 'token'),
|
|
152
|
+
'NB_CLUSTER_NAME': ('clusters', 0, 'settings', 'cluster_name'),
|
|
153
|
+
'NB_SITE_NAME': ('clusters', 0, 'settings', 'site_name'),
|
|
154
|
+
'TELEGRAM_BOT_TOKEN': ('telegram', 'bot_token'),
|
|
155
|
+
'TELEGRAM_CHAT_ID': ('telegram', 'chat_id'),
|
|
156
|
+
'WEBHOOK_SECRET': ('webhook', 'secret'),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
merged = yaml_config.copy()
|
|
160
|
+
|
|
161
|
+
for env_key, path in env_mappings.items():
|
|
162
|
+
env_value = os.getenv(env_key)
|
|
163
|
+
if env_value is not None:
|
|
164
|
+
# 遍历路径,逐层创建/设置
|
|
165
|
+
current = merged
|
|
166
|
+
for i, key in enumerate(path[:-1]):
|
|
167
|
+
if isinstance(key, int): # 数组索引
|
|
168
|
+
if len(current) <= key:
|
|
169
|
+
current.append({})
|
|
170
|
+
current = current[key]
|
|
171
|
+
else:
|
|
172
|
+
if key not in current:
|
|
173
|
+
current[key] = {} if i < len(path) - 2 else None
|
|
174
|
+
current = current[key]
|
|
175
|
+
|
|
176
|
+
# 设置值
|
|
177
|
+
final_key = path[-1]
|
|
178
|
+
if isinstance(final_key, int):
|
|
179
|
+
if len(current) <= final_key:
|
|
180
|
+
current.append(env_value)
|
|
181
|
+
else:
|
|
182
|
+
current[final_key] = env_value
|
|
183
|
+
else:
|
|
184
|
+
# 类型转换
|
|
185
|
+
if final_key == 'verify_ssl':
|
|
186
|
+
current[final_key] = env_value.lower() == 'true'
|
|
187
|
+
else:
|
|
188
|
+
current[final_key] = env_value
|
|
189
|
+
|
|
190
|
+
return merged
|
|
191
|
+
|
|
192
|
+
def _start_watcher(self):
|
|
193
|
+
"""启动文件监视器"""
|
|
194
|
+
if not self._enabled:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
self._observer = Observer()
|
|
199
|
+
handler = ConfigChangeHandler(self)
|
|
200
|
+
self._observer.schedule(handler, self.config_file.parent, recursive=False)
|
|
201
|
+
self._observer.start()
|
|
202
|
+
print(f"🔄 配置热重载已启用,监控: {self.config_file}")
|
|
203
|
+
except Exception as e:
|
|
204
|
+
print(f"⚠ 配置热重载不可用 (watchdog 未安装?): {e}")
|
|
205
|
+
self._observer = None
|
|
206
|
+
|
|
207
|
+
def reload(self):
|
|
208
|
+
"""重新加载配置(由 watchdog 调用)"""
|
|
209
|
+
old_config = self.config.copy()
|
|
210
|
+
self._load_config()
|
|
211
|
+
|
|
212
|
+
# 通知配置变更
|
|
213
|
+
if old_config != self.config:
|
|
214
|
+
print("✅ 配置已重新加载")
|
|
215
|
+
# 这里可以触发回调,通知其他模块配置已变更
|
|
216
|
+
|
|
217
|
+
def get(self, key_path: str, default: Any = None) -> Any:
|
|
218
|
+
"""
|
|
219
|
+
获取配置值(支持点分隔路径)
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
key_path: 配置路径,如 'telegram.enabled'、'clusters.0.pve.host'
|
|
223
|
+
default: 默认值
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
配置值或默认值
|
|
227
|
+
"""
|
|
228
|
+
with self._lock:
|
|
229
|
+
keys = key_path.split('.')
|
|
230
|
+
value = self.config
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
for key in keys:
|
|
234
|
+
# 处理数字索引
|
|
235
|
+
if key.isdigit():
|
|
236
|
+
key = int(key)
|
|
237
|
+
value = value[key]
|
|
238
|
+
return value
|
|
239
|
+
except (KeyError, IndexError, TypeError):
|
|
240
|
+
return default
|
|
241
|
+
|
|
242
|
+
def get_cluster_configs(self) -> List[Dict[str, Any]]:
|
|
243
|
+
"""获取所有集群配置"""
|
|
244
|
+
return self.get('clusters', [])
|
|
245
|
+
|
|
246
|
+
def get_telegram_config(self) -> Optional[Dict[str, Any]]:
|
|
247
|
+
"""获取 Telegram 配置"""
|
|
248
|
+
telegram_config = self.get('telegram', {})
|
|
249
|
+
if telegram_config.get('enabled'):
|
|
250
|
+
return telegram_config
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def get_monitoring_config(self) -> Dict[str, Any]:
|
|
254
|
+
"""获取监控配置"""
|
|
255
|
+
return self.get('monitoring', {})
|
|
256
|
+
|
|
257
|
+
def get_sync_config(self) -> Dict[str, Any]:
|
|
258
|
+
"""获取同步配置"""
|
|
259
|
+
return self.get('sync', {})
|
|
260
|
+
|
|
261
|
+
def get_webhook_config(self) -> Optional[Dict[str, Any]]:
|
|
262
|
+
"""获取 Webhook 配置"""
|
|
263
|
+
webhook_config = self.get('webhook', {})
|
|
264
|
+
if webhook_config.get('enabled'):
|
|
265
|
+
return webhook_config
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def get_state_db_config(self) -> Dict[str, Any]:
|
|
269
|
+
"""获取状态数据库配置"""
|
|
270
|
+
return self.get('state_db', {})
|
|
271
|
+
|
|
272
|
+
def stop(self):
|
|
273
|
+
"""停止配置监视器"""
|
|
274
|
+
if self._observer:
|
|
275
|
+
self._observer.stop()
|
|
276
|
+
self._observer.join()
|
|
277
|
+
|
|
278
|
+
def __del__(self):
|
|
279
|
+
"""析构时停止监视器"""
|
|
280
|
+
self.stop()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# 全局配置管理器单实例
|
|
284
|
+
_global_config: Optional[ConfigManager] = None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def init_config(config_file: str = None) -> ConfigManager:
|
|
288
|
+
"""初始化全局配置管理器"""
|
|
289
|
+
global _global_config
|
|
290
|
+
if _global_config is None:
|
|
291
|
+
if config_file:
|
|
292
|
+
_global_config = ConfigManager(config_file)
|
|
293
|
+
else:
|
|
294
|
+
# 默认路径查找
|
|
295
|
+
paths = [
|
|
296
|
+
'/etc/pve-sync/config.yaml',
|
|
297
|
+
'/opt/pve-sync/config.yaml',
|
|
298
|
+
'config.yaml',
|
|
299
|
+
'config.example.yaml'
|
|
300
|
+
]
|
|
301
|
+
for path in paths:
|
|
302
|
+
if Path(path).exists():
|
|
303
|
+
_global_config = ConfigManager(path)
|
|
304
|
+
break
|
|
305
|
+
else:
|
|
306
|
+
_global_config = ConfigManager() # 使用默认
|
|
307
|
+
return _global_config
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_config() -> ConfigManager:
|
|
311
|
+
"""获取全局配置管理器"""
|
|
312
|
+
if _global_config is None:
|
|
313
|
+
return init_config()
|
|
314
|
+
return _global_config
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
# 测试
|
|
319
|
+
import sys
|
|
320
|
+
|
|
321
|
+
if len(sys.argv) > 1:
|
|
322
|
+
config_file = sys.argv[1]
|
|
323
|
+
else:
|
|
324
|
+
config_file = "config.yaml"
|
|
325
|
+
|
|
326
|
+
# 创建示例配置
|
|
327
|
+
sample_config = ConfigManager(config_file)._build_default_config()
|
|
328
|
+
|
|
329
|
+
print("=== 示例配置 ===")
|
|
330
|
+
print(yaml.dump(sample_config, default_flow_style=False, allow_unicode=True))
|
|
331
|
+
|
|
332
|
+
# 初始化并测试热重载
|
|
333
|
+
cm = init_config(config_file)
|
|
334
|
+
print("\n当前配置值:")
|
|
335
|
+
print(f" telegram.enabled: {cm.get('telegram.enabled')}")
|
|
336
|
+
print(f" monitoring.resource_alert.memory_threshold: {cm.get('monitoring.resource_alert.memory_threshold')}%")
|
|
337
|
+
print(f" clusters 数量: {len(cm.get_cluster_configs())}")
|
pbs_sync.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
pbs-sync: 同步 PBS 到 NetBox (修正整數類型衝突版本)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import urllib3
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional, Any
|
|
12
|
+
|
|
13
|
+
import pynetbox
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# ============================================================================
|
|
19
|
+
# 配置部分
|
|
20
|
+
# ============================================================================
|
|
21
|
+
|
|
22
|
+
def _get_env(key, default=''):
|
|
23
|
+
"""Read env var at call time (not import time) so RQ workers pick up per-job values."""
|
|
24
|
+
return os.environ.get(key, default)
|
|
25
|
+
|
|
26
|
+
# ============================================================================
|
|
27
|
+
# 工具函數
|
|
28
|
+
# ============================================================================
|
|
29
|
+
|
|
30
|
+
def slugify(text: str) -> str:
|
|
31
|
+
text = text.lower()
|
|
32
|
+
text = re.sub(r'[^a-z0-9]+', '-', text)
|
|
33
|
+
return re.sub(r'-+', '-', text).strip('-')[:50]
|
|
34
|
+
|
|
35
|
+
def disk_format_size(bytes_val: int) -> str:
|
|
36
|
+
"""轉換為 GB 字串"""
|
|
37
|
+
return f"{round(bytes_val / (1024**4), 2)} TB"
|
|
38
|
+
|
|
39
|
+
def mem_format_size(bytes_val: int) -> str:
|
|
40
|
+
"""轉換為 GB 字串"""
|
|
41
|
+
return f"{round(bytes_val / (1024**3), 2)} GB"
|
|
42
|
+
|
|
43
|
+
# ============================================================================
|
|
44
|
+
# PBS API 客户端
|
|
45
|
+
# ============================================================================
|
|
46
|
+
|
|
47
|
+
class PBSClient:
|
|
48
|
+
def __init__(self, host: str, token_name: str, token_secret: str, verify_ssl: bool = False):
|
|
49
|
+
self.host = host.rstrip('/')
|
|
50
|
+
self.session = requests.Session()
|
|
51
|
+
self.session.headers.update({
|
|
52
|
+
"Authorization": f"PBSAPIToken={token_name}:{token_secret}",
|
|
53
|
+
"Accept": "application/json"
|
|
54
|
+
})
|
|
55
|
+
self.verify_ssl = verify_ssl
|
|
56
|
+
|
|
57
|
+
def get(self, endpoint: str) -> Optional[Any]:
|
|
58
|
+
try:
|
|
59
|
+
resp = self.session.get(f"{self.host}{endpoint}", verify=self.verify_ssl, timeout=15)
|
|
60
|
+
if resp.status_code == 200:
|
|
61
|
+
return resp.json().get('data')
|
|
62
|
+
return None
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
logger.warning("PBS API request failed (%s): %s", endpoint, exc)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# ============================================================================
|
|
68
|
+
# NetBox 同步器
|
|
69
|
+
# ============================================================================
|
|
70
|
+
|
|
71
|
+
class PBSToNetBoxSync:
|
|
72
|
+
def __init__(self):
|
|
73
|
+
pbs_host = _get_env('PBS_HOST', 'https://localhost:8007')
|
|
74
|
+
pbs_token_name = _get_env('PBS_TOKEN_NAME', 'root@pam!apitoken')
|
|
75
|
+
pbs_token_secret = _get_env('PBS_TOKEN_SECRET', '')
|
|
76
|
+
pbs_verify_ssl = _get_env('PBS_VERIFY_SSL', 'false').lower() == 'true'
|
|
77
|
+
netbox_host = _get_env('NB_API_URL', 'http://localhost:8000')
|
|
78
|
+
netbox_token = _get_env('NB_API_TOKEN', '')
|
|
79
|
+
|
|
80
|
+
self.nb = pynetbox.api(netbox_host, netbox_token)
|
|
81
|
+
self.nb.http_session.verify = False
|
|
82
|
+
self.pbs = PBSClient(pbs_host, pbs_token_name, pbs_token_secret, pbs_verify_ssl)
|
|
83
|
+
|
|
84
|
+
def get_or_create_obj(self, endpoint, name, extra_fields=None):
|
|
85
|
+
slug = slugify(name)
|
|
86
|
+
obj = endpoint.get(slug=slug) or endpoint.get(name=name)
|
|
87
|
+
if not obj:
|
|
88
|
+
data = {'name': name, 'slug': slug}
|
|
89
|
+
if extra_fields: data.update(extra_fields)
|
|
90
|
+
obj = endpoint.create(**data)
|
|
91
|
+
return obj
|
|
92
|
+
|
|
93
|
+
def sync(self):
|
|
94
|
+
node_name = _get_env('PBS_NODE_NAME', 'pbs')
|
|
95
|
+
logger.info("Starting PBS sync for node: %s", node_name)
|
|
96
|
+
|
|
97
|
+
# 1. 獲取 PBS 數據
|
|
98
|
+
status = self.pbs.get(f"/api2/json/nodes/{node_name}/status")
|
|
99
|
+
version_data = self.pbs.get("/api2/json/version") or {}
|
|
100
|
+
if not status:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"Cannot fetch status for PBS node '{node_name}' "
|
|
103
|
+
f"(host={_get_env('PBS_HOST')}) — check connectivity or pbs_node_name setting"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
pbs_ver = version_data.get('version', '4.x')
|
|
107
|
+
cpu_cores = int(status.get('cpuinfo', {}).get('cpus', 0))
|
|
108
|
+
mem_total = mem_format_size(status.get('memory', {}).get('total', 0))
|
|
109
|
+
disk_total = disk_format_size(status.get('root', {}).get('total', 0))
|
|
110
|
+
disk_free = disk_format_size(status.get('root', {}).get('avail', 0))
|
|
111
|
+
|
|
112
|
+
# 2. 獲取 NetBox 基礎物件
|
|
113
|
+
site_name = _get_env('PBS_NETBOX_SITE', '') or "Main Datacenter"
|
|
114
|
+
site = self.get_or_create_obj(self.nb.dcim.sites, site_name)
|
|
115
|
+
manu = self.get_or_create_obj(self.nb.dcim.manufacturers, "Proxmox")
|
|
116
|
+
role = self.get_or_create_obj(self.nb.dcim.device_roles, "Backup Server", {'color': '9e9e9e'})
|
|
117
|
+
platform = self.get_or_create_obj(self.nb.dcim.platforms, "Proxmox Backup Server")
|
|
118
|
+
|
|
119
|
+
model_name = "PBS Server"
|
|
120
|
+
slug = slugify(model_name)
|
|
121
|
+
dt = self.nb.dcim.device_types.get(slug=slug) or self.nb.dcim.device_types.get(model=model_name)
|
|
122
|
+
if not dt:
|
|
123
|
+
dt = self.nb.dcim.device_types.create(model=model_name, slug=slug, manufacturer=manu.id)
|
|
124
|
+
|
|
125
|
+
# 3. 同步設備資訊
|
|
126
|
+
comments = (
|
|
127
|
+
f"### PBS 系統資訊\n"
|
|
128
|
+
f"- **Version**: {pbs_ver}\n"
|
|
129
|
+
f"- **CPU Cores**: {cpu_cores}\n"
|
|
130
|
+
f"- **Memory**: {mem_total}\n"
|
|
131
|
+
f"- **Disk Size**: {disk_total}\n"
|
|
132
|
+
f"- **Disk Free**: {disk_free}\n"
|
|
133
|
+
f"- **Last Sync**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
device_data = {
|
|
137
|
+
'name': node_name,
|
|
138
|
+
'device_type': dt.id,
|
|
139
|
+
'role': role.id,
|
|
140
|
+
'site': site.id,
|
|
141
|
+
'platform': platform.id,
|
|
142
|
+
'status': 'active',
|
|
143
|
+
'comments': comments,
|
|
144
|
+
'custom_fields': {
|
|
145
|
+
'host_cpu_cores': cpu_cores,
|
|
146
|
+
'host_memory': mem_total,
|
|
147
|
+
'host_disk_size': disk_total,
|
|
148
|
+
'host_disk_free': disk_free,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
device = self.nb.dcim.devices.get(name=node_name)
|
|
153
|
+
try:
|
|
154
|
+
if device:
|
|
155
|
+
device.update(device_data)
|
|
156
|
+
logger.info("Updated device: %s", node_name)
|
|
157
|
+
else:
|
|
158
|
+
device = self.nb.dcim.devices.create(**device_data)
|
|
159
|
+
logger.info("Created device: %s", node_name)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.warning("Device sync error (retrying without custom_fields): %s", e)
|
|
162
|
+
device_data.pop('custom_fields')
|
|
163
|
+
if device:
|
|
164
|
+
device.update(device_data)
|
|
165
|
+
else:
|
|
166
|
+
device = self.nb.dcim.devices.create(**device_data)
|
|
167
|
+
|
|
168
|
+
# 4. 同步網路並設置 Primary IP
|
|
169
|
+
self.sync_networking(device, node_name)
|
|
170
|
+
|
|
171
|
+
def sync_networking(self, device, node_name=None):
|
|
172
|
+
if node_name is None:
|
|
173
|
+
node_name = _get_env('PBS_NODE_NAME', 'pbs')
|
|
174
|
+
logger.info("Syncing network config for %s", node_name)
|
|
175
|
+
pbs_net = self.pbs.get(f"/api2/json/nodes/{node_name}/network")
|
|
176
|
+
if not pbs_net:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
api_host_ip = _get_env('PBS_HOST', '').split('//')[-1].split(':')[0]
|
|
180
|
+
primary_ip_candidate = None
|
|
181
|
+
|
|
182
|
+
for item in pbs_net:
|
|
183
|
+
iface_name = item.get('iface')
|
|
184
|
+
if not iface_name or item.get('type') not in ['eth', 'bridge', 'bond']:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
mac = item.get('address', '').lower()
|
|
188
|
+
cidr = item.get('cidr')
|
|
189
|
+
|
|
190
|
+
# 接口
|
|
191
|
+
nb_if = self.nb.dcim.interfaces.get(device_id=device.id, name=iface_name)
|
|
192
|
+
if not nb_if:
|
|
193
|
+
nb_if = self.nb.dcim.interfaces.create(
|
|
194
|
+
device=device.id, name=iface_name, type='1000base-t', mac_address=mac or None
|
|
195
|
+
)
|
|
196
|
+
elif mac and nb_if.mac_address != mac:
|
|
197
|
+
nb_if.mac_address = mac
|
|
198
|
+
nb_if.save()
|
|
199
|
+
|
|
200
|
+
# IP
|
|
201
|
+
if cidr:
|
|
202
|
+
nb_ip = self.nb.ipam.ip_addresses.get(address=cidr)
|
|
203
|
+
assigned_to_this_device = False
|
|
204
|
+
if not nb_ip:
|
|
205
|
+
nb_ip = self.nb.ipam.ip_addresses.create(
|
|
206
|
+
address=cidr, status='active',
|
|
207
|
+
assigned_object_type='dcim.interface', assigned_object_id=nb_if.id
|
|
208
|
+
)
|
|
209
|
+
assigned_to_this_device = True
|
|
210
|
+
else:
|
|
211
|
+
already_on_this_iface = (
|
|
212
|
+
getattr(nb_ip, 'assigned_object_id', None) == nb_if.id
|
|
213
|
+
and getattr(nb_ip, 'assigned_object_type', '') == 'dcim.interface'
|
|
214
|
+
)
|
|
215
|
+
if already_on_this_iface:
|
|
216
|
+
assigned_to_this_device = True
|
|
217
|
+
else:
|
|
218
|
+
try:
|
|
219
|
+
nb_ip.assigned_object_type = 'dcim.interface'
|
|
220
|
+
nb_ip.assigned_object_id = nb_if.id
|
|
221
|
+
nb_ip.save()
|
|
222
|
+
assigned_to_this_device = True
|
|
223
|
+
except Exception as e:
|
|
224
|
+
if 'primary' in str(e).lower() or '400' in str(e):
|
|
225
|
+
logger.warning(
|
|
226
|
+
"IP %s is primary IP elsewhere, skipping reassignment", cidr
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
# Only use as primary candidate if actually assigned to this device
|
|
232
|
+
if assigned_to_this_device:
|
|
233
|
+
if not primary_ip_candidate or (api_host_ip in cidr):
|
|
234
|
+
primary_ip_candidate = nb_ip
|
|
235
|
+
|
|
236
|
+
if primary_ip_candidate:
|
|
237
|
+
device.primary_ip4 = primary_ip_candidate.id
|
|
238
|
+
device.save()
|
|
239
|
+
logger.info("Set primary IPv4: %s", primary_ip_candidate.address)
|
|
240
|
+
|
|
241
|
+
if __name__ == '__main__':
|
|
242
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
|
243
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
244
|
+
PBSToNetBoxSync().sync()
|