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.
Files changed (111) hide show
  1. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.claude-plugin/plugin.json +1 -1
  2. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.mcp.json +1 -1
  3. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/PKG-INFO +9 -2
  4. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/README-CN.md +6 -0
  5. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/README.md +7 -0
  6. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/RELEASE_NOTES.md +15 -0
  7. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/openclaw.plugin.json +1 -1
  8. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/package.json +1 -1
  9. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/pyproject.toml +2 -2
  10. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/server.json +2 -2
  11. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/SKILL.md +6 -2
  12. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/setup-guide.md +10 -3
  13. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +12 -6
  14. vmware_storage-2.3.1/tests/eval/regression/test_login_without_a_role.py +215 -0
  15. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/uv.lock +5 -5
  16. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/__init__.py +1 -1
  17. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/cli.py +6 -2
  18. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/connection.py +29 -0
  19. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/mcp_server/server.py +40 -12
  20. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.clawhubignore +0 -0
  21. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.env.example +0 -0
  22. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/.gitignore +0 -0
  23. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/Dockerfile +0 -0
  24. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/LICENSE +0 -0
  25. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/SECURITY.md +0 -0
  26. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/config.example.yaml +0 -0
  27. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/docker-compose.yml +0 -0
  28. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/README.md +0 -0
  29. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/claude-code.json +0 -0
  30. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/continue.yaml +0 -0
  31. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/cursor.json +0 -0
  32. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/goose.json +0 -0
  33. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/localcowork.json +0 -0
  34. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/mcp-agent.yaml +0 -0
  35. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/examples/mcp-configs/vscode-copilot.json +0 -0
  36. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/patterns/README.md +0 -0
  37. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/patterns/iscsi-target-stale-rescan.yaml +0 -0
  38. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/evals/evals.json +0 -0
  39. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/agent-guardrails.md +0 -0
  40. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/capabilities.md +0 -0
  41. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/skills/vmware-storage/references/cli-reference.md +0 -0
  42. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/smithery.yaml +0 -0
  43. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/__init__.py +0 -0
  44. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/conftest.py +0 -0
  45. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/__init__.py +0 -0
  46. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/__init__.py +0 -0
  47. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_family.py +0 -0
  48. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_scores.json +0 -0
  49. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_scoring.py +0 -0
  50. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/_skill.py +0 -0
  51. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/conftest.py +0 -0
  52. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_entity_reachability.py +0 -0
  53. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_error_actionability.py +0 -0
  54. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_tool_description_quality.py +0 -0
  55. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
  56. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/__init__.py +0 -0
  57. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_audit_isolation.py +0 -0
  58. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_cached_loader_agrees_with_the_resolver.py +0 -0
  59. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_capability_grader.py +0 -0
  60. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
  61. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_credential_env_override.py +0 -0
  62. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_datastore_list_renders.py +0 -0
  63. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
  64. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_doctor_uses_the_config_the_tools_use.py +0 -0
  65. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
  66. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_fc_multipath.py +0 -0
  67. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_instructions_name_targets.py +0 -0
  68. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_inventory_scale_n1.py +0 -0
  69. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_iscsi_unreachable_host.py +0 -0
  70. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_release_blockers.py +0 -0
  71. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_remedy_survives_a_long_home.py +0 -0
  72. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_result_envelope.py +0 -0
  73. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
  74. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_session_probe_eviction.py +0 -0
  75. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_storage_hardening_fixes.py +0 -0
  76. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_storage_ops_behavior.py +0 -0
  77. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_tool_names_2_0.py +0 -0
  78. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
  79. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vsan_efficiency.py +0 -0
  80. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vsan_health_is_actually_queried.py +0 -0
  81. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/regression/test_vsan_reports_what_it_could_not_read.py +0 -0
  82. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/spec/__init__.py +0 -0
  83. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/spec/vsan_efficiency_endpoints.py +0 -0
  84. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/eval/spec/vsan_health_endpoints.py +0 -0
  85. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_access_info.py +0 -0
  86. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_init_and_onboarding.py +0 -0
  87. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_iscsi_gate.py +0 -0
  88. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_no_destructive_ops.py +0 -0
  89. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_no_destructive_vm_code.py +0 -0
  90. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_safe_error_passthrough.py +0 -0
  91. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_session_pool.py +0 -0
  92. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/tests/test_stop_signal_runs_atexit.py +0 -0
  93. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/access_requirements.py +0 -0
  94. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/config.py +0 -0
  95. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/doctor.py +0 -0
  96. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/init_wizard.py +0 -0
  97. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/mcp_server/__init__.py +0 -0
  98. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/mcp_server/__main__.py +0 -0
  99. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/notify/__init__.py +0 -0
  100. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/notify/audit.py +0 -0
  101. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/__init__.py +0 -0
  102. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/datastore_browser.py +0 -0
  103. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/inventory.py +0 -0
  104. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/iscsi_config.py +0 -0
  105. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/iscsi_gate.py +0 -0
  106. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/multipath.py +0 -0
  107. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/storage_paths.py +0 -0
  108. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/vsan.py +0 -0
  109. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/vsan_efficiency.py +0 -0
  110. {vmware_storage-2.3.0 → vmware_storage-2.3.1}/vmware_storage/ops/vsan_sdk.py +0 -0
  111. {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.0",
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",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "vmware-storage==2.3.0",
8
+ "vmware-storage==2.3.1",
9
9
  "vmware-storage-mcp"
10
10
  ]
11
11
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vmware-storage
3
- Version: 2.3.0
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.22.0
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:
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "vmware-storage==2.3.0",
14
+ "vmware-storage==2.3.1",
15
15
  "vmware-storage-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/vmware-storage",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "VMware vSphere storage with 15 MCP tools: datastores, iSCSI, vSAN, FC multipath.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vmware-storage"
7
- version = "2.3.0"
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.22.0,<2.0",
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.0",
10
+ "version": "2.3.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-storage",
15
- "version": "2.3.0",
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.0
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.0
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
@@ -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.0
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.0
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.0 https://github.com/vmware-skills/VMware-Storage.git
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 _dead_si(*_a, **_kw):
47
- """An SI whose liveness probe says dead, so the next connect() evicts it."""
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=_dead_si):
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=_dead_si):
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=_dead_si), \
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.22.0"
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/d7/52/405a9d5e88d79649f4d7bddade8248f38ab6a508007a7500a3cdcf81897a/vmware_policy-1.22.0.tar.gz", hash = "sha256:e1a96b44ddf3c4d00fb8bdcaae4e1a059b03f343a84df9de23f865122d3dc280", size = 319890, upload-time = "2026-10-15T14:39:45.757Z" }
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/73/b5/2536cc8750c3ecb0a57c573def664989642ccae1a4f4a8b4283e34877721/vmware_policy-1.22.0-py3-none-any.whl", hash = "sha256:ca8afc5731e926b5eb1b93e26e14763b477bd45c33d3808138fb72903e2af664", size = 108585, upload-time = "2026-10-15T14:39:43.888Z" },
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.0"
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.22.0,<2.0" },
1206
+ { name = "vmware-policy", specifier = ">=1.23.0,<2.0" },
1207
1207
  ]
1208
1208
 
1209
1209
  [package.metadata.requires-dev]
@@ -1,3 +1,3 @@
1
1
  """VMware Storage — vSphere datastore, iSCSI, and vSAN management."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.3.1"
@@ -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 ConnectionManager
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 = "Run 'vmware-storage doctor' to verify connectivity."
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity."}
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": "Run 'vmware-storage doctor' to verify connectivity.",
995
+ "hint": _hint_for(e),
968
996
  }
969
997
 
970
998
 
File without changes