vmware-storage 2.4.0__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.
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/.claude-plugin/plugin.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/.mcp.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/PKG-INFO +2 -2
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/README-CN.md +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/RELEASE_NOTES.md +28 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/openclaw.plugin.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/package.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/pyproject.toml +2 -2
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/server.json +2 -2
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/skills/vmware-storage/SKILL.md +4 -4
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/skills/vmware-storage/references/capabilities.md +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/skills/vmware-storage/references/setup-guide.md +3 -3
- vmware_storage-2.4.2/tests/eval/regression/test_browse_ds_path_join.py +79 -0
- vmware_storage-2.4.2/tests/eval/regression/test_browse_sees_every_file.py +70 -0
- vmware_storage-2.4.2/tests/eval/regression/test_error_hints_fit_the_surface.py +60 -0
- vmware_storage-2.4.2/tests/eval/regression/test_image_registry_cross_process.py +149 -0
- vmware_storage-2.4.2/tests/eval/regression/test_list_cached_images_target.py +82 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_login_without_a_role.py +65 -0
- vmware_storage-2.4.2/tests/eval/regression/test_scan_populates_image_cache.py +184 -0
- vmware_storage-2.4.2/tests/test_filelock.py +81 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/uv.lock +8 -8
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/__init__.py +1 -1
- vmware_storage-2.4.2/vmware_storage/_filelock.py +94 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/cli.py +4 -2
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/config.py +8 -3
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/connection.py +39 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/mcp_server/server.py +51 -10
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/datastore_browser.py +154 -60
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/.clawhubignore +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/.env.example +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/.gitignore +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/Dockerfile +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/LICENSE +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/README.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/SECURITY.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/config.example.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/docker-compose.yml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/README.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/claude-code.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/continue.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/cursor.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/goose.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/localcowork.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/mcp-agent.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/examples/mcp-configs/vscode-copilot.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/patterns/README.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/patterns/iscsi-target-stale-rescan.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/skills/vmware-storage/evals/evals.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/skills/vmware-storage/references/agent-guardrails.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/skills/vmware-storage/references/cli-reference.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/smithery.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/conftest.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/_family.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/_scores.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/_scoring.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/_skill.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/conftest.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/test_entity_reachability.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/test_error_actionability.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/test_tool_description_quality.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_audit_isolation.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_cached_loader_agrees_with_the_resolver.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_capability_grader.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_credential_env_override.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_datastore_list_renders.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_doctor_uses_the_config_the_tools_use.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_fc_multipath.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_instructions_name_targets.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_inventory_scale_n1.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_iscsi_unreachable_host.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_release_blockers.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_remedy_survives_a_long_home.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_result_envelope.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_session_probe_eviction.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_storage_hardening_fixes.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_storage_ops_behavior.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_tool_names_2_0.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_vsan_efficiency.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_vsan_health_is_actually_queried.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/regression/test_vsan_reports_what_it_could_not_read.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/spec/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/spec/vsan_efficiency_endpoints.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/eval/spec/vsan_health_endpoints.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_access_info.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_init_and_onboarding.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_iscsi_gate.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_no_destructive_ops.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_no_destructive_vm_code.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_policy_roles.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_safe_error_passthrough.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_session_pool.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/tests/test_stop_signal_runs_atexit.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/access_requirements.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/doctor.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/init_wizard.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/mcp_server/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/mcp_server/__main__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/notify/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/notify/audit.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/inventory.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/iscsi_config.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/iscsi_gate.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/multipath.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/storage_paths.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/vsan.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/vsan_efficiency.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/ops/vsan_sdk.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.2}/vmware_storage/policy_environment.py +0 -0
- {vmware_storage-2.4.0 → 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.
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vmware-storage
|
|
3
|
-
Version: 2.4.
|
|
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.
|
|
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,31 @@
|
|
|
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
|
+
|
|
17
|
+
## v2.4.1 — fixes from real-hardware testing (2026-10-17)
|
|
18
|
+
|
|
19
|
+
* **`list_cached_images` returns what was scanned.** Nothing ever wrote the cache it reads, so it was always empty.
|
|
20
|
+
`scan_datastore_images` now replaces that target's entries for the scanned datastore folder; entries carry
|
|
21
|
+
`target`, so `datastore1` on two hosts stays apart. The cache file is written atomically under a lock; a cache that
|
|
22
|
+
cannot be read or written is reported in `cache_note` and does not fail the scan.
|
|
23
|
+
* **`browse_datastore` / `scan_datastore_images` return well-formed `ds_path`s** for both ESXi and vCenter folder
|
|
24
|
+
paths (an ESXi browse produced `[datastore1] backupX.iso`). A leading `/` is still refused; the error now names the
|
|
25
|
+
corrected path, and vmware-aiops follows the same rule.
|
|
26
|
+
* Error hints: `doctor` is suggested for connection and credential problems, not for an invalid path or an unknown
|
|
27
|
+
target; the unknown-target error names `target=` with the CLI flag in brackets.
|
|
28
|
+
|
|
1
29
|
## v2.4.0 — roles for network callers (HLD §18)
|
|
2
30
|
|
|
3
31
|
* Declares the role metadata vmware-policy ≥ 1.24.0 needs to apply **roles** to this server over HTTPS
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vmware-storage"
|
|
7
|
-
version = "2.4.
|
|
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.
|
|
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.
|
|
10
|
+
"version": "2.4.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "vmware-storage",
|
|
15
|
-
"version": "2.4.
|
|
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.
|
|
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 `
|
|
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.
|
|
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
|
{vmware_storage-2.4.0 → vmware_storage-2.4.2}/skills/vmware-storage/references/capabilities.md
RENAMED
|
@@ -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
|
|
{vmware_storage-2.4.0 → vmware_storage-2.4.2}/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.4.
|
|
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.
|
|
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.
|
|
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,79 @@
|
|
|
1
|
+
"""browse_datastore must return a ds_path that deploy/attach tools can use.
|
|
2
|
+
|
|
3
|
+
Regression source: real ESXi 8 host (home-esxi, 2026-10-17). The browse task
|
|
4
|
+
reports each folder's ``folderPath`` with no trailing separator —
|
|
5
|
+
``"[datastore1]"`` for the root, ``"[datastore1] backup"`` for a folder —
|
|
6
|
+
and the old code glued the file name straight on, producing
|
|
7
|
+
``"[datastore1] backupVMware-VCSA.iso"`` and ``"[datastore1]Hermers - TT"``.
|
|
8
|
+
Agents pass that string into deploy_vm_from_ova / attach_iso_to_vm, so a
|
|
9
|
+
malformed path breaks the very flow the browse exists for. The earlier tests
|
|
10
|
+
mocked ``"[ds1] images/"`` (trailing slash), the one shape that hid the bug.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from pyVmomi import vim
|
|
18
|
+
|
|
19
|
+
from vmware_storage.ops import datastore_browser as dsb
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.unit
|
|
23
|
+
@pytest.mark.parametrize(
|
|
24
|
+
("folder", "name", "expected"),
|
|
25
|
+
[
|
|
26
|
+
# Root, as ESXi reports it: no space after the bracket.
|
|
27
|
+
("[datastore1]", "Hermers - TT", "[datastore1] Hermers - TT"),
|
|
28
|
+
("[datastore1]", "x.iso", "[datastore1] x.iso"),
|
|
29
|
+
# Root, as vCenter reports it: trailing space.
|
|
30
|
+
("[datastore1] ", "x.iso", "[datastore1] x.iso"),
|
|
31
|
+
# Folder without a trailing slash (ESXi).
|
|
32
|
+
("[datastore1] backup", "x.iso", "[datastore1] backup/x.iso"),
|
|
33
|
+
(
|
|
34
|
+
"[datastore1] Hermers - TT",
|
|
35
|
+
"Hermers - TT.vmdk",
|
|
36
|
+
"[datastore1] Hermers - TT/Hermers - TT.vmdk",
|
|
37
|
+
),
|
|
38
|
+
("[datastore1] a/b", "x.iso", "[datastore1] a/b/x.iso"),
|
|
39
|
+
# Folder with a trailing slash (vCenter) — must not double it.
|
|
40
|
+
("[datastore1] backup/", "x.iso", "[datastore1] backup/x.iso"),
|
|
41
|
+
],
|
|
42
|
+
)
|
|
43
|
+
def test_join_ds_path(folder, name, expected):
|
|
44
|
+
assert dsb._join_ds_path(folder, name) == expected
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.unit
|
|
48
|
+
def test_browse_root_returns_well_formed_paths_for_every_folder(monkeypatch):
|
|
49
|
+
"""Root browse is recursive: subfolder rows come back too, each well formed."""
|
|
50
|
+
def _file(path):
|
|
51
|
+
return SimpleNamespace(path=path, fileSize=1024 * 1024, modification="2026-10-17")
|
|
52
|
+
|
|
53
|
+
results = [
|
|
54
|
+
SimpleNamespace(folderPath="[datastore1]", file=[_file("backup")]),
|
|
55
|
+
SimpleNamespace(folderPath="[datastore1] backup", file=[_file("x.iso")]),
|
|
56
|
+
]
|
|
57
|
+
seen = {}
|
|
58
|
+
|
|
59
|
+
def fake_search(datastorePath, searchSpec): # noqa: N803 — pyVmomi API names
|
|
60
|
+
seen["path"] = datastorePath
|
|
61
|
+
return SimpleNamespace(
|
|
62
|
+
info=SimpleNamespace(state=vim.TaskInfo.State.success, result=results, error=None)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
monkeypatch.setattr(
|
|
66
|
+
dsb,
|
|
67
|
+
"find_datastore_by_name",
|
|
68
|
+
lambda si, name: SimpleNamespace(
|
|
69
|
+
browser=SimpleNamespace(SearchDatastoreSubFolders_Task=fake_search)
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
rows = dsb.browse_datastore(object(), "datastore1")["items"]
|
|
74
|
+
|
|
75
|
+
assert seen["path"] == "[datastore1]"
|
|
76
|
+
assert {r["name"]: r["ds_path"] for r in rows} == {
|
|
77
|
+
"backup": "[datastore1] backup",
|
|
78
|
+
"x.iso": "[datastore1] backup/x.iso",
|
|
79
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""browse_datastore must see every file, and treat a leading '/' one way.
|
|
2
|
+
|
|
3
|
+
Real hardware, 2026-10-17: vmware-aiops' search spec listed only IsoImageQuery,
|
|
4
|
+
VmDiskQuery and FolderQuery under a comment saying "include all file types".
|
|
5
|
+
A DatastoreBrowser query is a filter, not a hint -- with those three, .ova /
|
|
6
|
+
.ovf / .vmx / .log files are never returned. On the lab vCenter it found 0 of
|
|
7
|
+
the 2 OVAs on 'datastore1 (1)' that vmware-storage (which also passes the
|
|
8
|
+
generic Query) found, so scan_all_datastore_images could not do the one job its
|
|
9
|
+
description gives it: finding an OVA for deploy_vm_from_ova.
|
|
10
|
+
|
|
11
|
+
The twin also drifted on a leading slash: vmware-aiops passed '/vcsa8' through,
|
|
12
|
+
vmware-storage refused it as "absolute" (its 1.5.35 hardening). Both now refuse
|
|
13
|
+
it, and the error names the corrected path.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from types import SimpleNamespace
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
from pyVmomi import vim
|
|
21
|
+
|
|
22
|
+
from vmware_storage.ops import datastore_browser as dsb
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _browse(monkeypatch, path=""):
|
|
26
|
+
seen: dict = {}
|
|
27
|
+
|
|
28
|
+
def fake_search(datastorePath, searchSpec): # noqa: N803 -- pyVmomi API names
|
|
29
|
+
seen["path"] = datastorePath
|
|
30
|
+
seen["spec"] = searchSpec
|
|
31
|
+
return SimpleNamespace(
|
|
32
|
+
info=SimpleNamespace(state=vim.TaskInfo.State.success, result=[], error=None)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(
|
|
36
|
+
dsb,
|
|
37
|
+
"find_datastore_by_name",
|
|
38
|
+
lambda si, name: SimpleNamespace(
|
|
39
|
+
browser=SimpleNamespace(SearchDatastoreSubFolders_Task=fake_search)
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
dsb.browse_datastore(object(), "datastore1", path=path, pattern="*.ova")
|
|
43
|
+
return seen
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.unit
|
|
47
|
+
def test_search_spec_includes_the_generic_file_query(monkeypatch) -> None:
|
|
48
|
+
spec = _browse(monkeypatch)["spec"]
|
|
49
|
+
# Exact type: every specific query subclasses Query, so isinstance would pass
|
|
50
|
+
# for the three that hid the OVAs.
|
|
51
|
+
assert any(type(q) is vim.host.DatastoreBrowser.Query for q in spec.query)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.unit
|
|
55
|
+
def test_relative_path_is_joined_under_the_datastore(monkeypatch) -> None:
|
|
56
|
+
assert _browse(monkeypatch, "vcsa8")["path"] == "[datastore1] vcsa8"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.unit
|
|
60
|
+
@pytest.mark.parametrize("path", ["/vcsa8", "//vcsa8"])
|
|
61
|
+
def test_leading_slash_is_refused_with_the_fix(monkeypatch, path) -> None:
|
|
62
|
+
with pytest.raises(ValueError, match="path='vcsa8'"):
|
|
63
|
+
_browse(monkeypatch, path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.unit
|
|
67
|
+
@pytest.mark.parametrize("path", ["../other", "a/../../b", "x\x00y", "\\\\server\\share", "/"])
|
|
68
|
+
def test_traversal_and_odd_paths_are_refused(monkeypatch, path) -> None:
|
|
69
|
+
with pytest.raises(ValueError, match="datastore-relative"):
|
|
70
|
+
_browse(monkeypatch, path)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Error hints must fit the error, and the surface that shows them.
|
|
2
|
+
|
|
3
|
+
Real hardware, 2026-10-17: every MCP error carried "Run 'vmware-storage doctor'
|
|
4
|
+
to verify connectivity." -- also for an invalid path or a datastore name that
|
|
5
|
+
does not resolve, where the message already says what to change. And the
|
|
6
|
+
unknown-target error said "Pass --target", a CLI flag; the MCP name is the
|
|
7
|
+
``target`` parameter.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from vmware_storage.config import AppConfig
|
|
15
|
+
from vmware_storage.mcp_server import server
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _unknown_target():
|
|
19
|
+
from vmware_storage.config import UnknownTargetError
|
|
20
|
+
|
|
21
|
+
return UnknownTargetError("Target 'x' not found.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.unit
|
|
25
|
+
@pytest.mark.parametrize("exc", [ValueError("bad"), _unknown_target()])
|
|
26
|
+
def test_an_authored_request_error_does_not_send_the_agent_to_doctor(exc) -> None:
|
|
27
|
+
assert "doctor" not in server._hint_for(exc)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.unit
|
|
31
|
+
@pytest.mark.parametrize("exc", [ConnectionError("x"), TimeoutError("x"), RuntimeError("x")])
|
|
32
|
+
def test_connectivity_and_unplanned_errors_keep_the_doctor_hint(exc) -> None:
|
|
33
|
+
assert server._hint_for(exc) == server._DOCTOR_HINT
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.unit
|
|
37
|
+
def test_unknown_target_names_the_mcp_parameter() -> None:
|
|
38
|
+
with pytest.raises(KeyError) as info:
|
|
39
|
+
AppConfig(targets=()).get_target("nope")
|
|
40
|
+
assert "target=" in str(info.value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.unit
|
|
45
|
+
@pytest.mark.parametrize("exc", [
|
|
46
|
+
__import__("json").JSONDecodeError("Expecting value", "<html>", 0),
|
|
47
|
+
KeyError("summary.capacity"),
|
|
48
|
+
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
|
|
49
|
+
])
|
|
50
|
+
def test_parse_errors_and_bare_key_errors_keep_the_doctor_hint(exc) -> None:
|
|
51
|
+
"""Independent review 2026-10-17: a reverse proxy answering HTML raises
|
|
52
|
+
JSONDecodeError (a ValueError), and a programming slip raises a bare KeyError;
|
|
53
|
+
neither message says what to change, so they must not be told it does."""
|
|
54
|
+
assert server._hint_for(exc) == server._DOCTOR_HINT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.unit
|
|
58
|
+
def test_an_unknown_target_is_still_a_key_error() -> None:
|
|
59
|
+
"""Callers catch KeyError from get_target; the subclass must not break them."""
|
|
60
|
+
assert issubclass(type(_unknown_target()), KeyError)
|
|
@@ -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"]
|