netbox-bmc 0.4.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.
- netbox_bmc/__init__.py +78 -0
- netbox_bmc/api/__init__.py +0 -0
- netbox_bmc/api/serializers.py +23 -0
- netbox_bmc/api/urls.py +7 -0
- netbox_bmc/api/views.py +9 -0
- netbox_bmc/credentials.py +209 -0
- netbox_bmc/drivers/__init__.py +3 -0
- netbox_bmc/drivers/base.py +71 -0
- netbox_bmc/drivers/ipmi.py +103 -0
- netbox_bmc/drivers/redfish.py +543 -0
- netbox_bmc/forms.py +18 -0
- netbox_bmc/inventory.py +43 -0
- netbox_bmc/jobs.py +28 -0
- netbox_bmc/migrations/0001_initial.py +75 -0
- netbox_bmc/migrations/0002_add_jobs_feature.py +23 -0
- netbox_bmc/migrations/0003_inventoryitem_redfish_path_cf.py +47 -0
- netbox_bmc/migrations/0004_module_custom_fields.py +80 -0
- netbox_bmc/migrations/0005_device_firmware_cf.py +54 -0
- netbox_bmc/migrations/__init__.py +0 -0
- netbox_bmc/models.py +71 -0
- netbox_bmc/module_sync.py +305 -0
- netbox_bmc/navigation.py +15 -0
- netbox_bmc/normalizer.py +86 -0
- netbox_bmc/tables.py +25 -0
- netbox_bmc/template_content.py +19 -0
- netbox_bmc/templates/netbox_bmc/bmcendpoint.html +96 -0
- netbox_bmc/templates/netbox_bmc/inc/device_bmc_panel.html +29 -0
- netbox_bmc/templates/netbox_bmc/module_preview.html +145 -0
- netbox_bmc/urls.py +24 -0
- netbox_bmc/views.py +191 -0
- netbox_bmc-0.4.0.dist-info/METADATA +230 -0
- netbox_bmc-0.4.0.dist-info/RECORD +36 -0
- netbox_bmc-0.4.0.dist-info/WHEEL +5 -0
- netbox_bmc-0.4.0.dist-info/licenses/LICENSE +182 -0
- netbox_bmc-0.4.0.dist-info/licenses/NOTICE +4 -0
- netbox_bmc-0.4.0.dist-info/top_level.txt +1 -0
netbox_bmc/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
__version__ = "0.4.0"
|
|
4
|
+
__author__ = "tak-labo"
|
|
5
|
+
__email__ = ""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetBoxBMCConfig(PluginConfig):
|
|
9
|
+
name = "netbox_bmc"
|
|
10
|
+
verbose_name = "NetBox BMC (IPMI/Redfish)"
|
|
11
|
+
description = "Unified out-of-band management: Redfish & IPMI inventory sync, power control, console access"
|
|
12
|
+
version = __version__
|
|
13
|
+
author = __author__
|
|
14
|
+
author_email = __email__
|
|
15
|
+
base_url = "bmc"
|
|
16
|
+
min_version = "4.5"
|
|
17
|
+
max_version = "4.6.99"
|
|
18
|
+
default_settings = {
|
|
19
|
+
# 定期一括同期の間隔 (分)。0 で無効。
|
|
20
|
+
"sync_interval_minutes": 0,
|
|
21
|
+
"default_verify_ssl": False,
|
|
22
|
+
# netbox-secrets バックグラウンドジョブ用サービスアカウント設定
|
|
23
|
+
# (netbox-secrets 使用時のみ必要)
|
|
24
|
+
# "service_account": "bmc-sync",
|
|
25
|
+
# "service_private_key_path": "/opt/netbox/bmc-sync.pem",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def ready(self):
|
|
29
|
+
super().ready()
|
|
30
|
+
from . import jobs # noqa: F401
|
|
31
|
+
|
|
32
|
+
interval = (getattr(self, "settings", None) or {}).get("sync_interval_minutes") or 0
|
|
33
|
+
if interval:
|
|
34
|
+
self._enqueue_scheduled_sync(interval)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _enqueue_scheduled_sync(interval_minutes: int) -> None:
|
|
38
|
+
"""
|
|
39
|
+
ScheduledInventorySyncJob の定期ジョブを登録する。
|
|
40
|
+
|
|
41
|
+
ready() は workers / web プロセスの起動ごとに呼ばれるため、
|
|
42
|
+
既に pending / scheduled / running の同名ジョブがあればスキップして
|
|
43
|
+
冪等にする。初回マイグレーション前など Job テーブル未作成の場合は黙って抜ける。
|
|
44
|
+
"""
|
|
45
|
+
import logging
|
|
46
|
+
|
|
47
|
+
from django.db import DatabaseError
|
|
48
|
+
|
|
49
|
+
from .jobs import ScheduledInventorySyncJob
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("netbox_bmc")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from datetime import timedelta
|
|
55
|
+
|
|
56
|
+
from core.models import Job
|
|
57
|
+
from django.utils import timezone
|
|
58
|
+
|
|
59
|
+
active = Job.objects.filter(
|
|
60
|
+
name=ScheduledInventorySyncJob.Meta.name,
|
|
61
|
+
status__in=("pending", "scheduled", "running"),
|
|
62
|
+
).exists()
|
|
63
|
+
if active:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
ScheduledInventorySyncJob.enqueue(
|
|
67
|
+
instance=None,
|
|
68
|
+
schedule_at=timezone.now() + timedelta(minutes=interval_minutes),
|
|
69
|
+
interval=interval_minutes,
|
|
70
|
+
)
|
|
71
|
+
except DatabaseError:
|
|
72
|
+
# マイグレーション前: Job テーブルが存在しない
|
|
73
|
+
pass
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning("Failed to schedule recurring inventory sync: %s", e)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
config = NetBoxBMCConfig
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
2
|
+
from rest_framework import serializers
|
|
3
|
+
|
|
4
|
+
from ..models import BMCEndpoint
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BMCEndpointSerializer(NetBoxModelSerializer):
|
|
8
|
+
url = serializers.HyperlinkedIdentityField(
|
|
9
|
+
view_name="plugins-api:netbox_bmc-api:bmcendpoint-detail",
|
|
10
|
+
)
|
|
11
|
+
password = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
|
12
|
+
|
|
13
|
+
class Meta:
|
|
14
|
+
model = BMCEndpoint
|
|
15
|
+
fields = (
|
|
16
|
+
"id", "url", "display",
|
|
17
|
+
"device", "address", "port", "protocol", "verify_ssl",
|
|
18
|
+
"username", "password",
|
|
19
|
+
"detected_vendor", "detected_protocol",
|
|
20
|
+
"last_sync", "last_sync_status",
|
|
21
|
+
"tags", "custom_fields", "created", "last_updated",
|
|
22
|
+
)
|
|
23
|
+
brief_fields = ("id", "url", "display", "device", "address")
|
netbox_bmc/api/urls.py
ADDED
netbox_bmc/api/views.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from netbox.api.viewsets import NetBoxModelViewSet
|
|
2
|
+
|
|
3
|
+
from ..models import BMCEndpoint
|
|
4
|
+
from .serializers import BMCEndpointSerializer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BMCEndpointViewSet(NetBoxModelViewSet):
|
|
8
|
+
queryset = BMCEndpoint.objects.prefetch_related("tags")
|
|
9
|
+
serializer_class = BMCEndpointSerializer
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
netbox-secrets からBMC認証情報を取得するヘルパー。
|
|
3
|
+
|
|
4
|
+
【復号の経路】
|
|
5
|
+
バックグラウンドジョブ (InventorySyncJob / ScheduledInventorySyncJob):
|
|
6
|
+
request なし → PLUGINS_CONFIG の service_private_key_path に
|
|
7
|
+
置いたサービスアカウントの RSA 秘密鍵 (PEM) で UserKey を復号し
|
|
8
|
+
master_key を取得する。
|
|
9
|
+
|
|
10
|
+
request 引数経路は将来的な REST API 同期実行用に残してあるが、
|
|
11
|
+
ジョブ kwargs を介してセッションキーを渡すと Job レコードに平文で
|
|
12
|
+
保存されてしまうため、現状の UI トリガーでは使用しない。
|
|
13
|
+
|
|
14
|
+
【Secretのレイアウト規約】
|
|
15
|
+
- SecretRole slug : bmc-credentials
|
|
16
|
+
- Secret.name : BMC username (非暗号化フィールド)
|
|
17
|
+
- Secret.plaintext: BMC password (RSA暗号化)
|
|
18
|
+
- assigned_object: Device (当該 BMCEndpoint の device)
|
|
19
|
+
|
|
20
|
+
【フォールバック動作】
|
|
21
|
+
netbox-secrets が未インストールの場合は BMCEndpoint.username/password の
|
|
22
|
+
平文フィールドにフォールバックする (後方互換)。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import logging
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
from django.conf import settings
|
|
33
|
+
from django.contrib.contenttypes.models import ContentType
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from django.http import HttpRequest
|
|
37
|
+
|
|
38
|
+
from .models import BMCEndpoint
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("netbox_bmc.credentials")
|
|
41
|
+
|
|
42
|
+
SECRET_ROLE_SLUG = "bmc-credentials"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Credential:
|
|
47
|
+
username: str
|
|
48
|
+
password: str
|
|
49
|
+
source: str # "netbox_secrets" | "plaintext_fallback"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_credential(endpoint: BMCEndpoint,
|
|
53
|
+
request: HttpRequest | None = None) -> Credential:
|
|
54
|
+
"""
|
|
55
|
+
BMCEndpoint に紐づく認証情報を取得して返す。
|
|
56
|
+
|
|
57
|
+
netbox-secrets が使用可能な場合:
|
|
58
|
+
- request が渡された場合はセッションキーで復号
|
|
59
|
+
- request=None の場合はサービスアカウント秘密鍵で復号
|
|
60
|
+
|
|
61
|
+
どちらも失敗した場合 / netbox-secrets 未インストールの場合:
|
|
62
|
+
- endpoint.username / endpoint.password を返す
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
return _get_from_secrets(endpoint, request)
|
|
66
|
+
except _SecretsUnavailable:
|
|
67
|
+
logger.debug("netbox-secrets unavailable, using plaintext fields")
|
|
68
|
+
except _SecretNotFound:
|
|
69
|
+
logger.debug("No bmc-credentials secret for device %s, using plaintext fields",
|
|
70
|
+
endpoint.device)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
# netbox-secrets が利用可能なのに復号に失敗した場合は警告ではなく
|
|
73
|
+
# error レベルで残す (運用上、無音で平文に落ちると気付きにくい)。
|
|
74
|
+
logger.error("Failed to decrypt secret for %s: %s, using plaintext fields",
|
|
75
|
+
endpoint.device, e)
|
|
76
|
+
|
|
77
|
+
# フォールバック
|
|
78
|
+
return Credential(
|
|
79
|
+
username=endpoint.username,
|
|
80
|
+
password=endpoint.password,
|
|
81
|
+
source="plaintext_fallback",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# 内部実装
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
class _SecretsUnavailable(Exception):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _SecretNotFound(Exception):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get_from_secrets(endpoint: BMCEndpoint,
|
|
98
|
+
request: HttpRequest | None) -> Credential:
|
|
99
|
+
try:
|
|
100
|
+
from netbox_secrets.models import Secret, SecretRole
|
|
101
|
+
except ImportError:
|
|
102
|
+
raise _SecretsUnavailable from None
|
|
103
|
+
|
|
104
|
+
device = endpoint.device
|
|
105
|
+
device_ct = ContentType.objects.get_for_model(device)
|
|
106
|
+
|
|
107
|
+
role_qs = SecretRole.objects.filter(slug=SECRET_ROLE_SLUG)
|
|
108
|
+
if not role_qs.exists():
|
|
109
|
+
raise _SecretNotFound(f"SecretRole '{SECRET_ROLE_SLUG}' not found")
|
|
110
|
+
|
|
111
|
+
secret_qs = Secret.objects.filter(
|
|
112
|
+
role__in=role_qs,
|
|
113
|
+
assigned_object_type=device_ct,
|
|
114
|
+
assigned_object_id=device.pk,
|
|
115
|
+
)
|
|
116
|
+
if not secret_qs.exists():
|
|
117
|
+
raise _SecretNotFound
|
|
118
|
+
|
|
119
|
+
secret = secret_qs.first()
|
|
120
|
+
master_key = _resolve_master_key(request)
|
|
121
|
+
secret.decrypt(master_key)
|
|
122
|
+
|
|
123
|
+
if secret.plaintext is None:
|
|
124
|
+
raise Exception("decrypt returned None — wrong key?")
|
|
125
|
+
|
|
126
|
+
return Credential(
|
|
127
|
+
username=secret.name, # name フィールドがユーザー名
|
|
128
|
+
password=secret.plaintext,
|
|
129
|
+
source="netbox_secrets",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _resolve_master_key(request: HttpRequest | None) -> bytes:
|
|
134
|
+
"""
|
|
135
|
+
master_key (bytes) を返す。
|
|
136
|
+
|
|
137
|
+
request あり → Cookie または X-Session-Key ヘッダからセッションキーを取得
|
|
138
|
+
request なし → サービスアカウント秘密鍵で復号
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
if request is not None:
|
|
142
|
+
return _master_key_from_request(request)
|
|
143
|
+
|
|
144
|
+
return _master_key_from_service_account()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _master_key_from_request(request) -> bytes:
|
|
148
|
+
from netbox_secrets.models import UserKey
|
|
149
|
+
|
|
150
|
+
# X-Session-Key ヘッダ (API) or session_key Cookie (ブラウザ)
|
|
151
|
+
session_key_b64 = (
|
|
152
|
+
request.META.get("HTTP_X_SESSION_KEY")
|
|
153
|
+
or request.COOKIES.get("session_key")
|
|
154
|
+
)
|
|
155
|
+
if not session_key_b64:
|
|
156
|
+
raise Exception("No X-Session-Key header or session_key cookie in request")
|
|
157
|
+
|
|
158
|
+
session_key = base64.b64decode(session_key_b64)
|
|
159
|
+
try:
|
|
160
|
+
uk = UserKey.objects.get(user=request.user)
|
|
161
|
+
except UserKey.DoesNotExist as e:
|
|
162
|
+
raise Exception(f"No UserKey found for user {request.user}") from e
|
|
163
|
+
|
|
164
|
+
master_key = uk.get_master_key(session_key)
|
|
165
|
+
if master_key is None:
|
|
166
|
+
raise Exception("get_master_key returned None — session key may be expired")
|
|
167
|
+
return master_key
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _master_key_from_service_account() -> bytes:
|
|
171
|
+
"""
|
|
172
|
+
PLUGINS_CONFIG["netbox_bmc"]["service_account"] で指定された
|
|
173
|
+
サービスアカウントの UserKey と RSA 秘密鍵ファイルで master_key を取得する。
|
|
174
|
+
|
|
175
|
+
configuration.py での設定例:
|
|
176
|
+
PLUGINS_CONFIG = {
|
|
177
|
+
"netbox_bmc": {
|
|
178
|
+
"service_account": "bmc-sync", # NetBoxユーザー名
|
|
179
|
+
"service_private_key_path": "/opt/netbox/bmc-sync.pem",
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
"""
|
|
183
|
+
from netbox_secrets.models import UserKey
|
|
184
|
+
|
|
185
|
+
plugin_cfg = settings.PLUGINS_CONFIG.get("netbox_bmc", {})
|
|
186
|
+
account = plugin_cfg.get("service_account")
|
|
187
|
+
key_path = plugin_cfg.get("service_private_key_path")
|
|
188
|
+
|
|
189
|
+
if not account or not key_path:
|
|
190
|
+
raise Exception(
|
|
191
|
+
"service_account and service_private_key_path must be set in "
|
|
192
|
+
"PLUGINS_CONFIG['netbox_bmc'] for background job decryption"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
pem = Path(key_path).read_text()
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
uk = UserKey.objects.get(user__username=account)
|
|
199
|
+
except UserKey.DoesNotExist as e:
|
|
200
|
+
raise Exception(f"No UserKey for service account '{account}'") from e
|
|
201
|
+
|
|
202
|
+
# UserKey.get_master_key は秘密鍵PEM文字列を受け付ける
|
|
203
|
+
master_key = uk.get_master_key(private_key=pem)
|
|
204
|
+
if master_key is None:
|
|
205
|
+
raise Exception(
|
|
206
|
+
f"master_key is None for service account '{account}' — "
|
|
207
|
+
"check that the private key matches the stored public key"
|
|
208
|
+
)
|
|
209
|
+
return master_key
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ドライバ基底クラスとプロトコル/ベンダー自動検出。
|
|
3
|
+
|
|
4
|
+
設計方針:
|
|
5
|
+
- BaseDriver は最小限のインターフェイスのみ定義
|
|
6
|
+
- Redfish はパスをハードコードせず ServiceRoot からリンクを辿る
|
|
7
|
+
- ベンダー固有処理 (OEM 拡張) はベンダーサブクラスでオーバーライド
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..inventory import InventoryResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BMCError(Exception):
|
|
15
|
+
"""ドライバ層の共通例外。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseDriver:
|
|
19
|
+
protocol: str = ""
|
|
20
|
+
|
|
21
|
+
def __init__(self, address: str, username: str, password: str,
|
|
22
|
+
port: int | None = None, verify_ssl: bool = False,
|
|
23
|
+
timeout: int = 15):
|
|
24
|
+
self.address = address
|
|
25
|
+
self.username = username
|
|
26
|
+
self.password = password
|
|
27
|
+
self.port = port
|
|
28
|
+
self.verify_ssl = verify_ssl
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
|
|
31
|
+
# --- 必須インターフェイス -------------------------------------------
|
|
32
|
+
def get_inventory(self) -> InventoryResult:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def get_power_state(self) -> str:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def set_power(self, action: str) -> None:
|
|
39
|
+
"""action: on | off | cycle | reset | soft"""
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def __enter__(self):
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *exc):
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_and_build(address: str, username: str, password: str,
|
|
53
|
+
protocol: str = "auto", **kwargs) -> BaseDriver:
|
|
54
|
+
"""
|
|
55
|
+
プロトコルを判別してドライバインスタンスを返す。
|
|
56
|
+
|
|
57
|
+
auto の場合: まず https://<addr>/redfish/v1 を叩き、応答があれば
|
|
58
|
+
Redfish、なければ IPMI へフォールバックする。
|
|
59
|
+
"""
|
|
60
|
+
from .ipmi import IPMIDriver
|
|
61
|
+
from .redfish import build_redfish_driver, probe_redfish
|
|
62
|
+
|
|
63
|
+
if protocol == "redfish":
|
|
64
|
+
return build_redfish_driver(address, username, password, **kwargs)
|
|
65
|
+
if protocol == "ipmi":
|
|
66
|
+
return IPMIDriver(address, username, password, **kwargs)
|
|
67
|
+
|
|
68
|
+
# auto
|
|
69
|
+
if probe_redfish(address, timeout=5, verify_ssl=kwargs.get("verify_ssl", False)):
|
|
70
|
+
return build_redfish_driver(address, username, password, **kwargs)
|
|
71
|
+
return IPMIDriver(address, username, password, **kwargs)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IPMI ドライバ (レガシー BMC 向けフォールバック)。
|
|
3
|
+
|
|
4
|
+
pyghmi の get_inventory() で FRU 情報を取得する。
|
|
5
|
+
Redfish より取得できる情報は限定的 (CPU/DIMM の詳細は出ないことが多い)。
|
|
6
|
+
既存 netbox-ipmi-plugin の電源操作・SOL 周りはここに段階的に移植する。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from ..inventory import Component, InventoryResult, SystemInfo
|
|
13
|
+
from .base import BaseDriver, BMCError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("netbox_bmc.ipmi")
|
|
16
|
+
|
|
17
|
+
POWER_ACTION_MAP = {
|
|
18
|
+
"on": "on",
|
|
19
|
+
"off": "off",
|
|
20
|
+
"soft": "softoff",
|
|
21
|
+
"cycle": "boot", # pyghmi: off→on
|
|
22
|
+
"reset": "reset",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IPMIDriver(BaseDriver):
|
|
27
|
+
protocol = "ipmi"
|
|
28
|
+
|
|
29
|
+
def __init__(self, *args, **kwargs):
|
|
30
|
+
super().__init__(*args, **kwargs)
|
|
31
|
+
try:
|
|
32
|
+
from pyghmi.ipmi import command
|
|
33
|
+
except ImportError as e:
|
|
34
|
+
raise BMCError("pyghmi is not installed") from e
|
|
35
|
+
try:
|
|
36
|
+
self.cmd = command.Command(
|
|
37
|
+
bmc=self.address,
|
|
38
|
+
userid=self.username,
|
|
39
|
+
password=self.password,
|
|
40
|
+
port=self.port or 623,
|
|
41
|
+
)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise BMCError(f"IPMI connection to {self.address} failed: {e}") from e
|
|
44
|
+
|
|
45
|
+
def get_inventory(self) -> InventoryResult:
|
|
46
|
+
system = SystemInfo()
|
|
47
|
+
components: list[Component] = []
|
|
48
|
+
try:
|
|
49
|
+
for name, info in self.cmd.get_inventory():
|
|
50
|
+
if info is None:
|
|
51
|
+
continue
|
|
52
|
+
if name == "System":
|
|
53
|
+
system.manufacturer = info.get("Manufacturer", "") or ""
|
|
54
|
+
system.model = info.get("Product name", "") or ""
|
|
55
|
+
system.serial = info.get("Serial Number", "") or ""
|
|
56
|
+
system.uuid = str(info.get("UUID", "") or "")
|
|
57
|
+
else:
|
|
58
|
+
components.append(Component(
|
|
59
|
+
kind=_guess_kind(name),
|
|
60
|
+
name=name,
|
|
61
|
+
manufacturer=info.get("Manufacturer", "") or "",
|
|
62
|
+
part_id=info.get("Part Number", "")
|
|
63
|
+
or info.get("Product name", "") or "",
|
|
64
|
+
serial=info.get("Serial Number", "") or "",
|
|
65
|
+
))
|
|
66
|
+
except Exception as e:
|
|
67
|
+
raise BMCError(f"IPMI FRU read failed: {e}") from e
|
|
68
|
+
|
|
69
|
+
return InventoryResult(system=system, components=components,
|
|
70
|
+
vendor=system.manufacturer or "Unknown",
|
|
71
|
+
protocol=self.protocol)
|
|
72
|
+
|
|
73
|
+
def get_power_state(self) -> str:
|
|
74
|
+
try:
|
|
75
|
+
return self.cmd.get_power().get("powerstate", "unknown")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
raise BMCError(str(e)) from e
|
|
78
|
+
|
|
79
|
+
def set_power(self, action: str) -> None:
|
|
80
|
+
mapped = POWER_ACTION_MAP.get(action)
|
|
81
|
+
if not mapped:
|
|
82
|
+
raise BMCError(f"Unknown power action: {action}")
|
|
83
|
+
try:
|
|
84
|
+
self.cmd.set_power(mapped, wait=False)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
raise BMCError(f"IPMI power action failed: {e}") from e
|
|
87
|
+
|
|
88
|
+
def close(self):
|
|
89
|
+
try:
|
|
90
|
+
self.cmd.ipmi_session.logout()
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _guess_kind(fru_name: str) -> str:
|
|
96
|
+
n = fru_name.lower()
|
|
97
|
+
if "psu" in n or "power" in n:
|
|
98
|
+
return "psu"
|
|
99
|
+
if "fan" in n:
|
|
100
|
+
return "fan"
|
|
101
|
+
if "nic" in n or "net" in n:
|
|
102
|
+
return "nic"
|
|
103
|
+
return "other"
|