vmware-storage 2.3.0__tar.gz → 2.3.1__tar.gz
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-2.3.0 → vmware_storage-2.3.1}/.claude-plugin/plugin.json +1 -1
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.mcp.json +1 -1
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/PKG-INFO +9 -2
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/README-CN.md +6 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/README.md +7 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/RELEASE_NOTES.md +15 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/openclaw.plugin.json +1 -1
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/package.json +1 -1
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/pyproject.toml +2 -2
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/server.json +2 -2
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/SKILL.md +6 -2
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/setup-guide.md +10 -3
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +12 -6
- vmware_storage-2.3.1/tests/eval/regression/test_login_without_a_role.py +215 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/uv.lock +5 -5
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/__init__.py +1 -1
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/cli.py +6 -2
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/connection.py +29 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/mcp_server/server.py +40 -12
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.clawhubignore +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.env.example +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.gitignore +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/Dockerfile +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/LICENSE +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/SECURITY.md +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/config.example.yaml +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/docker-compose.yml +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/README.md +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/claude-code.json +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/continue.yaml +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/cursor.json +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/goose.json +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/localcowork.json +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/mcp-agent.yaml +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/vscode-copilot.json +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/patterns/README.md +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/patterns/iscsi-target-stale-rescan.yaml +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/evals/evals.json +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/agent-guardrails.md +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/capabilities.md +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/cli-reference.md +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/smithery.yaml +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/conftest.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_family.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_scores.json +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_scoring.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_skill.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/conftest.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_entity_reachability.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_error_actionability.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_tool_description_quality.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_audit_isolation.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_cached_loader_agrees_with_the_resolver.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_capability_grader.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_credential_env_override.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_datastore_list_renders.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_doctor_uses_the_config_the_tools_use.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_fc_multipath.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_instructions_name_targets.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_inventory_scale_n1.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_iscsi_unreachable_host.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_release_blockers.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_remedy_survives_a_long_home.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_result_envelope.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_session_probe_eviction.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_storage_hardening_fixes.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_storage_ops_behavior.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_tool_names_2_0.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vsan_efficiency.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vsan_health_is_actually_queried.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vsan_reports_what_it_could_not_read.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/spec/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/spec/vsan_efficiency_endpoints.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/spec/vsan_health_endpoints.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_access_info.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_init_and_onboarding.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_iscsi_gate.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_no_destructive_ops.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_no_destructive_vm_code.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_safe_error_passthrough.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_session_pool.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_stop_signal_runs_atexit.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/access_requirements.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/config.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/doctor.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/init_wizard.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/mcp_server/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/mcp_server/__main__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/notify/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/notify/audit.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/__init__.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/datastore_browser.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/inventory.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/iscsi_config.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/iscsi_gate.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/multipath.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/storage_paths.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/vsan.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/vsan_efficiency.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/vsan_sdk.py +0 -0
- {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/policy_environment.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "vmware-storage",
|
|
4
4
|
"displayName": "VMware Storage",
|
|
5
|
-
"version": "2.3.
|
|
5
|
+
"version": "2.3.1",
|
|
6
6
|
"description": "VMware vSphere storage with 15 MCP tools: datastores, iSCSI, vSAN, FC multipath.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Wei Zhou",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vmware-storage
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: VMware vSphere storage management: datastores, iSCSI, vSAN. Domain-focused MCP skill.
|
|
5
5
|
Project-URL: Homepage, https://github.com/vmware-skills/VMware-Storage
|
|
6
6
|
Project-URL: Repository, https://github.com/vmware-skills/VMware-Storage
|
|
@@ -16,7 +16,7 @@ Requires-Dist: python-dotenv<2.0,>=1.0
|
|
|
16
16
|
Requires-Dist: pyvmomi<10.0,>=8.0.3.0
|
|
17
17
|
Requires-Dist: rich<15.0,>=13.0
|
|
18
18
|
Requires-Dist: typer<1.0,>=0.12
|
|
19
|
-
Requires-Dist: vmware-policy<2.0,>=1.
|
|
19
|
+
Requires-Dist: vmware-policy<2.0,>=1.23.0
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
|
|
22
22
|
<!-- mcp-name: io.github.vmware-skills/vmware-storage -->
|
|
@@ -276,6 +276,7 @@ vmware-storage-mcp
|
|
|
276
276
|
| `get_vsan_health` returns `overall_health: null` | `null` = not asked (see `health_not_queried_reason`), never a measurement; a string is always vSAN's own answer, including its own `"unknown"`. Usual cause: connected to standalone ESXi, but the health service runs in **vCenter**. |
|
|
277
277
|
| Rescan doesn't discover new LUNs | Wait 15-30 seconds after adding targets, then rescan again. Verify target IP is reachable from ESXi. |
|
|
278
278
|
| "Password not found" error | Variable names follow `VMWARE_<TARGET_UPPER>_PASSWORD` (hyphens → underscores). Check `~/.vmware-storage/.env`. |
|
|
279
|
+
| `doctor` fails with "no vSphere role", or every command fails with "session is not authenticated" | The login works but the account has no vSphere role. Assign one (Read-Only or higher) on the vCenter root folder (Administration > Access Control > Global Permissions). Mid-run, the same fault can be an expired session: run the command again. |
|
|
279
280
|
| Connection timeout to vCenter | Use `vmware-storage doctor --skip-auth` to bypass auth checks on high-latency networks. |
|
|
280
281
|
| "Datastore browse did not finish within Ns" | The datastore is very large or busy. Narrow the search with a sub-path and a specific pattern (e.g. `datastore browse ds01 --path templates --pattern "*.ova"`) instead of browsing the root — do not just retry the same broad browse. |
|
|
281
282
|
|
|
@@ -319,6 +320,12 @@ claude mcp add --transport http vmware-storage https://mcp.example.com:8443/mcp
|
|
|
319
320
|
before it binds that unauthenticated requests get 401. Use a certificate your clients trust for those names.
|
|
320
321
|
- Behind a TLS-terminating reverse proxy on the same machine use `--transport http --host 127.0.0.1
|
|
321
322
|
--public-name mcp.example.com` and pass the `Authorization` header through.
|
|
323
|
+
- Behind a load balancer or gateway on another machine, keep `--transport https` (a self-signed certificate is
|
|
324
|
+
fine), list in `--public-name` the host name the balancer sends as `Host`, and have it forward `Authorization`.
|
|
325
|
+
Its health probe can use `GET /healthz`, which needs no token and returns only `{"status": "ok"}`
|
|
326
|
+
(vmware-policy ≥ 1.23.0).
|
|
327
|
+
- Send `traceparent` or `X-Request-ID` with each request to have it stored on the audit row;
|
|
328
|
+
`vmware-audit log --correlation-id ID` then finds the calls behind one platform request.
|
|
322
329
|
- The audit log records each caller as `token:<name>`. Calls run side by side, also on the same target: the server keeps up to `VMWARE_MCP_POOL_SIZE` (default 2) sessions per target, each one a login on that target, and a call waits at most 300 s for a free session (vmware-policy ≥ 1.22.0).
|
|
323
330
|
- **Everyone who can reach the endpoint acts with this server's account.** Tokens identify callers; they grant
|
|
324
331
|
nothing on the platform. Run one endpoint per privilege level. `vmware-storage access-info` shows which account that is and what it may do.
|
|
@@ -290,6 +290,7 @@ vmware-storage-mcp
|
|
|
290
290
|
| `get_vsan_health` 返回 `overall_health: null` | `null` 表示**没有查询**(原因见 `health_not_queried_reason`),不是测量结果;只要是字符串就一定是 vSAN 自己给的答案,包括它自己的 `"unknown"`。常见原因:连的是独立 ESXi,而健康服务跑在 **vCenter** 上。 |
|
|
291
291
|
| 重扫后未发现新 LUN | 添加目标后等待 15-30 秒再重扫。确认 ESXi 能访问目标 IP。 |
|
|
292
292
|
| "Password not found" 错误 | 变量名规则:`VMWARE_<目标名大写>_PASSWORD`(连字符→下划线)。检查 `~/.vmware-storage/.env`。 |
|
|
293
|
+
| `doctor` 报 "no vSphere role",或每个命令都报 "session is not authenticated" | 能登录,但账号没有 vSphere 角色。在 vCenter 根文件夹上给它分配一个角色(Read-Only 或更高):Administration > Access Control > Global Permissions。运行中出现同一错误也可能是会话过期:重跑命令即可。 |
|
|
293
294
|
| 连接 vCenter 超时 | 使用 `vmware-storage doctor --skip-auth` 跳过高延迟网络的认证检查。 |
|
|
294
295
|
|
|
295
296
|
<!-- usage: remote-and-exposure -->
|
|
@@ -332,6 +333,11 @@ claude mcp add --transport http vmware-storage https://mcp.example.com:8443/mcp
|
|
|
332
333
|
证书要用客户端信任、且覆盖这些名字的证书。
|
|
333
334
|
- 在同一台机器上由反向代理终结 TLS 时,用 `--transport http --host 127.0.0.1 --public-name mcp.example.com`,
|
|
334
335
|
并让代理透传 `Authorization` 头。
|
|
336
|
+
- 负载均衡或网关在另一台机器上时,保持 `--transport https`(自签证书也可以),在 `--public-name` 里写上负载均衡
|
|
337
|
+
转发时带的 `Host` 名,并让它透传 `Authorization`。健康检查可以用 `GET /healthz`:不需要 token,只返回
|
|
338
|
+
`{"status": "ok"}`(需 vmware-policy ≥ 1.23.0)。
|
|
339
|
+
- 每个请求带上 `traceparent` 或 `X-Request-ID`,它会记进审计记录;用 `vmware-audit log --correlation-id ID`
|
|
340
|
+
就能查到平台上某一次请求对应的调用。
|
|
335
341
|
- 审计日志把每个调用者记为 `token:<名字>`。调用并行执行,同一 target 上也一样:server 为每个 target 最多保持 `VMWARE_MCP_POOL_SIZE`(默认 2)个会话,每个会话都是在该 target 上的一次登录;一个调用最多等 300 秒拿到空闲会话(vmware-policy ≥ 1.22.0)。
|
|
336
342
|
- **能连到这个端点的人,都以该 server 配置的账号操作。** token 只用来识别调用者,不在平台上授予任何权限。
|
|
337
343
|
每个权限级别单独起一个端点。`vmware-storage access-info` 可查看是哪个账号、它能做什么。
|
|
@@ -255,6 +255,7 @@ vmware-storage-mcp
|
|
|
255
255
|
| `get_vsan_health` returns `overall_health: null` | `null` = not asked (see `health_not_queried_reason`), never a measurement; a string is always vSAN's own answer, including its own `"unknown"`. Usual cause: connected to standalone ESXi, but the health service runs in **vCenter**. |
|
|
256
256
|
| Rescan doesn't discover new LUNs | Wait 15-30 seconds after adding targets, then rescan again. Verify target IP is reachable from ESXi. |
|
|
257
257
|
| "Password not found" error | Variable names follow `VMWARE_<TARGET_UPPER>_PASSWORD` (hyphens → underscores). Check `~/.vmware-storage/.env`. |
|
|
258
|
+
| `doctor` fails with "no vSphere role", or every command fails with "session is not authenticated" | The login works but the account has no vSphere role. Assign one (Read-Only or higher) on the vCenter root folder (Administration > Access Control > Global Permissions). Mid-run, the same fault can be an expired session: run the command again. |
|
|
258
259
|
| Connection timeout to vCenter | Use `vmware-storage doctor --skip-auth` to bypass auth checks on high-latency networks. |
|
|
259
260
|
| "Datastore browse did not finish within Ns" | The datastore is very large or busy. Narrow the search with a sub-path and a specific pattern (e.g. `datastore browse ds01 --path templates --pattern "*.ova"`) instead of browsing the root — do not just retry the same broad browse. |
|
|
260
261
|
|
|
@@ -298,6 +299,12 @@ claude mcp add --transport http vmware-storage https://mcp.example.com:8443/mcp
|
|
|
298
299
|
before it binds that unauthenticated requests get 401. Use a certificate your clients trust for those names.
|
|
299
300
|
- Behind a TLS-terminating reverse proxy on the same machine use `--transport http --host 127.0.0.1
|
|
300
301
|
--public-name mcp.example.com` and pass the `Authorization` header through.
|
|
302
|
+
- Behind a load balancer or gateway on another machine, keep `--transport https` (a self-signed certificate is
|
|
303
|
+
fine), list in `--public-name` the host name the balancer sends as `Host`, and have it forward `Authorization`.
|
|
304
|
+
Its health probe can use `GET /healthz`, which needs no token and returns only `{"status": "ok"}`
|
|
305
|
+
(vmware-policy ≥ 1.23.0).
|
|
306
|
+
- Send `traceparent` or `X-Request-ID` with each request to have it stored on the audit row;
|
|
307
|
+
`vmware-audit log --correlation-id ID` then finds the calls behind one platform request.
|
|
301
308
|
- The audit log records each caller as `token:<name>`. Calls run side by side, also on the same target: the server keeps up to `VMWARE_MCP_POOL_SIZE` (default 2) sessions per target, each one a login on that target, and a call waits at most 300 s for a free session (vmware-policy ≥ 1.22.0).
|
|
302
309
|
- **Everyone who can reach the endpoint acts with this server's account.** Tokens identify callers; they grant
|
|
303
310
|
nothing on the platform. Run one endpoint per privilege level. `vmware-storage access-info` shows which account that is and what it may do.
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## v2.3.1 — a login with no vSphere role is named as such (AIops#47)
|
|
2
|
+
|
|
3
|
+
* An account that logs in but has no vSphere role (vCenter shows no session for it) is now refused at connect time
|
|
4
|
+
with a message naming the missing role (assign Read-Only or higher), on the `doctor` auth row, the CLI and every
|
|
5
|
+
MCP tool. The session is logged out and never cached. Before, `doctor` passed and every call then failed with
|
|
6
|
+
"the session is not authenticated".
|
|
7
|
+
* Refused only when two signals agree: no session, **and** vCenter refuses a view of the root folder
|
|
8
|
+
(`NotAuthenticated` / `NoPermission`). An account scoped to one folder can also show no session, and still
|
|
9
|
+
connects; anything the check cannot read lets the login through.
|
|
10
|
+
* The MCP hint beside these two errors (tool errors and `get_access_info`) points at `doctor`'s role check
|
|
11
|
+
instead of "verify connectivity".
|
|
12
|
+
* `NotAuthenticated` raised later (session expired, or a role removed while running) now says both causes, on the
|
|
13
|
+
CLI (it was a raw traceback) and over MCP (it was `NotAuthenticated: operation failed.`).
|
|
14
|
+
* Requires vmware-policy 1.23.0.
|
|
15
|
+
|
|
1
16
|
## v2.3.0 — calls run side by side on one target; `get_access_info`
|
|
2
17
|
|
|
3
18
|
**Concurrent calls (HLD §16 D-13(2), issue #18).** On a shared HTTPS server, calls no longer wait for each other:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vmware-storage"
|
|
7
|
-
version = "2.3.
|
|
7
|
+
version = "2.3.1"
|
|
8
8
|
description = "VMware vSphere storage management: datastores, iSCSI, vSAN. Domain-focused MCP skill."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -17,7 +17,7 @@ dependencies = [
|
|
|
17
17
|
"rich>=13.0,<15.0",
|
|
18
18
|
"python-dotenv>=1.0,<2.0",
|
|
19
19
|
"mcp[cli]>=1.28,<2.0",
|
|
20
|
-
"vmware-policy>=1.
|
|
20
|
+
"vmware-policy>=1.23.0,<2.0",
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/vmware-skills/VMware-Storage",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.3.
|
|
10
|
+
"version": "2.3.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "vmware-storage",
|
|
15
|
-
"version": "2.3.
|
|
15
|
+
"version": "2.3.1",
|
|
16
16
|
"runtimeHint": "uvx",
|
|
17
17
|
"packageArguments": [
|
|
18
18
|
{
|
|
@@ -40,7 +40,7 @@ VMware vSphere storage management — 15 MCP tools for datastores, iSCSI, vSAN,
|
|
|
40
40
|
## Quick Install
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
uv tool install vmware-storage==2.3.
|
|
43
|
+
uv tool install vmware-storage==2.3.1
|
|
44
44
|
vmware-storage init # guided setup: writes config + .env (chmod 600, password grep-safe), then verifies
|
|
45
45
|
vmware-storage doctor
|
|
46
46
|
```
|
|
@@ -234,6 +234,10 @@ After adding iSCSI targets, the storage subsystem may need 10-30 seconds to enum
|
|
|
234
234
|
|
|
235
235
|
The password environment variable is missing. Variable names follow the pattern `VMWARE_<TARGET_NAME_UPPER>_PASSWORD` where hyphens become underscores. Example: target `my-vcenter` needs `VMWARE_MY_VCENTER_PASSWORD`. Check your `~/.vmware-storage/.env` file.
|
|
236
236
|
|
|
237
|
+
### "Session is not authenticated" or "no vSphere role" right after login
|
|
238
|
+
|
|
239
|
+
The password is right; the account has no vSphere role, so vCenter shows no session for it and every call fails. `doctor` now reports this on the auth row. Assign the account a role (Read-Only or higher) on the vCenter root folder under Administration > Access Control > Global Permissions. Seen mid-run, the same fault can also be an expired session: run the command again.
|
|
240
|
+
|
|
237
241
|
### Doctor reports ".env permissions too open"
|
|
238
242
|
|
|
239
243
|
The `.env` file contains passwords and must have owner-only permissions:
|
|
@@ -267,7 +271,7 @@ Corporate TLS proxies inject certificates that uv's bundled CA store doesn't tru
|
|
|
267
271
|
## Setup
|
|
268
272
|
|
|
269
273
|
```bash
|
|
270
|
-
uv tool install vmware-storage==2.3.
|
|
274
|
+
uv tool install vmware-storage==2.3.1
|
|
271
275
|
mkdir -p ~/.vmware-storage
|
|
272
276
|
cp config.example.yaml ~/.vmware-storage/config.yaml
|
|
273
277
|
# Edit config.yaml with your vCenter/ESXi targets
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/setup-guide.md
RENAMED
|
@@ -13,19 +13,19 @@ Complete setup and security guide for `vmware-storage`.
|
|
|
13
13
|
### Via uv (recommended)
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
uv tool install vmware-storage==2.3.
|
|
16
|
+
uv tool install vmware-storage==2.3.1
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
### Via pip
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
pip install vmware-storage==2.3.
|
|
22
|
+
pip install vmware-storage==2.3.1
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
### From source
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
git clone --branch v2.3.
|
|
28
|
+
git clone --branch v2.3.1 https://github.com/vmware-skills/VMware-Storage.git
|
|
29
29
|
cd VMware-Storage
|
|
30
30
|
pip install -e .
|
|
31
31
|
```
|
|
@@ -235,6 +235,13 @@ vmware-storage mcp --transport https --host 0.0.0.0 --port 8443 \
|
|
|
235
235
|
- Behind a reverse proxy that terminates TLS (Nginx, HAProxy) on the same machine, use
|
|
236
236
|
`--transport http --host 127.0.0.1 --public-name mcp.example.com` (the names the proxy forwards in `Host`), and
|
|
237
237
|
have the proxy pass the `Authorization` header through.
|
|
238
|
+
- Behind a load balancer or gateway on another machine, keep `--transport https` (a self-signed certificate is
|
|
239
|
+
fine: the balancer is the client that has to trust it), list in `--public-name` the host name the balancer sends
|
|
240
|
+
as `Host`, and have it forward `Authorization`. Point its health probe at `GET /healthz`: it needs no token, returns
|
|
241
|
+
only `{"status": "ok"}` and accepts any `Host` (vmware-policy ≥ 1.23.0). Every other path still needs the token.
|
|
242
|
+
- Correlation: each audit row stores the caller's `traceparent` trace-id, else its `X-Request-ID` /
|
|
243
|
+
`X-Correlation-ID`. Have the agent platform send one, and `vmware-audit log --correlation-id ID` lists the calls
|
|
244
|
+
behind one platform request.
|
|
238
245
|
- `vmware-audit token list` / `token revoke NAME` manage callers; a revoked token is refused from its next request.
|
|
239
246
|
Replace a leaked token under a new name.
|
|
240
247
|
- The server will not start without at least one token, and checks before it binds that unauthenticated requests
|
|
@@ -43,9 +43,15 @@ def _target(name: str = "vcenter-prod") -> TargetConfig:
|
|
|
43
43
|
return TargetConfig(name=name, host="vc.example.com", config_username="svc")
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
def
|
|
47
|
-
"""An SI
|
|
46
|
+
def _live_si(*_a, **_kw):
|
|
47
|
+
"""An SI with a session; a login without one is refused (AIops#47)."""
|
|
48
48
|
si = MagicMock()
|
|
49
|
+
si.content.sessionManager.currentSession = MagicMock()
|
|
50
|
+
return si
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _expire(si: MagicMock) -> MagicMock:
|
|
54
|
+
"""The session expires after login, so the next connect() evicts it."""
|
|
49
55
|
si.content.sessionManager.currentSession = None
|
|
50
56
|
return si
|
|
51
57
|
|
|
@@ -53,7 +59,7 @@ def _dead_si(*_a, **_kw):
|
|
|
53
59
|
@pytest.mark.unit
|
|
54
60
|
def test_a_released_connection_is_collectable() -> None:
|
|
55
61
|
"""The mechanism: what connect() registers, _release_si takes back."""
|
|
56
|
-
with patch("pyVim.connect.SmartConnect", side_effect=
|
|
62
|
+
with patch("pyVim.connect.SmartConnect", side_effect=_live_si):
|
|
57
63
|
si = ConnectionManager._create_connection(_target())
|
|
58
64
|
ref = weakref.ref(si)
|
|
59
65
|
|
|
@@ -68,7 +74,7 @@ def test_a_connection_not_released_is_pinned() -> None:
|
|
|
68
74
|
"""The negative half. Without this, the test above could pass because
|
|
69
75
|
nothing ever held the object -- and would then keep passing with the whole
|
|
70
76
|
registration deleted."""
|
|
71
|
-
with patch("pyVim.connect.SmartConnect", side_effect=
|
|
77
|
+
with patch("pyVim.connect.SmartConnect", side_effect=_live_si):
|
|
72
78
|
si = ConnectionManager._create_connection(_target())
|
|
73
79
|
ref = weakref.ref(si)
|
|
74
80
|
|
|
@@ -87,10 +93,10 @@ def test_repeated_eviction_does_not_pin_every_session() -> None:
|
|
|
87
93
|
mgr = ConnectionManager(cfg)
|
|
88
94
|
|
|
89
95
|
seen: list[weakref.ref] = []
|
|
90
|
-
with patch("pyVim.connect.SmartConnect", side_effect=
|
|
96
|
+
with patch("pyVim.connect.SmartConnect", side_effect=_live_si), \
|
|
91
97
|
patch("pyVim.connect.Disconnect"):
|
|
92
98
|
for _ in range(20):
|
|
93
|
-
seen.append(weakref.ref(mgr.connect("vcenter-prod")))
|
|
99
|
+
seen.append(weakref.ref(_expire(mgr.connect("vcenter-prod"))))
|
|
94
100
|
# Drop the last, still-cached session the way production does.
|
|
95
101
|
mgr.disconnect_all()
|
|
96
102
|
gc.collect()
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""A login that works but has no vSphere role (AIops#47).
|
|
2
|
+
|
|
3
|
+
Reported on vCenter 8.0.3: an account with no vSphere role logs in, vCenter
|
|
4
|
+
shows no session for it (``currentSession`` is ``None``), and every inventory
|
|
5
|
+
call then fails with ``vim.fault.NotAuthenticated``. ``doctor`` passed, and the
|
|
6
|
+
error told the user to check credentials, which were fine.
|
|
7
|
+
|
|
8
|
+
Driven through the real ConnectionManager, doctor, CLI handler and MCP tool;
|
|
9
|
+
only ``SmartConnect`` / ``Disconnect`` are fakes, so nothing touches a network.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from unittest.mock import MagicMock, patch
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
import typer
|
|
18
|
+
from pyVmomi import vim
|
|
19
|
+
|
|
20
|
+
from vmware_storage import doctor
|
|
21
|
+
from vmware_storage.config import AppConfig, TargetConfig
|
|
22
|
+
from vmware_storage.connection import _SI_ATEXIT, ConnectionManager, NoVsphereRoleError
|
|
23
|
+
from vmware_storage.mcp_server import server
|
|
24
|
+
|
|
25
|
+
ROLE_WORDS = ("no vSphere role", "Read-Only")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _config() -> AppConfig:
|
|
29
|
+
return AppConfig(
|
|
30
|
+
targets=(
|
|
31
|
+
TargetConfig(name="lab", host="vc.example.com", config_username="u@vsphere.local"),
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _si(has_session: bool, *, root_readable: bool = False) -> MagicMock:
|
|
37
|
+
"""A login as vCenter answers it.
|
|
38
|
+
|
|
39
|
+
No role (AIops#47) takes both signals: ``currentSession`` reads ``None`` and
|
|
40
|
+
a container view on the root folder is refused with NotAuthenticated.
|
|
41
|
+
``root_readable`` is the scoped account (one folder, or Supervisor namespace
|
|
42
|
+
permissions only): no session either, but the root view opens.
|
|
43
|
+
"""
|
|
44
|
+
si = MagicMock(name="si")
|
|
45
|
+
si.content.sessionManager.currentSession = MagicMock() if has_session else None
|
|
46
|
+
if not has_session and not root_readable:
|
|
47
|
+
si.content.viewManager.CreateContainerView.side_effect = vim.fault.NotAuthenticated()
|
|
48
|
+
return si
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def fake_login(monkeypatch):
|
|
53
|
+
monkeypatch.setenv("VMWARE_LAB_PASSWORD", "pw")
|
|
54
|
+
|
|
55
|
+
def install(has_session: bool, root_readable: bool = False):
|
|
56
|
+
si = _si(has_session, root_readable=root_readable)
|
|
57
|
+
return si, patch("pyVim.connect.SmartConnect", return_value=si), patch(
|
|
58
|
+
"pyVim.connect.Disconnect"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return install
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.unit
|
|
65
|
+
def test_role_less_login_is_refused_closed_and_not_cached(fake_login):
|
|
66
|
+
si, connect, disconnect = fake_login(has_session=False)
|
|
67
|
+
mgr = ConnectionManager(_config())
|
|
68
|
+
with connect as smart, disconnect as dis:
|
|
69
|
+
with pytest.raises(NoVsphereRoleError) as excinfo:
|
|
70
|
+
mgr.connect("lab")
|
|
71
|
+
msg = str(excinfo.value)
|
|
72
|
+
for word in ROLE_WORDS:
|
|
73
|
+
assert word in msg
|
|
74
|
+
assert "'lab'" in msg
|
|
75
|
+
dis.assert_called_once_with(si) # the session is logged out
|
|
76
|
+
assert id(si) not in _SI_ATEXIT # and its exit handler unpinned
|
|
77
|
+
assert mgr.list_connected() == [] # nothing cached
|
|
78
|
+
with pytest.raises(NoVsphereRoleError):
|
|
79
|
+
mgr.connect("lab")
|
|
80
|
+
assert smart.call_count == 2 # the second call logged in again
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.unit
|
|
84
|
+
def test_normal_login_is_unaffected(fake_login):
|
|
85
|
+
si, connect, disconnect = fake_login(has_session=True)
|
|
86
|
+
mgr = ConnectionManager(_config())
|
|
87
|
+
with connect, disconnect as dis:
|
|
88
|
+
assert mgr.connect("lab") is si
|
|
89
|
+
assert mgr.list_connected() == ["lab"]
|
|
90
|
+
dis.assert_not_called()
|
|
91
|
+
mgr.disconnect_all()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.unit
|
|
95
|
+
def test_scoped_account_without_a_session_still_connects(fake_login):
|
|
96
|
+
"""``currentSession`` alone is not a verdict: a readable root view lets it through."""
|
|
97
|
+
si, connect, disconnect = fake_login(has_session=False, root_readable=True)
|
|
98
|
+
mgr = ConnectionManager(_config())
|
|
99
|
+
with connect, disconnect as dis:
|
|
100
|
+
assert mgr.connect("lab") is si # no NoVsphereRoleError
|
|
101
|
+
assert mgr.list_connected() == ["lab"] # cached like any session
|
|
102
|
+
dis.assert_not_called()
|
|
103
|
+
view = si.content.viewManager.CreateContainerView.return_value
|
|
104
|
+
view.Destroy.assert_called_once_with() # the probe leaves no view behind
|
|
105
|
+
mgr.disconnect_all()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.unit
|
|
109
|
+
def test_doctor_auth_row_names_the_missing_role(fake_login, monkeypatch):
|
|
110
|
+
_, connect, disconnect = fake_login(has_session=False)
|
|
111
|
+
monkeypatch.setattr(type(doctor.resolve_config_path()), "exists", lambda self: True)
|
|
112
|
+
monkeypatch.setattr("vmware_storage.config.load_config", _config)
|
|
113
|
+
with connect, disconnect:
|
|
114
|
+
ok, row = doctor._check_auth()
|
|
115
|
+
assert ok is False
|
|
116
|
+
for word in ROLE_WORDS:
|
|
117
|
+
assert word in row
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.unit
|
|
121
|
+
def test_mcp_tool_returns_the_role_message_whole(fake_login, monkeypatch):
|
|
122
|
+
_, connect, disconnect = fake_login(has_session=False)
|
|
123
|
+
mgr = ConnectionManager(_config())
|
|
124
|
+
monkeypatch.setattr(server, "_get_conn_mgr", lambda: mgr)
|
|
125
|
+
with connect, disconnect:
|
|
126
|
+
out = server.list_all_datastores(target="lab")
|
|
127
|
+
for word in ROLE_WORDS:
|
|
128
|
+
assert word in out["error"]
|
|
129
|
+
# The remedy sits at the end of the message; the 300-character cap cut it.
|
|
130
|
+
assert "vmware-storage doctor" in out["error"]
|
|
131
|
+
# The hint points at the role check, not at connectivity, which is fine.
|
|
132
|
+
assert "session" in out["hint"] and "verify connectivity" not in out["hint"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.unit
|
|
136
|
+
def test_cli_handler_prints_the_role_message(fake_login, monkeypatch, capsys):
|
|
137
|
+
from vmware_storage import cli
|
|
138
|
+
|
|
139
|
+
_, connect, disconnect = fake_login(has_session=False)
|
|
140
|
+
monkeypatch.setattr(cli, "load_config", lambda path=None: _config())
|
|
141
|
+
|
|
142
|
+
@cli.handle_cli_errors
|
|
143
|
+
def run():
|
|
144
|
+
cli._get_connection("lab")
|
|
145
|
+
|
|
146
|
+
with connect, disconnect, pytest.raises(typer.Exit):
|
|
147
|
+
run()
|
|
148
|
+
out = " ".join(capsys.readouterr().out.split())
|
|
149
|
+
for word in ROLE_WORDS:
|
|
150
|
+
assert word in out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── NotAuthenticated raised later: expired session, or a role removed ─────────
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _says_both(text: str) -> None:
|
|
157
|
+
text = " ".join(text.split())
|
|
158
|
+
assert "expired" in text and "run the command again" in text
|
|
159
|
+
assert "no vSphere role" in text and "Read-Only" in text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.unit
|
|
163
|
+
def test_cli_not_authenticated_says_both_causes(capsys):
|
|
164
|
+
from vmware_storage.cli import handle_cli_errors
|
|
165
|
+
|
|
166
|
+
@handle_cli_errors
|
|
167
|
+
def boom():
|
|
168
|
+
raise vim.fault.NotAuthenticated()
|
|
169
|
+
|
|
170
|
+
with pytest.raises(typer.Exit):
|
|
171
|
+
boom()
|
|
172
|
+
_says_both(capsys.readouterr().out)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.unit
|
|
176
|
+
def test_mcp_not_authenticated_says_both_causes(monkeypatch):
|
|
177
|
+
def _boom(target=None):
|
|
178
|
+
raise vim.fault.NotAuthenticated()
|
|
179
|
+
|
|
180
|
+
monkeypatch.setattr(server, "_get_connection", _boom)
|
|
181
|
+
out = server.list_all_datastores(target="lab")
|
|
182
|
+
_says_both(out["error"])
|
|
183
|
+
assert "operation failed" not in out["error"]
|
|
184
|
+
assert "session" in out["hint"] and "verify connectivity" not in out["hint"]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.unit
|
|
188
|
+
def test_get_access_info_hint_names_the_role_check(fake_login, monkeypatch):
|
|
189
|
+
_, connect, disconnect = fake_login(has_session=False)
|
|
190
|
+
mgr = ConnectionManager(_config())
|
|
191
|
+
monkeypatch.setattr(server, "_get_conn_mgr", lambda: mgr)
|
|
192
|
+
with connect, disconnect:
|
|
193
|
+
out = server.get_access_info(target="lab")
|
|
194
|
+
for word in ROLE_WORDS:
|
|
195
|
+
assert word in out["error"]
|
|
196
|
+
assert "session" in out["hint"] and "verify connectivity" not in out["hint"]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.mark.unit
|
|
200
|
+
def test_write_tool_error_reply_hint_names_the_role_check():
|
|
201
|
+
out = server._error_reply(vim.fault.NotAuthenticated(), "storage")
|
|
202
|
+
assert "session" in out["hint"] and "verify connectivity" not in out["hint"]
|
|
203
|
+
other = server._error_reply(TimeoutError("slow"), "storage")
|
|
204
|
+
assert "verify connectivity" in other["hint"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@pytest.mark.unit
|
|
208
|
+
def test_doctor_row_is_not_a_class_name(fake_login, monkeypatch):
|
|
209
|
+
"""The row must carry the text, not 'NoVsphereRoleError'."""
|
|
210
|
+
_, connect, disconnect = fake_login(has_session=False)
|
|
211
|
+
monkeypatch.setattr(type(doctor.resolve_config_path()), "exists", lambda self: True)
|
|
212
|
+
monkeypatch.setattr("vmware_storage.config.load_config", _config)
|
|
213
|
+
with connect, disconnect:
|
|
214
|
+
_, row = doctor._check_auth()
|
|
215
|
+
assert "NoVsphereRoleError" not in row
|
|
@@ -1162,21 +1162,21 @@ wheels = [
|
|
|
1162
1162
|
|
|
1163
1163
|
[[package]]
|
|
1164
1164
|
name = "vmware-policy"
|
|
1165
|
-
version = "1.
|
|
1165
|
+
version = "1.23.0"
|
|
1166
1166
|
source = { registry = "https://pypi.org/simple" }
|
|
1167
1167
|
dependencies = [
|
|
1168
1168
|
{ name = "pyyaml" },
|
|
1169
1169
|
{ name = "rich" },
|
|
1170
1170
|
{ name = "typer" },
|
|
1171
1171
|
]
|
|
1172
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
1172
|
+
sdist = { url = "https://files.pythonhosted.org/packages/1b/ef/2c56cc93e0744d7e68b75f7a288f580215865b4685a07b9d58e49773819b/vmware_policy-1.23.0.tar.gz", hash = "sha256:70a682fa44e36fadee7ca09c7c5411fff51c9e25ad9d638f9877737361e2ae31", size = 325401, upload-time = "2026-10-16T00:10:32.297Z" }
|
|
1173
1173
|
wheels = [
|
|
1174
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1174
|
+
{ url = "https://files.pythonhosted.org/packages/e3/ca/bdfc9e50cedf6684ed7d34348177f7688323617ce26d492f55b3d80cd45c/vmware_policy-1.23.0-py3-none-any.whl", hash = "sha256:2f35a97f7d0997dd49ab5a0d8d0fd5f4eae11ef66ca8ed26bc9e4b395cb0d45c", size = 110836, upload-time = "2026-10-16T00:10:30.55Z" },
|
|
1175
1175
|
]
|
|
1176
1176
|
|
|
1177
1177
|
[[package]]
|
|
1178
1178
|
name = "vmware-storage"
|
|
1179
|
-
version = "2.3.
|
|
1179
|
+
version = "2.3.1"
|
|
1180
1180
|
source = { editable = "." }
|
|
1181
1181
|
dependencies = [
|
|
1182
1182
|
{ name = "mcp", extra = ["cli"] },
|
|
@@ -1203,7 +1203,7 @@ requires-dist = [
|
|
|
1203
1203
|
{ name = "pyvmomi", specifier = ">=8.0.3.0,<10.0" },
|
|
1204
1204
|
{ name = "rich", specifier = ">=13.0,<15.0" },
|
|
1205
1205
|
{ name = "typer", specifier = ">=0.12,<1.0" },
|
|
1206
|
-
{ name = "vmware-policy", specifier = ">=1.
|
|
1206
|
+
{ name = "vmware-policy", specifier = ">=1.23.0,<2.0" },
|
|
1207
1207
|
]
|
|
1208
1208
|
|
|
1209
1209
|
[package.metadata.requires-dev]
|
|
@@ -16,7 +16,7 @@ from rich.text import Text
|
|
|
16
16
|
from vmware_policy import PolicyDenied, audited, cli_local, guarded
|
|
17
17
|
|
|
18
18
|
from vmware_storage.config import load_config
|
|
19
|
-
from vmware_storage.connection import ConnectionManager
|
|
19
|
+
from vmware_storage.connection import NOT_AUTHENTICATED_HINT, ConnectionManager
|
|
20
20
|
from vmware_storage.notify.audit import AuditLogger
|
|
21
21
|
from vmware_storage.ops.datastore_browser import DatastoreBrowseError
|
|
22
22
|
from vmware_storage.ops.iscsi_config import HostNotFoundError, ISCSIError
|
|
@@ -84,7 +84,7 @@ def _expected_error_types() -> tuple[type[BaseException], ...]:
|
|
|
84
84
|
"""Expected error types, including the lazily-imported SOAP auth fault."""
|
|
85
85
|
from pyVmomi import vim
|
|
86
86
|
|
|
87
|
-
return (*_EXPECTED_ERRORS, vim.fault.InvalidLogin)
|
|
87
|
+
return (*_EXPECTED_ERRORS, vim.fault.InvalidLogin, vim.fault.NotAuthenticated)
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
def handle_cli_errors(fn):
|
|
@@ -124,6 +124,10 @@ def handle_cli_errors(fn):
|
|
|
124
124
|
"VMWARE_<TARGET>_PASSWORD) and the username in "
|
|
125
125
|
"~/.vmware-storage/config.yaml. Re-run 'vmware-storage init' to reset both."
|
|
126
126
|
)
|
|
127
|
+
elif isinstance(e, vim.fault.NotAuthenticated):
|
|
128
|
+
# Not a credentials problem: the session expired, or the
|
|
129
|
+
# account has no vSphere role (AIops#47). Say both.
|
|
130
|
+
msg = NOT_AUTHENTICATED_HINT
|
|
127
131
|
elif isinstance(e, ssl.SSLError):
|
|
128
132
|
# Self-signed lab certs are the usual cause; teach the toggle.
|
|
129
133
|
msg = (
|
|
@@ -15,6 +15,7 @@ from pyVmomi import vim
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from pyVmomi.vim import ServiceInstance
|
|
17
17
|
|
|
18
|
+
from vmware_policy.access import login_has_no_session, no_role_message
|
|
18
19
|
from vmware_policy.pool import SessionPool
|
|
19
20
|
|
|
20
21
|
from vmware_storage.config import CONFIG_FILE, AppConfig, ConfigError, TargetConfig, load_config
|
|
@@ -85,6 +86,23 @@ class ConnectError(ConfigError):
|
|
|
85
86
|
self.cause_name = cause_name
|
|
86
87
|
|
|
87
88
|
|
|
89
|
+
class NoVsphereRoleError(ConnectError):
|
|
90
|
+
"""The login was accepted but the account has no vSphere role (AIops#47).
|
|
91
|
+
|
|
92
|
+
A dedicated class so every surface can tell it from a transport failure:
|
|
93
|
+
the message is authored (the target name from config.yaml, nothing from
|
|
94
|
+
the server) and names the missing role, not the password.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
NOT_AUTHENTICATED_HINT = (
|
|
99
|
+
"vCenter answered 'the session is not authenticated'. Either the session "
|
|
100
|
+
"expired (run the command again; a new session is opened) or the account "
|
|
101
|
+
"has no vSphere role (assign Read-Only or higher on the vCenter root folder, "
|
|
102
|
+
"then run 'vmware-storage doctor'). Changing the password will not help."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
88
106
|
def _connect_failed(target: TargetConfig, exc: BaseException) -> ConnectError:
|
|
89
107
|
"""Authored replacement for a transport error, safe to show an agent.
|
|
90
108
|
|
|
@@ -243,6 +261,17 @@ class ConnectionManager:
|
|
|
243
261
|
|
|
244
262
|
_SI_ATEXIT[id(si)] = _cleanup
|
|
245
263
|
atexit.register(_cleanup)
|
|
264
|
+
|
|
265
|
+
# An account with no vSphere role logs in fine and then reads nothing:
|
|
266
|
+
# vCenter shows no session for it, a root-folder view is refused, and
|
|
267
|
+
# every call fails with NotAuthenticated (AIops#47). Just after login an expired session is
|
|
268
|
+
# not the explanation, so refuse here, where doctor sees it too, and
|
|
269
|
+
# close the session the way the pool would so nothing caches it.
|
|
270
|
+
if login_has_no_session(si):
|
|
271
|
+
_close_si(si)
|
|
272
|
+
raise NoVsphereRoleError(
|
|
273
|
+
no_role_message(target.name, "vmware-storage"), cause_name="NoVsphereRole"
|
|
274
|
+
)
|
|
246
275
|
return si
|
|
247
276
|
|
|
248
277
|
|
|
@@ -41,7 +41,11 @@ from vmware_policy.access import unknown_tool_message, vsphere_access_info
|
|
|
41
41
|
from vmware_storage import __version__
|
|
42
42
|
from vmware_storage.access_requirements import REQUIREMENTS
|
|
43
43
|
from vmware_storage.config import AppConfig, ConfigError, load_config
|
|
44
|
-
from vmware_storage.connection import
|
|
44
|
+
from vmware_storage.connection import (
|
|
45
|
+
NOT_AUTHENTICATED_HINT,
|
|
46
|
+
ConnectionManager,
|
|
47
|
+
NoVsphereRoleError,
|
|
48
|
+
)
|
|
45
49
|
from vmware_storage.notify.audit import AuditLogger
|
|
46
50
|
from vmware_storage.ops import datastore_browser
|
|
47
51
|
from vmware_storage.ops import iscsi_config as _iscsi_config
|
|
@@ -116,6 +120,14 @@ def _safe_error(exc: Exception, tool: str) -> str:
|
|
|
116
120
|
# reads the remedy for them from the CLI, which catches ssl.SSLError itself.
|
|
117
121
|
if isinstance(exc, ssl.SSLError):
|
|
118
122
|
return f"{type(exc).__name__}: operation failed."
|
|
123
|
+
# Both authored here, so nothing from the server travels. The role error is
|
|
124
|
+
# longer than the 300-character cap, and its remedy is at the end (AIops#47).
|
|
125
|
+
if isinstance(exc, NoVsphereRoleError):
|
|
126
|
+
return sanitize(str(exc), 500)
|
|
127
|
+
from pyVmomi import vim
|
|
128
|
+
|
|
129
|
+
if isinstance(exc, vim.fault.NotAuthenticated):
|
|
130
|
+
return NOT_AUTHENTICATED_HINT
|
|
119
131
|
_passthrough = (
|
|
120
132
|
ValueError,
|
|
121
133
|
FileNotFoundError,
|
|
@@ -135,6 +147,22 @@ def _safe_error(exc: Exception, tool: str) -> str:
|
|
|
135
147
|
return f"{type(exc).__name__}: operation failed."
|
|
136
148
|
|
|
137
149
|
|
|
150
|
+
_DOCTOR_HINT = "Run 'vmware-storage doctor' to verify connectivity."
|
|
151
|
+
#: For the two failures where connectivity and credentials are fine (AIops#47).
|
|
152
|
+
_ROLE_HINT = (
|
|
153
|
+
"Run 'vmware-storage doctor': it checks every target's login and whether "
|
|
154
|
+
"vCenter gives the account a session."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _hint_for(exc: Exception) -> str:
|
|
159
|
+
from pyVmomi import vim
|
|
160
|
+
|
|
161
|
+
if isinstance(exc, (NoVsphereRoleError, vim.fault.NotAuthenticated)):
|
|
162
|
+
return _ROLE_HINT
|
|
163
|
+
return _DOCTOR_HINT
|
|
164
|
+
|
|
165
|
+
|
|
138
166
|
def _error_reply(exc: Exception, tool: str, decision: Optional[_gate.Decision] = None) -> dict:
|
|
139
167
|
"""The error envelope for a write tool, which vmware-policy audits as a failure.
|
|
140
168
|
|
|
@@ -148,7 +176,7 @@ def _error_reply(exc: Exception, tool: str, decision: Optional[_gate.Decision] =
|
|
|
148
176
|
if isinstance(exc, _gate.GateRefusedError):
|
|
149
177
|
hint = "Nothing was changed. Call the tool without confirm to see the blast radius."
|
|
150
178
|
else:
|
|
151
|
-
hint =
|
|
179
|
+
hint = _hint_for(exc)
|
|
152
180
|
payload = {"error": msg, "hint": hint}
|
|
153
181
|
if decision is not None and decision.deprecated:
|
|
154
182
|
payload["deprecated"] = decision.deprecated
|
|
@@ -353,7 +381,7 @@ def list_all_datastores(
|
|
|
353
381
|
return list_datastores(si, limit=limit)
|
|
354
382
|
except Exception as e:
|
|
355
383
|
logger.error("list_all_datastores failed: %s", e)
|
|
356
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
384
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
357
385
|
|
|
358
386
|
|
|
359
387
|
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True})
|
|
@@ -384,7 +412,7 @@ def browse_datastore(
|
|
|
384
412
|
return datastore_browser.browse_datastore(si, datastore_name, path=path, pattern=pattern)
|
|
385
413
|
except Exception as e:
|
|
386
414
|
logger.error("browse_datastore failed: %s", e)
|
|
387
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
415
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
388
416
|
|
|
389
417
|
|
|
390
418
|
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True})
|
|
@@ -413,7 +441,7 @@ def scan_datastore_images(
|
|
|
413
441
|
return datastore_browser.scan_images(si, datastore_name, path=path)
|
|
414
442
|
except Exception as e:
|
|
415
443
|
logger.error("scan_datastore_images failed: %s", e)
|
|
416
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
444
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
417
445
|
|
|
418
446
|
|
|
419
447
|
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True})
|
|
@@ -527,7 +555,7 @@ def get_iscsi_status(
|
|
|
527
555
|
return _ops_get_iscsi_status(si, host_name)
|
|
528
556
|
except Exception as e:
|
|
529
557
|
logger.error("get_iscsi_status failed: %s", e)
|
|
530
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
558
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
531
559
|
|
|
532
560
|
|
|
533
561
|
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": True})
|
|
@@ -748,7 +776,7 @@ def get_vsan_health(
|
|
|
748
776
|
return _ops_get_vsan_health(si, cluster_name)
|
|
749
777
|
except Exception as e:
|
|
750
778
|
logger.error("get_vsan_health failed: %s", e)
|
|
751
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
779
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
752
780
|
|
|
753
781
|
|
|
754
782
|
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True})
|
|
@@ -779,7 +807,7 @@ def get_vsan_capacity(
|
|
|
779
807
|
return _ops_get_vsan_capacity(si, cluster_name)
|
|
780
808
|
except Exception as e:
|
|
781
809
|
logger.error("get_vsan_capacity failed: %s", e)
|
|
782
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
810
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
783
811
|
|
|
784
812
|
|
|
785
813
|
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True})
|
|
@@ -814,7 +842,7 @@ def get_vsan_efficiency(
|
|
|
814
842
|
return _ops_get_vsan_efficiency(si, cluster_name)
|
|
815
843
|
except Exception as e:
|
|
816
844
|
logger.error("get_vsan_efficiency failed: %s", e)
|
|
817
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
845
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
818
846
|
|
|
819
847
|
|
|
820
848
|
# ---------------------------------------------------------------------------
|
|
@@ -857,7 +885,7 @@ def list_fc_adapters(
|
|
|
857
885
|
return _ops_list_fc_adapters(si, cluster=cluster, host=host, limit=limit, offset=offset)
|
|
858
886
|
except Exception as e:
|
|
859
887
|
logger.error("list_fc_adapters failed: %s", e)
|
|
860
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
888
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
861
889
|
|
|
862
890
|
|
|
863
891
|
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True})
|
|
@@ -922,7 +950,7 @@ def list_storage_device_paths(
|
|
|
922
950
|
)
|
|
923
951
|
except Exception as e:
|
|
924
952
|
logger.error("list_storage_device_paths failed: %s", e)
|
|
925
|
-
return {"error": _safe_error(e, "storage"), "hint":
|
|
953
|
+
return {"error": _safe_error(e, "storage"), "hint": _hint_for(e)}
|
|
926
954
|
|
|
927
955
|
|
|
928
956
|
# ---------------------------------------------------------------------------
|
|
@@ -964,7 +992,7 @@ def get_access_info(
|
|
|
964
992
|
logger.error("get_access_info failed: %s", e)
|
|
965
993
|
return {
|
|
966
994
|
"error": _safe_error(e, "storage"),
|
|
967
|
-
"hint":
|
|
995
|
+
"hint": _hint_for(e),
|
|
968
996
|
}
|
|
969
997
|
|
|
970
998
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/agent-guardrails.md
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/capabilities.md
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/cli-reference.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_entity_reachability.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_error_actionability.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_tool_description_quality.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_tool_manifest_budget.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_capability_grader.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_cli_writes_guarded.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_credential_env_override.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_datastore_list_renders.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_env_secret_obfuscation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_inventory_scale_n1.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_iscsi_unreachable_host.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_release_blockers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_session_probe_eviction.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_storage_hardening_fixes.py
RENAMED
|
File without changes
|
{vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_storage_ops_behavior.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|