vmware-storage 2.4.1__tar.gz → 2.4.2__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 (121) hide show
  1. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/.claude-plugin/plugin.json +1 -1
  2. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/.mcp.json +1 -1
  3. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/PKG-INFO +2 -2
  4. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/README-CN.md +1 -1
  5. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/RELEASE_NOTES.md +16 -0
  6. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/openclaw.plugin.json +1 -1
  7. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/package.json +1 -1
  8. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/pyproject.toml +2 -2
  9. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/server.json +2 -2
  10. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/skills/vmware-storage/SKILL.md +4 -4
  11. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/skills/vmware-storage/references/capabilities.md +1 -1
  12. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/skills/vmware-storage/references/setup-guide.md +3 -3
  13. vmware_storage-2.4.2/tests/eval/regression/test_image_registry_cross_process.py +149 -0
  14. vmware_storage-2.4.2/tests/eval/regression/test_list_cached_images_target.py +82 -0
  15. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_login_without_a_role.py +65 -0
  16. vmware_storage-2.4.2/tests/test_filelock.py +81 -0
  17. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/uv.lock +5 -5
  18. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/__init__.py +1 -1
  19. vmware_storage-2.4.2/vmware_storage/_filelock.py +94 -0
  20. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/connection.py +39 -0
  21. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/mcp_server/server.py +14 -1
  22. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/datastore_browser.py +31 -3
  23. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/.clawhubignore +0 -0
  24. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/.env.example +0 -0
  25. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/.gitignore +0 -0
  26. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/Dockerfile +0 -0
  27. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/LICENSE +0 -0
  28. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/README.md +0 -0
  29. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/SECURITY.md +0 -0
  30. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/config.example.yaml +0 -0
  31. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/docker-compose.yml +0 -0
  32. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/README.md +0 -0
  33. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/claude-code.json +0 -0
  34. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/continue.yaml +0 -0
  35. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/cursor.json +0 -0
  36. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/goose.json +0 -0
  37. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/localcowork.json +0 -0
  38. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/mcp-agent.yaml +0 -0
  39. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/examples/mcp-configs/vscode-copilot.json +0 -0
  40. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/patterns/README.md +0 -0
  41. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/patterns/iscsi-target-stale-rescan.yaml +0 -0
  42. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/skills/vmware-storage/evals/evals.json +0 -0
  43. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/skills/vmware-storage/references/agent-guardrails.md +0 -0
  44. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/skills/vmware-storage/references/cli-reference.md +0 -0
  45. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/smithery.yaml +0 -0
  46. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/__init__.py +0 -0
  47. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/conftest.py +0 -0
  48. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/__init__.py +0 -0
  49. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/__init__.py +0 -0
  50. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/_family.py +0 -0
  51. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/_scores.json +0 -0
  52. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/_scoring.py +0 -0
  53. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/_skill.py +0 -0
  54. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/conftest.py +0 -0
  55. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/test_entity_reachability.py +0 -0
  56. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/test_error_actionability.py +0 -0
  57. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/test_tool_description_quality.py +0 -0
  58. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
  59. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/__init__.py +0 -0
  60. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
  61. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_audit_isolation.py +0 -0
  62. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_browse_ds_path_join.py +0 -0
  63. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_browse_sees_every_file.py +0 -0
  64. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_cached_loader_agrees_with_the_resolver.py +0 -0
  65. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_capability_grader.py +0 -0
  66. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
  67. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_credential_env_override.py +0 -0
  68. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_datastore_list_renders.py +0 -0
  69. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
  70. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_doctor_uses_the_config_the_tools_use.py +0 -0
  71. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
  72. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_error_hints_fit_the_surface.py +0 -0
  73. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_fc_multipath.py +0 -0
  74. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_instructions_name_targets.py +0 -0
  75. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_inventory_scale_n1.py +0 -0
  76. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_iscsi_unreachable_host.py +0 -0
  77. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_release_blockers.py +0 -0
  78. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_remedy_survives_a_long_home.py +0 -0
  79. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_result_envelope.py +0 -0
  80. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
  81. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_scan_populates_image_cache.py +0 -0
  82. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_session_probe_eviction.py +0 -0
  83. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_storage_hardening_fixes.py +0 -0
  84. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_storage_ops_behavior.py +0 -0
  85. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_tool_names_2_0.py +0 -0
  86. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
  87. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_vsan_efficiency.py +0 -0
  88. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_vsan_health_is_actually_queried.py +0 -0
  89. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/regression/test_vsan_reports_what_it_could_not_read.py +0 -0
  90. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/spec/__init__.py +0 -0
  91. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/spec/vsan_efficiency_endpoints.py +0 -0
  92. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/eval/spec/vsan_health_endpoints.py +0 -0
  93. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_access_info.py +0 -0
  94. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_init_and_onboarding.py +0 -0
  95. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_iscsi_gate.py +0 -0
  96. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_no_destructive_ops.py +0 -0
  97. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_no_destructive_vm_code.py +0 -0
  98. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_policy_roles.py +0 -0
  99. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_safe_error_passthrough.py +0 -0
  100. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_session_pool.py +0 -0
  101. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/tests/test_stop_signal_runs_atexit.py +0 -0
  102. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/access_requirements.py +0 -0
  103. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/cli.py +0 -0
  104. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/config.py +0 -0
  105. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/doctor.py +0 -0
  106. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/init_wizard.py +0 -0
  107. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/mcp_server/__init__.py +0 -0
  108. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/mcp_server/__main__.py +0 -0
  109. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/notify/__init__.py +0 -0
  110. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/notify/audit.py +0 -0
  111. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/__init__.py +0 -0
  112. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/inventory.py +0 -0
  113. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/iscsi_config.py +0 -0
  114. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/iscsi_gate.py +0 -0
  115. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/multipath.py +0 -0
  116. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/storage_paths.py +0 -0
  117. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/vsan.py +0 -0
  118. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/vsan_efficiency.py +0 -0
  119. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/ops/vsan_sdk.py +0 -0
  120. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/policy_environment.py +0 -0
  121. {vmware_storage-2.4.1 → vmware_storage-2.4.2}/vmware_storage/policy_roles.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.4.1",
5
+ "version": "2.4.2",
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.4.1",
8
+ "vmware-storage==2.4.2",
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.4.1
3
+ Version: 2.4.2
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
@@ -10,7 +10,7 @@ Author-email: Wei Zhou <wei-wz.zhou@broadcom.com>
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
12
12
  Requires-Python: >=3.10
13
- Requires-Dist: mcp[cli]<2.0,>=1.28
13
+ Requires-Dist: mcp[cli]<2.0,>=1.30
14
14
  Requires-Dist: pyaml<27.0,>=24.0
15
15
  Requires-Dist: python-dotenv<2.0,>=1.0
16
16
  Requires-Dist: pyvmomi<10.0,>=8.0.3.0
@@ -115,7 +115,7 @@ targets:
115
115
  - `list_all_datastores` — 列出所有数据存储,含容量、使用率、可达性
116
116
  - `browse_datastore` — 浏览数据存储目录下的文件(支持 glob 过滤)
117
117
  - `scan_datastore_images` — 扫描可部署镜像(OVA、ISO、OVF、VMDK)
118
- - `list_cached_images` — 查询本地镜像注册表(支持按类型/数据存储过滤)
118
+ - `list_cached_images` — 查询本地镜像注册表(支持按类型/数据存储/target 过滤)
119
119
 
120
120
  **iSCSI**
121
121
  - `enable_iscsi` — 在 ESXi 主机上启用软件 iSCSI 适配器
@@ -1,3 +1,19 @@
1
+ ## v2.4.2 — a login refused for lack of a vSphere role says so (2026-10-19)
2
+
3
+ * **A login vCenter refuses with `NoPermission` gets the role error, not the raw fault.** Found on a vCenter 8.0.3
4
+ lab (follow-up to AIops#47): for an SSO account with no permission anywhere, vCenter refuses the login itself
5
+ (`System.View` on the root folder), so the post-login no-role check never ran and `doctor` and every tool showed
6
+ the raw fault with a connectivity hint. It now raises the no-role error: the "no vSphere role / Global
7
+ Permissions" remedy only for a vCenter target and `System.View`; an ESXi target or another privilege gets a
8
+ neutral text naming the (sanitized) privilege. A wrong password (`InvalidLogin`) is unchanged. Same text as
9
+ vmware-monitor.
10
+
11
+ ## Unreleased — follow-ups to the 2.4.1 review
12
+
13
+ * **`list_cached_images(target=...)`** returns one target's cached images; without it, nothing changes.
14
+ * The cache write takes a lock across processes, so a CLI and an MCP scan at the same time keep both sides' rows; a
15
+ lock held for more than 10 s is reported in `cache_note` with the lock file's path.
16
+
1
17
  ## v2.4.1 — fixes from real-hardware testing (2026-10-17)
2
18
 
3
19
  * **`list_cached_images` returns what was scanned.** Nothing ever wrote the cache it reads, so it was always empty.
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "vmware-storage==2.4.1",
14
+ "vmware-storage==2.4.2",
15
15
  "vmware-storage-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/vmware-storage",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
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.4.1"
7
+ version = "2.4.2"
8
8
  description = "VMware vSphere storage management: datastores, iSCSI, vSAN. Domain-focused MCP skill."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -16,7 +16,7 @@ dependencies = [
16
16
  "typer>=0.12,<1.0",
17
17
  "rich>=13.0,<15.0",
18
18
  "python-dotenv>=1.0,<2.0",
19
- "mcp[cli]>=1.28,<2.0",
19
+ "mcp[cli]>=1.30,<2.0",
20
20
  "vmware-policy>=1.23.0,<2.0",
21
21
  ]
22
22
 
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/vmware-skills/VMware-Storage",
8
8
  "source": "github"
9
9
  },
10
- "version": "2.4.1",
10
+ "version": "2.4.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-storage",
15
- "version": "2.4.1",
15
+ "version": "2.4.2",
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.4.1
43
+ uv tool install vmware-storage==2.4.2
44
44
  vmware-storage init # guided setup: writes config + .env (chmod 600, password grep-safe), then verifies
45
45
  vmware-storage doctor
46
46
  ```
@@ -103,7 +103,7 @@ vmware-storage doctor
103
103
  4. `datastore browse <ds> --pattern "*.iso"` for ad-hoc searches; pattern is glob, not regex
104
104
  5. **If datastore not found**: name is case-sensitive. `datastore list --target <vc>` to verify exact spelling.
105
105
 
106
- For filtered queries against the cache: use `list_cached_images` MCP tool with `image_type` and `datastore` parameters — avoids re-scanning.
106
+ For filtered queries against the cache: use `list_cached_images` MCP tool with `image_type`, `datastore` and `target` parameters — avoids re-scanning.
107
107
 
108
108
  ### vSAN Health Assessment
109
109
 
@@ -158,7 +158,7 @@ The four Datastore read tools return the family list envelope — `{items, retur
158
158
  | Datastore | `list_all_datastores` | Read | List datastores with capacity, usage %, VM count |
159
159
  | | `browse_datastore` | Read | Browse files with optional path and glob pattern |
160
160
  | | `scan_datastore_images` | Read | Find OVA/ISO/OVF/VMDK in a datastore |
161
- | | `list_cached_images` | Read | Query local image registry with type/datastore filters |
161
+ | | `list_cached_images` | Read | Query local image registry with type/datastore/target filters |
162
162
  | iSCSI | `get_iscsi_status` | Read | Show adapter status, HBA device, IQN, send targets |
163
163
  | | `enable_iscsi` | Write | Enable software iSCSI adapter on a host |
164
164
  | | `add_iscsi_target` | Write | Add iSCSI send target (IP + port) and rescan |
@@ -271,7 +271,7 @@ Corporate TLS proxies inject certificates that uv's bundled CA store doesn't tru
271
271
  ## Setup
272
272
 
273
273
  ```bash
274
- uv tool install vmware-storage==2.4.1
274
+ uv tool install vmware-storage==2.4.2
275
275
  mkdir -p ~/.vmware-storage
276
276
  cp config.example.yaml ~/.vmware-storage/config.yaml
277
277
  # Edit config.yaml with your vCenter/ESXi targets
@@ -26,7 +26,7 @@ Each operation is classified by autonomy level per the Enterprise Harness Engine
26
26
  | `list_all_datastores` | List datastores with capacity, usage %, VM count | `target` (string, optional), `limit` (int, optional; omit for all) | Low | No |
27
27
  | `browse_datastore` | Browse files with optional path and glob pattern | `datastore_name` (string, **required**), `path` (string, optional), `pattern` (string, optional), `target` (string, optional) | Low | No |
28
28
  | `scan_datastore_images` | Find OVA/ISO/OVF/VMDK deployable images in a datastore | `datastore_name` (string, **required**), `target` (string, optional) | Low | No |
29
- | `list_cached_images` | Query local image registry with type/datastore filters | `image_type` (string, optional), `datastore` (string, optional) | Low | No |
29
+ | `list_cached_images` | Query local image registry with type/datastore/target filters | `image_type` (string, optional), `datastore` (string, optional), `target` (string, optional) | Low | No |
30
30
 
31
31
  **List envelope**: all four return `{items, returned, limit, total, truncated, hint}` instead of a bare array, so an agent can tell a complete answer from a first page rather than inferring it (VMware-AIops issue #31). Each enumerates its collection in full — a PropertyCollector walk, a datastore browse task, or the on-disk registry — so `total` is the real count and `truncated` is always `false`. On error the tools return `{error, hint}` (a dict, not a one-element list).
32
32
 
@@ -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.4.1
16
+ uv tool install vmware-storage==2.4.2
17
17
  ```
18
18
 
19
19
  ### Via pip
20
20
 
21
21
  ```bash
22
- pip install vmware-storage==2.4.1
22
+ pip install vmware-storage==2.4.2
23
23
  ```
24
24
 
25
25
  ### From source
26
26
 
27
27
  ```bash
28
- git clone --branch v2.4.1 https://github.com/vmware-skills/VMware-Storage.git
28
+ git clone --branch v2.4.2 https://github.com/vmware-skills/VMware-Storage.git
29
29
  cd VMware-Storage
30
30
  pip install -e .
31
31
  ```
@@ -0,0 +1,149 @@
1
+ """Two processes scanning at once must not drop each other's cached images.
2
+
3
+ Review 2026-10-18: the registry's read-modify-write was guarded by a
4
+ ``threading.Lock``, which only serialises one process. A CLI scan and an MCP
5
+ server scan running side by side each read the old file, and the last
6
+ ``os.replace`` wrote the other's rows away. The atomic replace stops a reader
7
+ seeing half a file; it does nothing for a lost update.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import textwrap
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ from vmware_storage.ops import datastore_browser as dsb
23
+
24
+ _WRITERS = 4
25
+
26
+ _CHILD = textwrap.dedent(
27
+ """
28
+ import sys, time
29
+ from pathlib import Path
30
+ from vmware_storage.ops import datastore_browser as dsb
31
+
32
+ reg, target = Path(sys.argv[1]), sys.argv[2]
33
+ dsb.IMAGE_REGISTRY_FILE = reg
34
+ dsb.CONFIG_DIR = reg.parent
35
+ real_load = dsb._load_registry
36
+
37
+ def slow_load():
38
+ # Widen the read-modify-write window so the race is not left to luck.
39
+ data = real_load()
40
+ time.sleep(0.3)
41
+ return data
42
+
43
+ dsb._load_registry = slow_load
44
+ (reg.parent / f"ready-{target}").touch()
45
+ go = reg.parent / "go"
46
+ deadline = time.monotonic() + 60
47
+ while not go.exists():
48
+ if time.monotonic() > deadline:
49
+ sys.exit("parent never said go")
50
+ time.sleep(0.01)
51
+ img = {"name": f"{target}.iso", "ds_path": f"[datastore1] {target}.iso",
52
+ "size_mb": 1.0, "type": "File", "modified": ""}
53
+ dsb._record_scan("datastore1", "", [img], target)
54
+ """
55
+ )
56
+
57
+
58
+ def _child_env() -> dict[str, str]:
59
+ env = dict(os.environ)
60
+ # The children must import exactly the code this test process imported.
61
+ env["PYTHONPATH"] = os.pathsep.join(p for p in sys.path if p)
62
+ return env
63
+
64
+
65
+ def _run_writers(reg: Path, script: str, targets: list[str]) -> None:
66
+ procs = [
67
+ subprocess.Popen([sys.executable, "-c", script, str(reg), t], env=_child_env())
68
+ for t in targets
69
+ ]
70
+ try:
71
+ deadline = time.monotonic() + 60
72
+ while not all((reg.parent / f"ready-{t}").exists() for t in targets):
73
+ assert time.monotonic() < deadline, "writers never became ready"
74
+ assert all(p.poll() is None for p in procs), "a writer exited early"
75
+ time.sleep(0.02)
76
+ (reg.parent / "go").touch()
77
+ for p in procs:
78
+ assert p.wait(timeout=60) == 0
79
+ finally:
80
+ for p in procs:
81
+ if p.poll() is None:
82
+ p.kill()
83
+
84
+
85
+ @pytest.mark.unit
86
+ def test_concurrent_scans_in_separate_processes_keep_every_row(tmp_path) -> None:
87
+ reg = tmp_path / "image_registry.json"
88
+ targets = [f"t{i}" for i in range(_WRITERS)]
89
+
90
+ _run_writers(reg, _CHILD, targets)
91
+
92
+ rows = json.loads(reg.read_text(encoding="utf-8"))["images"]
93
+ assert sorted(r["target"] for r in rows) == targets
94
+
95
+
96
+ @pytest.fixture
97
+ def registry(tmp_path, monkeypatch):
98
+ path = tmp_path / "image_registry.json"
99
+ monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
100
+ monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
101
+ return path
102
+
103
+
104
+ def _hold_lock(reg: Path):
105
+ fcntl = pytest.importorskip("fcntl")
106
+ fh = open(reg.with_name(reg.name + ".lock"), "a+b") # noqa: SIM115
107
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
108
+ return fh
109
+
110
+
111
+ _IMG = {"name": "x.iso", "ds_path": "[ds1] x.iso", "size_mb": 1.0,
112
+ "type": "File", "modified": ""}
113
+
114
+
115
+ @pytest.mark.unit
116
+ def test_a_held_lock_is_a_bounded_wait_that_names_the_lock_file(registry, monkeypatch) -> None:
117
+ monkeypatch.setattr(dsb, "REGISTRY_LOCK_TIMEOUT_S", 0.3, raising=False)
118
+ holder = _hold_lock(registry)
119
+ try:
120
+ started = time.monotonic()
121
+ with pytest.raises(dsb.DatastoreBrowseError, match=r"image_registry\.json\.lock"):
122
+ dsb._record_scan("ds1", "", [_IMG], "vc")
123
+ assert time.monotonic() - started < 5
124
+ finally:
125
+ holder.close()
126
+ assert not registry.exists(), "nothing may be written without the lock"
127
+
128
+
129
+ @pytest.mark.unit
130
+ def test_scan_reports_the_held_lock_in_cache_note(registry, monkeypatch) -> None:
131
+ monkeypatch.setattr(dsb, "REGISTRY_LOCK_TIMEOUT_S", 0.3, raising=False)
132
+ monkeypatch.setattr(
133
+ dsb, "browse_datastore",
134
+ lambda si, ds, path="", pattern="*": {"items": [_IMG] if pattern == "*.iso" else []},
135
+ )
136
+ holder = _hold_lock(registry)
137
+ try:
138
+ result = dsb.scan_images(object(), "ds1", target="vc")
139
+ finally:
140
+ holder.close()
141
+ assert [i["name"] for i in result["items"]] == ["x.iso"]
142
+ assert "image_registry.json.lock" in result.get("cache_note", "")
143
+
144
+
145
+ @pytest.mark.unit
146
+ def test_the_lock_is_released_after_a_write(registry) -> None:
147
+ dsb._record_scan("ds1", "", [_IMG], "vc")
148
+ holder = _hold_lock(registry) # would raise BlockingIOError if still held
149
+ holder.close()
@@ -0,0 +1,82 @@
1
+ """list_cached_images can answer for one target (review 2026-10-18).
2
+
3
+ Rows carry ``target`` since 2026-10-17 so two hosts' "datastore1" stay apart,
4
+ but the tool had no way to ask for one target's rows: a caller saw every
5
+ target's images mixed together and had to filter by hand.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ import pytest
13
+
14
+ from vmware_storage.config import AppConfig, TargetConfig
15
+ from vmware_storage.connection import ConnectionManager
16
+ from vmware_storage.mcp_server import server
17
+ from vmware_storage.ops import datastore_browser as dsb
18
+
19
+ _ROWS = [
20
+ {"target": "vc", "datastore": "datastore1", "name": "a.iso",
21
+ "ds_path": "[datastore1] a.iso", "size_mb": 1.0, "type": "File", "modified": ""},
22
+ {"target": "esx", "datastore": "datastore1", "name": "b.ova",
23
+ "ds_path": "[datastore1] b.ova", "size_mb": 1.0, "type": "File", "modified": ""},
24
+ # Written before targets were recorded: it cannot be attributed to either.
25
+ {"datastore": "datastore1", "name": "old.iso", "ds_path": "[datastore1] old.iso",
26
+ "size_mb": 1.0, "type": "File", "modified": ""},
27
+ ]
28
+
29
+
30
+ @pytest.fixture
31
+ def registry(tmp_path, monkeypatch):
32
+ path = tmp_path / "image_registry.json"
33
+ path.write_text(json.dumps({"images": _ROWS, "last_scan": None}), encoding="utf-8")
34
+ monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
35
+ monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
36
+ return path
37
+
38
+
39
+ def _with_targets(monkeypatch, *names: str) -> None:
40
+ cfg = AppConfig(targets=tuple(
41
+ TargetConfig(name=n, host=f"{n}.example.com", config_username="u") for n in names))
42
+ mgr = ConnectionManager(cfg)
43
+ monkeypatch.setattr(server, "_get_conn_mgr", lambda: mgr)
44
+
45
+
46
+ @pytest.mark.unit
47
+ def test_ops_filter_returns_only_that_targets_rows(registry) -> None:
48
+ out = dsb.list_images(target="esx")
49
+ assert [i["name"] for i in out["items"]] == ["b.ova"]
50
+ assert out["total"] == 1
51
+
52
+
53
+ @pytest.mark.unit
54
+ def test_ops_filter_combines_with_the_other_filters(registry) -> None:
55
+ assert dsb.list_images(target="vc", image_type="ova")["items"] == []
56
+ assert [i["name"] for i in dsb.list_images(target="vc", datastore="datastore1")["items"]] == [
57
+ "a.iso"]
58
+
59
+
60
+ @pytest.mark.unit
61
+ def test_tool_with_target_returns_only_that_target(registry, monkeypatch) -> None:
62
+ _with_targets(monkeypatch, "vc", "esx")
63
+ out = server.list_cached_images(target="vc")
64
+ assert [i["name"] for i in out["items"]] == ["a.iso"]
65
+
66
+
67
+ @pytest.mark.unit
68
+ def test_tool_without_target_is_unchanged_and_needs_no_config(registry, monkeypatch) -> None:
69
+ def no_config():
70
+ raise AssertionError("an unfiltered cache read must not load config.yaml")
71
+
72
+ monkeypatch.setattr(server, "_get_conn_mgr", no_config)
73
+ out = server.list_cached_images()
74
+ assert sorted(i["name"] for i in out["items"]) == ["a.iso", "b.ova", "old.iso"]
75
+
76
+
77
+ @pytest.mark.unit
78
+ def test_tool_with_an_unknown_target_names_the_configured_ones(registry, monkeypatch) -> None:
79
+ _with_targets(monkeypatch, "vc", "esx")
80
+ out = server.list_cached_images(target="nope")
81
+ assert "error" in out
82
+ assert "nope" in out["error"] and "vc" in out["error"]
@@ -213,3 +213,68 @@ def test_doctor_row_is_not_a_class_name(fake_login, monkeypatch):
213
213
  with connect, disconnect:
214
214
  _, row = doctor._check_auth()
215
215
  assert "NoVsphereRoleError" not in row
216
+
217
+
218
+ # 2026-10-18, lab vCenter 8.0.3, SSO account with no permission anywhere:
219
+ # vCenter refuses the *login itself* with NoPermission (System.View on the root
220
+ # folder). Before the fix that raw fault reached doctor and every tool.
221
+ def _login_refused(exc: Exception, monkeypatch: pytest.MonkeyPatch):
222
+ monkeypatch.setenv("VMWARE_LAB_PASSWORD", "pw")
223
+ mgr = ConnectionManager(_config())
224
+ with patch("pyVim.connect.SmartConnect", side_effect=exc):
225
+ try:
226
+ mgr.connect("lab")
227
+ except Exception as e: # noqa: BLE001 — the test inspects whatever is raised
228
+ return e
229
+ raise AssertionError("connect did not raise")
230
+
231
+
232
+ def test_login_refused_with_no_permission_names_the_missing_role(
233
+ monkeypatch: pytest.MonkeyPatch,
234
+ ) -> None:
235
+ fault = vim.fault.NoPermission(
236
+ privilegeId="System.View", msg="Permission to perform this operation was denied."
237
+ )
238
+ err = _login_refused(fault, monkeypatch)
239
+ assert isinstance(err, NoVsphereRoleError)
240
+ msg = str(err)
241
+ assert "no vSphere role" in msg and "Read-Only" in msg and "'lab'" in msg
242
+ assert "dynamicType" not in msg and "operation failed" not in msg
243
+ assert len(msg) <= 300
244
+ assert err.__cause__ is fault
245
+
246
+
247
+ def test_wrong_password_is_not_reported_as_a_missing_role(
248
+ monkeypatch: pytest.MonkeyPatch,
249
+ ) -> None:
250
+ err = _login_refused(vim.fault.InvalidLogin(), monkeypatch)
251
+ assert not isinstance(err, NoVsphereRoleError)
252
+ assert isinstance(err, vim.fault.InvalidLogin)
253
+
254
+
255
+ @pytest.mark.parametrize(
256
+ ("privilege", "esxi"),
257
+ [("System.View", True), ("Sessions.ValidateSession", False), (None, False)],
258
+ )
259
+ def test_other_login_refusals_get_a_neutral_remedy(
260
+ privilege, esxi, monkeypatch: pytest.MonkeyPatch
261
+ ) -> None:
262
+ # Review 2026-10-18 (M3): an ESXi host (lockdown mode, host permissions) or
263
+ # another privilege must not be sent to vCenter Global Permissions.
264
+ from dataclasses import replace
265
+
266
+ fault = vim.fault.NoPermission(privilegeId=privilege)
267
+ mgr = ConnectionManager(_config())
268
+ if esxi:
269
+ esxi_targets = tuple(replace(t, type="esxi") for t in mgr._config.targets)
270
+ mgr._config = replace(mgr._config, targets=esxi_targets)
271
+ monkeypatch.setenv("VMWARE_LAB_PASSWORD", "pw")
272
+ monkeypatch.setenv("VMWARE_PRIVATEAI_LAB_PASSWORD", "pw")
273
+ with patch("pyVim.connect.SmartConnect", side_effect=fault):
274
+ with pytest.raises(Exception) as info:
275
+ mgr.connect("lab")
276
+ msg = str(info.value)
277
+ assert "Global Permissions" not in msg and "lockdown mode" in msg
278
+ if privilege:
279
+ assert f"({privilege})" in msg
280
+ assert info.value.__cause__ is fault and len(msg) <= 300
@@ -0,0 +1,81 @@
1
+ """The inter-process registry lock: Windows branch and bounded wait.
2
+
3
+ The family runs on Windows, where ``fcntl`` does not exist. That branch cannot
4
+ run on the POSIX CI host, so it is driven here with a stand-in ``msvcrt`` that
5
+ records the calls and reports the lock as busy a set number of times.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import errno
11
+ import types
12
+
13
+ import pytest
14
+
15
+ from vmware_storage import _filelock
16
+
17
+
18
+ class _FakeMsvcrt(types.SimpleNamespace):
19
+ LK_NBLCK = 2
20
+ LK_UNLCK = 0
21
+
22
+ def __init__(self, busy: int) -> None:
23
+ super().__init__(calls=[], busy=busy)
24
+
25
+ def locking(self, fd: int, mode: int, nbytes: int) -> None:
26
+ self.calls.append((mode, nbytes))
27
+ if mode == self.LK_NBLCK and self.busy > 0:
28
+ self.busy -= 1
29
+ raise OSError(errno.EACCES, "Permission denied") # msvcrt: busy
30
+
31
+
32
+ @pytest.mark.unit
33
+ def test_windows_branch_retries_then_locks_and_unlocks(tmp_path, monkeypatch) -> None:
34
+ fake = _FakeMsvcrt(busy=2)
35
+ monkeypatch.setattr(_filelock, "msvcrt", fake)
36
+ monkeypatch.setattr(_filelock, "fcntl", None)
37
+
38
+ with _filelock.locked(tmp_path / "reg.json.lock", timeout=5, poll=0.01):
39
+ pass
40
+
41
+ assert fake.calls == [(2, 1), (2, 1), (2, 1), (0, 1)]
42
+
43
+
44
+ @pytest.mark.unit
45
+ def test_windows_branch_gives_up_and_names_the_file(tmp_path, monkeypatch) -> None:
46
+ monkeypatch.setattr(_filelock, "msvcrt", _FakeMsvcrt(busy=10**9))
47
+ monkeypatch.setattr(_filelock, "fcntl", None)
48
+ lock = tmp_path / "reg.json.lock"
49
+
50
+ with pytest.raises(_filelock.FileLockTimeoutError, match="reg.json.lock"):
51
+ with _filelock.locked(lock, timeout=0.1, poll=0.01):
52
+ pytest.fail("entered the block without the lock")
53
+
54
+
55
+ @pytest.mark.unit
56
+ def test_the_lock_file_is_owner_only(tmp_path) -> None:
57
+ import os
58
+ import stat
59
+
60
+ lock = tmp_path / "sub" / "reg.json.lock"
61
+ with _filelock.locked(lock, timeout=1):
62
+ pass
63
+ if os.name == "posix":
64
+ assert stat.S_IMODE(lock.stat().st_mode) == 0o600
65
+
66
+
67
+ @pytest.mark.unit
68
+ def test_a_lock_error_that_is_not_busy_is_raised_at_once(tmp_path, monkeypatch) -> None:
69
+ """No flock on this filesystem is not "another process holds it" (review 2026-10-18)."""
70
+ if _filelock.fcntl is None:
71
+ pytest.skip("POSIX branch")
72
+
73
+ def no_locks(fd, op):
74
+ raise OSError(errno.ENOLCK, "No locks available")
75
+
76
+ monkeypatch.setattr(_filelock.fcntl, "flock", no_locks)
77
+ with pytest.raises(OSError) as info:
78
+ with _filelock.locked(tmp_path / "r.lock", timeout=5.0, poll=0.01):
79
+ pass
80
+ assert info.value.errno == errno.ENOLCK
81
+ assert not isinstance(info.value, _filelock.FileLockTimeoutError)
@@ -1162,21 +1162,21 @@ wheels = [
1162
1162
 
1163
1163
  [[package]]
1164
1164
  name = "vmware-policy"
1165
- version = "1.24.0"
1165
+ version = "1.24.2"
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/4a/62/3a3cb83af77e3dd3c8fc92cb3b7968bfda04a2f71bf0d6f9b72ee3198441/vmware_policy-1.24.0.tar.gz", hash = "sha256:fba12732ccbd4371232c15d9fd014f9f5d697497210aedff2515d1d205ed3577", size = 361302, upload-time = "2026-10-16T07:18:28.317Z" }
1172
+ sdist = { url = "https://files.pythonhosted.org/packages/15/8d/b9867ea1c0112caa481841c290c3fe718c0dbd738757db7877836f46267a/vmware_policy-1.24.2.tar.gz", hash = "sha256:6c816174fcd0b490bcd1f9ebaaa18b80fcb26b0eccb614e938938bb2e8452a40", size = 369315, upload-time = "2026-10-18T03:43:52.753Z" }
1173
1173
  wheels = [
1174
- { url = "https://files.pythonhosted.org/packages/3d/f2/fe3d9d384712023916e2cacddc385f14b095509c5e00aea417d1a9550e98/vmware_policy-1.24.0-py3-none-any.whl", hash = "sha256:cf615d0256134f01fb84c39b083649281a66d2c064c2eaa28786a0fa95ec6e58", size = 131460, upload-time = "2026-10-16T07:18:26.907Z" },
1174
+ { url = "https://files.pythonhosted.org/packages/20/01/bc5f984a420d22514d8c3a627f332891f2008213d455c2f682f311e355f1/vmware_policy-1.24.2-py3-none-any.whl", hash = "sha256:2734ab49a76474fd74beb221a2d6535caab1f91a477668484c145dc2802ae072", size = 134565, upload-time = "2026-10-18T03:43:50.856Z" },
1175
1175
  ]
1176
1176
 
1177
1177
  [[package]]
1178
1178
  name = "vmware-storage"
1179
- version = "2.4.1"
1179
+ version = "2.4.2"
1180
1180
  source = { editable = "." }
1181
1181
  dependencies = [
1182
1182
  { name = "mcp", extra = ["cli"] },
@@ -1197,7 +1197,7 @@ dev = [
1197
1197
 
1198
1198
  [package.metadata]
1199
1199
  requires-dist = [
1200
- { name = "mcp", extras = ["cli"], specifier = ">=1.28,<2.0" },
1200
+ { name = "mcp", extras = ["cli"], specifier = ">=1.30,<2.0" },
1201
1201
  { name = "pyaml", specifier = ">=24.0,<27.0" },
1202
1202
  { name = "python-dotenv", specifier = ">=1.0,<2.0" },
1203
1203
  { name = "pyvmomi", specifier = ">=8.0.3.0,<10.0" },
@@ -1,3 +1,3 @@
1
1
  """VMware Storage — vSphere datastore, iSCSI, and vSAN management."""
2
2
 
3
- __version__ = "2.4.1"
3
+ __version__ = "2.4.2"