vmware-storage 1.2.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.
- vmware_storage/__init__.py +3 -0
- vmware_storage/cli.py +291 -0
- vmware_storage/config.py +133 -0
- vmware_storage/connection.py +99 -0
- vmware_storage/doctor.py +151 -0
- vmware_storage/notify/__init__.py +0 -0
- vmware_storage/notify/audit.py +88 -0
- vmware_storage/ops/__init__.py +0 -0
- vmware_storage/ops/datastore_browser.py +219 -0
- vmware_storage/ops/inventory.py +89 -0
- vmware_storage/ops/iscsi_config.py +205 -0
- vmware_storage/ops/vsan.py +148 -0
- vmware_storage-1.2.0.dist-info/METADATA +148 -0
- vmware_storage-1.2.0.dist-info/RECORD +17 -0
- vmware_storage-1.2.0.dist-info/WHEEL +4 -0
- vmware_storage-1.2.0.dist-info/entry_points.txt +3 -0
- vmware_storage-1.2.0.dist-info/licenses/LICENSE +21 -0
vmware_storage/cli.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""vmware-storage CLI — Datastore, iSCSI, and vSAN management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from vmware_storage.config import load_config
|
|
14
|
+
from vmware_storage.connection import ConnectionManager
|
|
15
|
+
from vmware_storage.notify.audit import AuditLogger
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="vmware-storage",
|
|
19
|
+
help="VMware vSphere storage management: datastores, iSCSI, vSAN.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
_audit = AuditLogger()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Shared helpers
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_connection(target: str | None, config_path: str | None = None):
|
|
33
|
+
config = load_config(Path(config_path) if config_path else None)
|
|
34
|
+
conn_mgr = ConnectionManager(config)
|
|
35
|
+
return conn_mgr.connect(target)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _print_json(data) -> None:
|
|
39
|
+
console.print_json(json.dumps(data, default=str, ensure_ascii=False))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _double_confirm(action: str, detail: str) -> bool:
|
|
43
|
+
"""Two-step confirmation for destructive operations."""
|
|
44
|
+
console.print(f"\n[bold yellow]WARNING:[/] {action}")
|
|
45
|
+
console.print(f" {detail}\n")
|
|
46
|
+
first = typer.confirm("Are you sure?", default=False)
|
|
47
|
+
if not first:
|
|
48
|
+
console.print("[dim]Cancelled.[/]")
|
|
49
|
+
return False
|
|
50
|
+
second = typer.confirm("This modifies host storage configuration. Confirm again?", default=False)
|
|
51
|
+
if not second:
|
|
52
|
+
console.print("[dim]Cancelled.[/]")
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Datastore commands
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
ds_app = typer.Typer(help="Datastore browsing and image discovery.")
|
|
62
|
+
app.add_typer(ds_app, name="datastore")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@ds_app.command("list")
|
|
66
|
+
def ds_list(
|
|
67
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
68
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""List all datastores with capacity info."""
|
|
71
|
+
from vmware_storage.ops.inventory import list_datastores
|
|
72
|
+
si = _get_connection(target, config)
|
|
73
|
+
result = list_datastores(si)
|
|
74
|
+
_audit.log_query(target=target or "default", resource="datastores", query_type="list")
|
|
75
|
+
table = Table(title="Datastores")
|
|
76
|
+
table.add_column("Name", style="bold")
|
|
77
|
+
table.add_column("Type")
|
|
78
|
+
table.add_column("Total GB", justify="right")
|
|
79
|
+
table.add_column("Free GB", justify="right")
|
|
80
|
+
table.add_column("Usage %", justify="right")
|
|
81
|
+
table.add_column("VMs", justify="right")
|
|
82
|
+
for ds in result:
|
|
83
|
+
usage_style = "red" if ds["usage_pct"] > 85 else ""
|
|
84
|
+
table.add_row(
|
|
85
|
+
ds["name"], ds["type"],
|
|
86
|
+
str(ds["total_gb"]), str(ds["free_gb"]),
|
|
87
|
+
f"[{usage_style}]{ds['usage_pct']}%[/]",
|
|
88
|
+
str(ds["vm_count"]),
|
|
89
|
+
)
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@ds_app.command("browse")
|
|
94
|
+
def ds_browse(
|
|
95
|
+
ds_name: str = typer.Argument(help="Datastore name"),
|
|
96
|
+
path: str = typer.Option("", help="Subdirectory path"),
|
|
97
|
+
pattern: str = typer.Option("*", help="Glob pattern"),
|
|
98
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
99
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Browse files in a datastore."""
|
|
102
|
+
from vmware_storage.ops.datastore_browser import browse_datastore
|
|
103
|
+
si = _get_connection(target, config)
|
|
104
|
+
result = browse_datastore(si, ds_name, path=path, pattern=pattern)
|
|
105
|
+
_audit.log_query(target=target or "default", resource=ds_name, query_type="browse")
|
|
106
|
+
_print_json(result)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@ds_app.command("scan-images")
|
|
110
|
+
def ds_scan_images(
|
|
111
|
+
ds_name: str = typer.Argument(help="Datastore name"),
|
|
112
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
113
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Scan a datastore for deployable images (OVA/ISO/OVF/VMDK)."""
|
|
116
|
+
from vmware_storage.ops.datastore_browser import scan_images
|
|
117
|
+
si = _get_connection(target, config)
|
|
118
|
+
result = scan_images(si, ds_name)
|
|
119
|
+
_audit.log_query(target=target or "default", resource=ds_name, query_type="scan_images")
|
|
120
|
+
_print_json(result)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# iSCSI commands
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
iscsi_app = typer.Typer(help="iSCSI adapter and target management.")
|
|
128
|
+
app.add_typer(iscsi_app, name="iscsi")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@iscsi_app.command("enable")
|
|
132
|
+
def iscsi_enable(
|
|
133
|
+
host_name: str = typer.Argument(help="ESXi host name"),
|
|
134
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without executing"),
|
|
135
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
136
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Enable software iSCSI adapter on a host."""
|
|
139
|
+
if dry_run:
|
|
140
|
+
console.print(f"[dim][DRY-RUN] Would enable software iSCSI on host '{host_name}'[/]")
|
|
141
|
+
return
|
|
142
|
+
if not _double_confirm(
|
|
143
|
+
"Enable software iSCSI adapter",
|
|
144
|
+
f"Host: {host_name}",
|
|
145
|
+
):
|
|
146
|
+
return
|
|
147
|
+
from vmware_storage.ops.iscsi_config import enable_software_iscsi
|
|
148
|
+
si = _get_connection(target, config)
|
|
149
|
+
result = enable_software_iscsi(si, host_name)
|
|
150
|
+
_audit.log(target=target or "default", operation="iscsi_enable",
|
|
151
|
+
resource=host_name, parameters={"host_name": host_name}, result=result)
|
|
152
|
+
console.print(result)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@iscsi_app.command("status")
|
|
156
|
+
def iscsi_status(
|
|
157
|
+
host_name: str = typer.Argument(help="ESXi host name"),
|
|
158
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
159
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Show iSCSI adapter status and targets."""
|
|
162
|
+
from vmware_storage.ops.iscsi_config import get_iscsi_status
|
|
163
|
+
si = _get_connection(target, config)
|
|
164
|
+
_print_json(get_iscsi_status(si, host_name))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@iscsi_app.command("add-target")
|
|
168
|
+
def iscsi_add_target(
|
|
169
|
+
host_name: str = typer.Argument(help="ESXi host name"),
|
|
170
|
+
address: str = typer.Argument(help="iSCSI target IP address"),
|
|
171
|
+
port: int = typer.Option(3260, help="iSCSI target port"),
|
|
172
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without executing"),
|
|
173
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
174
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Add an iSCSI send target and rescan storage."""
|
|
177
|
+
if dry_run:
|
|
178
|
+
console.print(
|
|
179
|
+
f"[dim][DRY-RUN] Would add iSCSI target {address}:{port} "
|
|
180
|
+
f"to host '{host_name}' and rescan[/]"
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
if not _double_confirm(
|
|
184
|
+
"Add iSCSI send target + rescan storage",
|
|
185
|
+
f"Host: {host_name} Target: {address}:{port}",
|
|
186
|
+
):
|
|
187
|
+
return
|
|
188
|
+
from vmware_storage.ops.iscsi_config import add_iscsi_target
|
|
189
|
+
si = _get_connection(target, config)
|
|
190
|
+
result = add_iscsi_target(si, host_name, address, port)
|
|
191
|
+
_audit.log(target=target or "default", operation="iscsi_add_target",
|
|
192
|
+
resource=host_name,
|
|
193
|
+
parameters={"host_name": host_name, "address": address, "port": port},
|
|
194
|
+
result=result)
|
|
195
|
+
console.print(result)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@iscsi_app.command("remove-target")
|
|
199
|
+
def iscsi_remove_target(
|
|
200
|
+
host_name: str = typer.Argument(help="ESXi host name"),
|
|
201
|
+
address: str = typer.Argument(help="iSCSI target IP address"),
|
|
202
|
+
port: int = typer.Option(3260, help="iSCSI target port"),
|
|
203
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without executing"),
|
|
204
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
205
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Remove an iSCSI send target and rescan storage."""
|
|
208
|
+
if dry_run:
|
|
209
|
+
console.print(
|
|
210
|
+
f"[dim][DRY-RUN] Would remove iSCSI target {address}:{port} "
|
|
211
|
+
f"from host '{host_name}' and rescan[/]"
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
if not _double_confirm(
|
|
215
|
+
"Remove iSCSI send target + rescan storage",
|
|
216
|
+
f"Host: {host_name} Target: {address}:{port}",
|
|
217
|
+
):
|
|
218
|
+
return
|
|
219
|
+
from vmware_storage.ops.iscsi_config import remove_iscsi_target
|
|
220
|
+
si = _get_connection(target, config)
|
|
221
|
+
result = remove_iscsi_target(si, host_name, address, port)
|
|
222
|
+
_audit.log(target=target or "default", operation="iscsi_remove_target",
|
|
223
|
+
resource=host_name,
|
|
224
|
+
parameters={"host_name": host_name, "address": address, "port": port},
|
|
225
|
+
result=result)
|
|
226
|
+
console.print(result)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@iscsi_app.command("rescan")
|
|
230
|
+
def iscsi_rescan(
|
|
231
|
+
host_name: str = typer.Argument(help="ESXi host name"),
|
|
232
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without executing"),
|
|
233
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
234
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Rescan all HBAs and VMFS volumes."""
|
|
237
|
+
if dry_run:
|
|
238
|
+
console.print(f"[dim][DRY-RUN] Would rescan all HBAs and VMFS on host '{host_name}'[/]")
|
|
239
|
+
return
|
|
240
|
+
from vmware_storage.ops.iscsi_config import rescan_storage
|
|
241
|
+
si = _get_connection(target, config)
|
|
242
|
+
result = rescan_storage(si, host_name)
|
|
243
|
+
_audit.log(target=target or "default", operation="storage_rescan",
|
|
244
|
+
resource=host_name, parameters={"host_name": host_name}, result=result)
|
|
245
|
+
console.print(result)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# vSAN commands
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
vsan_app = typer.Typer(help="vSAN health and capacity monitoring.")
|
|
253
|
+
app.add_typer(vsan_app, name="vsan")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@vsan_app.command("health")
|
|
257
|
+
def vsan_health_cmd(
|
|
258
|
+
cluster_name: str = typer.Argument(help="Cluster name"),
|
|
259
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
260
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Get vSAN cluster health summary."""
|
|
263
|
+
from vmware_storage.ops.vsan import get_vsan_health
|
|
264
|
+
si = _get_connection(target, config)
|
|
265
|
+
_print_json(get_vsan_health(si, cluster_name))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@vsan_app.command("capacity")
|
|
269
|
+
def vsan_capacity_cmd(
|
|
270
|
+
cluster_name: str = typer.Argument(help="Cluster name"),
|
|
271
|
+
target: str | None = typer.Option(None, help="Target name"),
|
|
272
|
+
config: str | None = typer.Option(None, "--config", help="Config file path"),
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Get vSAN capacity overview."""
|
|
275
|
+
from vmware_storage.ops.vsan import get_vsan_capacity
|
|
276
|
+
si = _get_connection(target, config)
|
|
277
|
+
_print_json(get_vsan_capacity(si, cluster_name))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Doctor
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.command()
|
|
286
|
+
def doctor(
|
|
287
|
+
skip_auth: bool = typer.Option(False, "--skip-auth", help="Skip auth check"),
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Run environment diagnostics."""
|
|
290
|
+
from vmware_storage.doctor import run_doctor
|
|
291
|
+
sys.exit(run_doctor(skip_auth=skip_auth))
|
vmware_storage/config.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Configuration management for VMware Storage.
|
|
2
|
+
|
|
3
|
+
Loads targets and settings from YAML config file + environment variables.
|
|
4
|
+
Passwords are NEVER stored in config files — always via environment variables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = Path.home() / ".vmware-storage"
|
|
20
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
21
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
22
|
+
|
|
23
|
+
_log = logging.getLogger("vmware-storage.config")
|
|
24
|
+
|
|
25
|
+
# Load passwords from .env file (if exists) before any config access
|
|
26
|
+
load_dotenv(ENV_FILE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _check_env_permissions() -> None:
|
|
30
|
+
"""Warn if .env file has permissions wider than owner-only (600)."""
|
|
31
|
+
if not ENV_FILE.exists():
|
|
32
|
+
return
|
|
33
|
+
try:
|
|
34
|
+
mode = ENV_FILE.stat().st_mode
|
|
35
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
36
|
+
_log.warning(
|
|
37
|
+
"Security warning: %s has permissions %s (should be 600). "
|
|
38
|
+
"Run: chmod 600 %s",
|
|
39
|
+
ENV_FILE,
|
|
40
|
+
oct(stat.S_IMODE(mode)),
|
|
41
|
+
ENV_FILE,
|
|
42
|
+
)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_check_env_permissions()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class TargetConfig:
|
|
52
|
+
"""A vCenter or ESXi connection target."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
host: str
|
|
56
|
+
username: str
|
|
57
|
+
type: Literal["vcenter", "esxi"] = "vcenter"
|
|
58
|
+
port: int = 443
|
|
59
|
+
verify_ssl: bool = False
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def password(self) -> str:
|
|
63
|
+
env_key = f"VMWARE_{self.name.upper().replace('-', '_')}_PASSWORD"
|
|
64
|
+
pw = os.environ.get(env_key, "")
|
|
65
|
+
if not pw:
|
|
66
|
+
raise OSError(
|
|
67
|
+
f"Password not found. Set environment variable: {env_key}"
|
|
68
|
+
)
|
|
69
|
+
return pw
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class NotifyConfig:
|
|
74
|
+
"""Notification settings."""
|
|
75
|
+
|
|
76
|
+
log_file: str = str(CONFIG_DIR / "scan.log")
|
|
77
|
+
webhook_url: str = ""
|
|
78
|
+
webhook_timeout: int = 10
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class AppConfig:
|
|
83
|
+
"""Top-level application config."""
|
|
84
|
+
|
|
85
|
+
targets: tuple[TargetConfig, ...] = ()
|
|
86
|
+
notify: NotifyConfig = field(default_factory=NotifyConfig)
|
|
87
|
+
|
|
88
|
+
def get_target(self, name: str) -> TargetConfig:
|
|
89
|
+
for t in self.targets:
|
|
90
|
+
if t.name == name:
|
|
91
|
+
return t
|
|
92
|
+
available = ", ".join(t.name for t in self.targets)
|
|
93
|
+
raise KeyError(f"Target '{name}' not found. Available: {available}")
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def default_target(self) -> TargetConfig:
|
|
97
|
+
if not self.targets:
|
|
98
|
+
raise ValueError("No targets configured. Check config.yaml")
|
|
99
|
+
return self.targets[0]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_config(config_path: Path | None = None) -> AppConfig:
|
|
103
|
+
"""Load config from YAML file, with env var overrides for passwords."""
|
|
104
|
+
path = config_path or CONFIG_FILE
|
|
105
|
+
if not path.exists():
|
|
106
|
+
raise FileNotFoundError(
|
|
107
|
+
f"Config file not found: {path}\n"
|
|
108
|
+
f"Copy config.example.yaml to {CONFIG_FILE} and edit it."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
with open(path) as f:
|
|
112
|
+
raw = yaml.safe_load(f) or {}
|
|
113
|
+
|
|
114
|
+
targets = tuple(
|
|
115
|
+
TargetConfig(
|
|
116
|
+
name=t["name"],
|
|
117
|
+
host=t["host"],
|
|
118
|
+
username=t.get("username", "administrator@vsphere.local"),
|
|
119
|
+
type=t.get("type", "vcenter"),
|
|
120
|
+
port=t.get("port", 443),
|
|
121
|
+
verify_ssl=t.get("verify_ssl", False),
|
|
122
|
+
)
|
|
123
|
+
for t in raw.get("targets", [])
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
notify_raw = raw.get("notify", {})
|
|
127
|
+
notify = NotifyConfig(
|
|
128
|
+
log_file=notify_raw.get("log_file", str(CONFIG_DIR / "scan.log")),
|
|
129
|
+
webhook_url=notify_raw.get("webhook_url", ""),
|
|
130
|
+
webhook_timeout=notify_raw.get("webhook_timeout", 10),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return AppConfig(targets=targets, notify=notify)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Connection management for vCenter and ESXi hosts.
|
|
2
|
+
|
|
3
|
+
Handles multi-target connections via pyVmomi with session reuse.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import atexit
|
|
9
|
+
import ssl
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from pyVmomi import vim, vmodl
|
|
13
|
+
from pyVmomi.VmomiSupport import VmomiJSONEncoder # noqa: F401
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pyVmomi.vim import ServiceInstance
|
|
17
|
+
|
|
18
|
+
from vmware_storage.config import AppConfig, TargetConfig, load_config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectionManager:
|
|
22
|
+
"""Manages connections to multiple vCenter/ESXi targets."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: AppConfig) -> None:
|
|
25
|
+
self._config = config
|
|
26
|
+
self._connections: dict[str, ServiceInstance] = {}
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_config(cls, config: AppConfig | None = None) -> ConnectionManager:
|
|
30
|
+
cfg = config or load_config()
|
|
31
|
+
return cls(cfg)
|
|
32
|
+
|
|
33
|
+
def connect(self, target_name: str | None = None) -> ServiceInstance:
|
|
34
|
+
"""Connect to a target by name, or the default target."""
|
|
35
|
+
target = (
|
|
36
|
+
self._config.get_target(target_name)
|
|
37
|
+
if target_name
|
|
38
|
+
else self._config.default_target
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if target.name in self._connections:
|
|
42
|
+
si = self._connections[target.name]
|
|
43
|
+
try:
|
|
44
|
+
_ = si.content.sessionManager.currentSession
|
|
45
|
+
return si
|
|
46
|
+
except (vmodl.fault.NotAuthenticated, Exception):
|
|
47
|
+
del self._connections[target.name]
|
|
48
|
+
|
|
49
|
+
si = self._create_connection(target)
|
|
50
|
+
self._connections[target.name] = si
|
|
51
|
+
return si
|
|
52
|
+
|
|
53
|
+
def disconnect(self, target_name: str) -> None:
|
|
54
|
+
"""Disconnect from a specific target."""
|
|
55
|
+
if target_name in self._connections:
|
|
56
|
+
from pyVim.connect import Disconnect
|
|
57
|
+
|
|
58
|
+
Disconnect(self._connections[target_name])
|
|
59
|
+
del self._connections[target_name]
|
|
60
|
+
|
|
61
|
+
def disconnect_all(self) -> None:
|
|
62
|
+
"""Disconnect from all targets."""
|
|
63
|
+
for name in list(self._connections):
|
|
64
|
+
self.disconnect(name)
|
|
65
|
+
|
|
66
|
+
def list_targets(self) -> list[str]:
|
|
67
|
+
"""List all configured target names."""
|
|
68
|
+
return [t.name for t in self._config.targets]
|
|
69
|
+
|
|
70
|
+
def list_connected(self) -> list[str]:
|
|
71
|
+
"""List currently connected target names."""
|
|
72
|
+
return list(self._connections.keys())
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _create_connection(target: TargetConfig) -> ServiceInstance:
|
|
76
|
+
"""Create a new pyVmomi connection."""
|
|
77
|
+
from pyVim.connect import Disconnect, SmartConnect
|
|
78
|
+
|
|
79
|
+
context = None
|
|
80
|
+
if not target.verify_ssl:
|
|
81
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
82
|
+
context.check_hostname = False
|
|
83
|
+
context.verify_mode = ssl.CERT_NONE
|
|
84
|
+
|
|
85
|
+
si = SmartConnect(
|
|
86
|
+
host=target.host,
|
|
87
|
+
user=target.username,
|
|
88
|
+
pwd=target.password,
|
|
89
|
+
port=target.port,
|
|
90
|
+
sslContext=context,
|
|
91
|
+
disableSslCertValidation=not target.verify_ssl,
|
|
92
|
+
)
|
|
93
|
+
atexit.register(Disconnect, si)
|
|
94
|
+
return si
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_content(si: ServiceInstance) -> vim.ServiceInstanceContent:
|
|
98
|
+
"""Shortcut to get ServiceContent from a ServiceInstance."""
|
|
99
|
+
return si.RetrieveContent()
|
vmware_storage/doctor.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""vmware-storage doctor — environment and connectivity diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import stat
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from vmware_storage.config import CONFIG_DIR, CONFIG_FILE, ENV_FILE
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
_PASS = "[green]\u2713[/]"
|
|
20
|
+
_FAIL = "[red]\u2717[/]"
|
|
21
|
+
_INFO = "[cyan]i[/]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _check(label: str, fn: Callable[[], tuple[bool, str]]) -> tuple[bool, str, str]:
|
|
25
|
+
try:
|
|
26
|
+
ok, msg = fn()
|
|
27
|
+
return ok, label, msg
|
|
28
|
+
except Exception as e:
|
|
29
|
+
return False, label, f"Error: {e}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_config_file() -> tuple[bool, str]:
|
|
33
|
+
if CONFIG_FILE.exists():
|
|
34
|
+
return True, f"Config found: {CONFIG_FILE}"
|
|
35
|
+
return False, f"Config not found: {CONFIG_FILE}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_env_file() -> tuple[bool, str]:
|
|
39
|
+
if not ENV_FILE.exists():
|
|
40
|
+
return False, f".env not found: {ENV_FILE}"
|
|
41
|
+
mode = ENV_FILE.stat().st_mode
|
|
42
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
43
|
+
return False, f".env permissions too open ({oct(stat.S_IMODE(mode))}) — Run: chmod 600 {ENV_FILE}"
|
|
44
|
+
return True, f".env found with correct permissions (600): {ENV_FILE}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_targets() -> tuple[bool, str]:
|
|
48
|
+
if not CONFIG_FILE.exists():
|
|
49
|
+
return False, "Config file missing"
|
|
50
|
+
import yaml
|
|
51
|
+
with open(CONFIG_FILE) as f:
|
|
52
|
+
raw = yaml.safe_load(f) or {}
|
|
53
|
+
targets = raw.get("targets", [])
|
|
54
|
+
if not targets:
|
|
55
|
+
return False, "No targets configured in config.yaml"
|
|
56
|
+
names = [t.get("name", "?") for t in targets]
|
|
57
|
+
return True, f"{len(targets)} target(s) configured: {', '.join(names)}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_connectivity() -> tuple[bool, str]:
|
|
61
|
+
if not CONFIG_FILE.exists():
|
|
62
|
+
return False, "Config file missing"
|
|
63
|
+
import yaml
|
|
64
|
+
with open(CONFIG_FILE) as f:
|
|
65
|
+
raw = yaml.safe_load(f) or {}
|
|
66
|
+
targets = raw.get("targets", [])
|
|
67
|
+
if not targets:
|
|
68
|
+
return False, "No targets to check"
|
|
69
|
+
|
|
70
|
+
results = []
|
|
71
|
+
all_ok = True
|
|
72
|
+
for t in targets:
|
|
73
|
+
host = t.get("host", "")
|
|
74
|
+
port = t.get("port", 443)
|
|
75
|
+
try:
|
|
76
|
+
sock = socket.create_connection((host, port), timeout=5)
|
|
77
|
+
sock.close()
|
|
78
|
+
results.append(f"{host}:{port} \u2713")
|
|
79
|
+
except OSError as e:
|
|
80
|
+
results.append(f"{host}:{port} \u2717 ({e})")
|
|
81
|
+
all_ok = False
|
|
82
|
+
return all_ok, " ".join(results)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_auth() -> tuple[bool, str]:
|
|
86
|
+
if not CONFIG_FILE.exists():
|
|
87
|
+
return False, "Config file missing"
|
|
88
|
+
try:
|
|
89
|
+
from vmware_storage.config import load_config
|
|
90
|
+
from vmware_storage.connection import ConnectionManager
|
|
91
|
+
config = load_config()
|
|
92
|
+
if not config.targets:
|
|
93
|
+
return False, "No targets configured"
|
|
94
|
+
conn_mgr = ConnectionManager(config)
|
|
95
|
+
target = config.default_target
|
|
96
|
+
conn_mgr.connect(target.name)
|
|
97
|
+
conn_mgr.disconnect_all()
|
|
98
|
+
return True, f"Authentication OK for target '{target.name}'"
|
|
99
|
+
except KeyError as e:
|
|
100
|
+
return False, f"Missing password env var: {e}"
|
|
101
|
+
except Exception as e:
|
|
102
|
+
return False, f"Auth failed: {e}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _check_mcp_server() -> tuple[bool, str]:
|
|
106
|
+
try:
|
|
107
|
+
import importlib
|
|
108
|
+
importlib.import_module("mcp_server.server")
|
|
109
|
+
return True, "MCP server module loads OK"
|
|
110
|
+
except ImportError as e:
|
|
111
|
+
return False, f"MCP server import failed: {e}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_CHECKS: list[tuple[str, Callable[[], tuple[bool, str]]]] = [
|
|
115
|
+
("Config file", _check_config_file),
|
|
116
|
+
(".env file", _check_env_file),
|
|
117
|
+
("Targets configured", _check_targets),
|
|
118
|
+
("Network connectivity", _check_connectivity),
|
|
119
|
+
("vSphere authentication", _check_auth),
|
|
120
|
+
("MCP server", _check_mcp_server),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_doctor(skip_auth: bool = False) -> int:
|
|
125
|
+
"""Run all checks and print results. Returns exit code (0 = all pass)."""
|
|
126
|
+
console.print("\n[bold]vmware-storage doctor[/]\n")
|
|
127
|
+
|
|
128
|
+
table = Table(show_header=True, header_style="bold")
|
|
129
|
+
table.add_column("", width=3)
|
|
130
|
+
table.add_column("Check", style="bold", min_width=25)
|
|
131
|
+
table.add_column("Result")
|
|
132
|
+
|
|
133
|
+
failures = 0
|
|
134
|
+
for label, fn in _CHECKS:
|
|
135
|
+
if skip_auth and label == "vSphere authentication":
|
|
136
|
+
table.add_row(_INFO, label, "[dim]skipped (--skip-auth)[/]")
|
|
137
|
+
continue
|
|
138
|
+
ok, lbl, msg = _check(label, fn)
|
|
139
|
+
icon = _PASS if ok else _FAIL
|
|
140
|
+
if not ok:
|
|
141
|
+
failures += 1
|
|
142
|
+
table.add_row(icon, lbl, msg)
|
|
143
|
+
|
|
144
|
+
console.print(table)
|
|
145
|
+
|
|
146
|
+
if failures == 0:
|
|
147
|
+
console.print("\n[green bold]\u2713 All checks passed.[/]\n")
|
|
148
|
+
else:
|
|
149
|
+
console.print(f"\n[red bold]\u2717 {failures} check(s) failed.[/]\n")
|
|
150
|
+
|
|
151
|
+
return 0 if failures == 0 else 1
|
|
File without changes
|