vmware-storage 2.4.0__tar.gz → 2.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/.claude-plugin/plugin.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/.mcp.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/PKG-INFO +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/RELEASE_NOTES.md +12 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/openclaw.plugin.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/package.json +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/pyproject.toml +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/server.json +2 -2
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/skills/vmware-storage/SKILL.md +2 -2
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/skills/vmware-storage/references/setup-guide.md +3 -3
- vmware_storage-2.4.1/tests/eval/regression/test_browse_ds_path_join.py +79 -0
- vmware_storage-2.4.1/tests/eval/regression/test_browse_sees_every_file.py +70 -0
- vmware_storage-2.4.1/tests/eval/regression/test_error_hints_fit_the_surface.py +60 -0
- vmware_storage-2.4.1/tests/eval/regression/test_scan_populates_image_cache.py +184 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/uv.lock +4 -4
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/__init__.py +1 -1
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/cli.py +4 -2
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/config.py +8 -3
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/mcp_server/server.py +37 -9
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/datastore_browser.py +126 -60
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/.clawhubignore +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/.env.example +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/.gitignore +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/Dockerfile +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/LICENSE +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/README-CN.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/README.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/SECURITY.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/config.example.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/docker-compose.yml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/README.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/claude-code.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/continue.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/cursor.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/goose.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/localcowork.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/mcp-agent.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/examples/mcp-configs/vscode-copilot.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/patterns/README.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/patterns/iscsi-target-stale-rescan.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/skills/vmware-storage/evals/evals.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/skills/vmware-storage/references/agent-guardrails.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/skills/vmware-storage/references/capabilities.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/skills/vmware-storage/references/cli-reference.md +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/smithery.yaml +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/conftest.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/_family.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/_scores.json +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/_scoring.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/_skill.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/conftest.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/test_entity_reachability.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/test_error_actionability.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/test_tool_description_quality.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_audit_isolation.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_cached_loader_agrees_with_the_resolver.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_capability_grader.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_credential_env_override.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_datastore_list_renders.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_doctor_uses_the_config_the_tools_use.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_fc_multipath.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_instructions_name_targets.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_inventory_scale_n1.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_iscsi_unreachable_host.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_login_without_a_role.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_release_blockers.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_remedy_survives_a_long_home.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_result_envelope.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_session_probe_eviction.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_storage_hardening_fixes.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_storage_ops_behavior.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_tool_names_2_0.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_vsan_efficiency.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_vsan_health_is_actually_queried.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/regression/test_vsan_reports_what_it_could_not_read.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/spec/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/spec/vsan_efficiency_endpoints.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/eval/spec/vsan_health_endpoints.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_access_info.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_init_and_onboarding.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_iscsi_gate.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_no_destructive_ops.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_no_destructive_vm_code.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_policy_roles.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_safe_error_passthrough.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_session_pool.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/tests/test_stop_signal_runs_atexit.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/access_requirements.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/connection.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/doctor.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/init_wizard.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/mcp_server/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/mcp_server/__main__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/notify/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/notify/audit.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/__init__.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/inventory.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/iscsi_config.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/iscsi_gate.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/multipath.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/storage_paths.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/vsan.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/vsan_efficiency.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/ops/vsan_sdk.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/vmware_storage/policy_environment.py +0 -0
- {vmware_storage-2.4.0 → vmware_storage-2.4.1}/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.1",
|
|
6
6
|
"description": "VMware vSphere storage with 15 MCP tools: datastores, iSCSI, vSAN, FC multipath.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Wei Zhou",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vmware-storage
|
|
3
|
-
Version: 2.4.
|
|
3
|
+
Version: 2.4.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
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## v2.4.1 — fixes from real-hardware testing (2026-10-17)
|
|
2
|
+
|
|
3
|
+
* **`list_cached_images` returns what was scanned.** Nothing ever wrote the cache it reads, so it was always empty.
|
|
4
|
+
`scan_datastore_images` now replaces that target's entries for the scanned datastore folder; entries carry
|
|
5
|
+
`target`, so `datastore1` on two hosts stays apart. The cache file is written atomically under a lock; a cache that
|
|
6
|
+
cannot be read or written is reported in `cache_note` and does not fail the scan.
|
|
7
|
+
* **`browse_datastore` / `scan_datastore_images` return well-formed `ds_path`s** for both ESXi and vCenter folder
|
|
8
|
+
paths (an ESXi browse produced `[datastore1] backupX.iso`). A leading `/` is still refused; the error now names the
|
|
9
|
+
corrected path, and vmware-aiops follows the same rule.
|
|
10
|
+
* Error hints: `doctor` is suggested for connection and credential problems, not for an invalid path or an unknown
|
|
11
|
+
target; the unknown-target error names `target=` with the CLI flag in brackets.
|
|
12
|
+
|
|
1
13
|
## v2.4.0 — roles for network callers (HLD §18)
|
|
2
14
|
|
|
3
15
|
* Declares the role metadata vmware-policy ≥ 1.24.0 needs to apply **roles** to this server over HTTPS
|
|
@@ -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.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "vmware-storage",
|
|
15
|
-
"version": "2.4.
|
|
15
|
+
"version": "2.4.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.4.
|
|
43
|
+
uv tool install vmware-storage==2.4.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
|
```
|
|
@@ -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.1
|
|
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.1}/skills/vmware-storage/references/setup-guide.md
RENAMED
|
@@ -13,19 +13,19 @@ Complete setup and security guide for `vmware-storage`.
|
|
|
13
13
|
### Via uv (recommended)
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
uv tool install vmware-storage==2.4.
|
|
16
|
+
uv tool install vmware-storage==2.4.1
|
|
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.1
|
|
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.1 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,184 @@
|
|
|
1
|
+
"""scan_datastore_images must fill the cache that list_cached_images reads.
|
|
2
|
+
|
|
3
|
+
Real hardware, 2026-10-17: list_cached_images was always empty. Its description
|
|
4
|
+
says the registry is "populated by prior datastore scans", but the only writer,
|
|
5
|
+
``update_registry()``, had no caller anywhere -- no MCP tool, no CLI command --
|
|
6
|
+
so the tool could never return anything, and SKILL.md told agents to prefer it
|
|
7
|
+
over re-scanning.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from vmware_storage.ops import datastore_browser as dsb
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _img(ds: str, rel: str) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"name": rel.rsplit("/", 1)[-1],
|
|
22
|
+
"size_mb": 1.0,
|
|
23
|
+
"type": "File",
|
|
24
|
+
"modified": "",
|
|
25
|
+
"ds_path": f"[{ds}] {rel}",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def registry(tmp_path, monkeypatch):
|
|
31
|
+
path = tmp_path / "image_registry.json"
|
|
32
|
+
monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
|
|
33
|
+
monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _scan_returns(monkeypatch, rows_by_pattern: dict[str, list[dict]]) -> None:
|
|
38
|
+
def fake_browse(si, ds_name, path="", pattern="*"):
|
|
39
|
+
rows = rows_by_pattern.get(pattern, [])
|
|
40
|
+
return {"items": rows, "total": len(rows)}
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(dsb, "browse_datastore", fake_browse)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.unit
|
|
46
|
+
def test_scan_then_list_cached_returns_what_was_scanned(registry, monkeypatch) -> None:
|
|
47
|
+
_scan_returns(monkeypatch, {"*.ova": [_img("ds1", "ec-wrkr.ova")]})
|
|
48
|
+
|
|
49
|
+
dsb.scan_images(object(), "ds1")
|
|
50
|
+
cached = dsb.list_images(image_type="ova")
|
|
51
|
+
|
|
52
|
+
assert [i["ds_path"] for i in cached["items"]] == ["[ds1] ec-wrkr.ova"]
|
|
53
|
+
assert cached["items"][0]["datastore"] == "ds1"
|
|
54
|
+
assert json.loads(registry.read_text(encoding="utf-8"))["last_scan"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.unit
|
|
58
|
+
def test_rescan_replaces_that_datastore_and_keeps_the_others(registry, monkeypatch) -> None:
|
|
59
|
+
_scan_returns(monkeypatch, {"*.ova": [_img("ds1", "old.ova")]})
|
|
60
|
+
dsb.scan_images(object(), "ds1")
|
|
61
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds2", "x.iso")]})
|
|
62
|
+
dsb.scan_images(object(), "ds2")
|
|
63
|
+
_scan_returns(monkeypatch, {"*.ova": [_img("ds1", "new.ova")]})
|
|
64
|
+
dsb.scan_images(object(), "ds1")
|
|
65
|
+
|
|
66
|
+
paths = sorted(i["ds_path"] for i in dsb.list_images()["items"])
|
|
67
|
+
assert paths == ["[ds1] new.ova", "[ds2] x.iso"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.unit
|
|
71
|
+
def test_subfolder_scan_only_replaces_that_subfolder(registry, monkeypatch) -> None:
|
|
72
|
+
_scan_returns(monkeypatch, {"*.ova": [_img("ds1", "a/one.ova"), _img("ds1", "b/two.ova")]})
|
|
73
|
+
dsb.scan_images(object(), "ds1")
|
|
74
|
+
_scan_returns(monkeypatch, {"*.ova": [_img("ds1", "a/three.ova")]})
|
|
75
|
+
dsb.scan_images(object(), "ds1", path="a")
|
|
76
|
+
|
|
77
|
+
paths = sorted(i["ds_path"] for i in dsb.list_images()["items"])
|
|
78
|
+
assert paths == ["[ds1] a/three.ova", "[ds1] b/two.ova"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.unit
|
|
82
|
+
def test_an_unwritable_cache_does_not_fail_the_scan(registry, monkeypatch) -> None:
|
|
83
|
+
_scan_returns(monkeypatch, {"*.ova": [_img("ds1", "x.ova")]})
|
|
84
|
+
|
|
85
|
+
def boom(_registry):
|
|
86
|
+
raise OSError("read-only file system")
|
|
87
|
+
|
|
88
|
+
monkeypatch.setattr(dsb, "_save_registry", boom)
|
|
89
|
+
result = dsb.scan_images(object(), "ds1")
|
|
90
|
+
|
|
91
|
+
assert result["total"] == 1
|
|
92
|
+
assert "cache_note" in result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── independent review, 2026-10-17 ───────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.unit
|
|
99
|
+
def test_a_corrupt_cache_does_not_fail_the_scan(registry, monkeypatch) -> None:
|
|
100
|
+
registry.write_text("{not json", encoding="utf-8")
|
|
101
|
+
_scan_returns(monkeypatch, {"*.ova": [_img("ds1", "x.ova")]})
|
|
102
|
+
|
|
103
|
+
result = dsb.scan_images(object(), "ds1")
|
|
104
|
+
|
|
105
|
+
assert result["total"] == 1 and "cache_note" in result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.unit
|
|
109
|
+
def test_a_corrupt_cache_tells_the_reader_how_to_recover(registry) -> None:
|
|
110
|
+
registry.write_text("[1, 2]", encoding="utf-8")
|
|
111
|
+
with pytest.raises(dsb.DatastoreBrowseError, match="scan_datastore_images"):
|
|
112
|
+
dsb.list_images()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.unit
|
|
116
|
+
def test_the_same_datastore_name_on_two_targets_is_kept_apart(registry, monkeypatch) -> None:
|
|
117
|
+
"""'datastore1' exists on every ESXi host; one target's scan must not erase another's."""
|
|
118
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds1", "a.iso")]})
|
|
119
|
+
dsb.scan_images(object(), "ds1", target="vc-a")
|
|
120
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds1", "b.iso")]})
|
|
121
|
+
dsb.scan_images(object(), "ds1", target="esx-b")
|
|
122
|
+
|
|
123
|
+
items = dsb.list_images()["items"]
|
|
124
|
+
assert sorted((i["target"], i["ds_path"]) for i in items) == [
|
|
125
|
+
("esx-b", "[ds1] b.iso"), ("vc-a", "[ds1] a.iso")]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.unit
|
|
129
|
+
def test_a_dot_slash_path_replaces_that_folder(registry, monkeypatch) -> None:
|
|
130
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds1", "a/old.iso")]})
|
|
131
|
+
dsb.scan_images(object(), "ds1")
|
|
132
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds1", "a/new.iso")]})
|
|
133
|
+
dsb.scan_images(object(), "ds1", path="./a")
|
|
134
|
+
|
|
135
|
+
assert [i["ds_path"] for i in dsb.list_images()["items"]] == ["[ds1] a/new.iso"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.unit
|
|
139
|
+
def test_concurrent_scans_lose_no_entries(registry, monkeypatch) -> None:
|
|
140
|
+
"""With the HTTPS session pool, scans run side by side."""
|
|
141
|
+
import threading
|
|
142
|
+
|
|
143
|
+
def fake_browse(si, ds_name, path="", pattern="*"):
|
|
144
|
+
rows = [_img(ds_name, "x.iso")] if pattern == "*.iso" else []
|
|
145
|
+
return {"items": rows, "total": len(rows)}
|
|
146
|
+
|
|
147
|
+
monkeypatch.setattr(dsb, "browse_datastore", fake_browse)
|
|
148
|
+
threads = [threading.Thread(target=dsb.scan_images, args=(object(), f"ds{i}"))
|
|
149
|
+
for i in range(20)]
|
|
150
|
+
for t in threads:
|
|
151
|
+
t.start()
|
|
152
|
+
for t in threads:
|
|
153
|
+
t.join()
|
|
154
|
+
|
|
155
|
+
assert dsb.list_images()["total"] == 20
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.unit
|
|
159
|
+
def test_the_registry_is_replaced_atomically(registry, monkeypatch) -> None:
|
|
160
|
+
"""A write that dies half-way must leave the previous file, not a truncated one."""
|
|
161
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds1", "keep.iso")]})
|
|
162
|
+
dsb.scan_images(object(), "ds1")
|
|
163
|
+
before = registry.read_text(encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
def boom(*_a, **_k):
|
|
166
|
+
raise OSError("disk full")
|
|
167
|
+
|
|
168
|
+
monkeypatch.setattr(dsb.json, "dump", boom)
|
|
169
|
+
monkeypatch.setattr(dsb.json, "dumps", boom)
|
|
170
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds2", "y.iso")]})
|
|
171
|
+
dsb.scan_images(object(), "ds2")
|
|
172
|
+
|
|
173
|
+
assert registry.read_text(encoding="utf-8") == before
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.unit
|
|
177
|
+
def test_a_row_from_before_targets_is_replaced_not_duplicated(registry, monkeypatch) -> None:
|
|
178
|
+
registry.write_text('{"images": [{"datastore": "ds1", "name": "x.iso", '
|
|
179
|
+
'"ds_path": "[ds1] x.iso"}], "last_scan": null}', encoding="utf-8")
|
|
180
|
+
_scan_returns(monkeypatch, {"*.iso": [_img("ds1", "x.iso")]})
|
|
181
|
+
dsb.scan_images(object(), "ds1", target="vc")
|
|
182
|
+
dsb.scan_images(object(), "ds1", target="vc")
|
|
183
|
+
|
|
184
|
+
assert dsb.list_images(datastore="ds1")["total"] == 1
|
|
@@ -453,7 +453,7 @@ wheels = [
|
|
|
453
453
|
|
|
454
454
|
[[package]]
|
|
455
455
|
name = "mcp"
|
|
456
|
-
version = "1.
|
|
456
|
+
version = "1.30.0"
|
|
457
457
|
source = { registry = "https://pypi.org/simple" }
|
|
458
458
|
dependencies = [
|
|
459
459
|
{ name = "anyio" },
|
|
@@ -471,9 +471,9 @@ dependencies = [
|
|
|
471
471
|
{ name = "typing-inspection" },
|
|
472
472
|
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
|
473
473
|
]
|
|
474
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
474
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" }
|
|
475
475
|
wheels = [
|
|
476
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
476
|
+
{ url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" },
|
|
477
477
|
]
|
|
478
478
|
|
|
479
479
|
[package.optional-dependencies]
|
|
@@ -1176,7 +1176,7 @@ wheels = [
|
|
|
1176
1176
|
|
|
1177
1177
|
[[package]]
|
|
1178
1178
|
name = "vmware-storage"
|
|
1179
|
-
version = "2.4.
|
|
1179
|
+
version = "2.4.1"
|
|
1180
1180
|
source = { editable = "." }
|
|
1181
1181
|
dependencies = [
|
|
1182
1182
|
{ name = "mcp", extra = ["cli"] },
|
|
@@ -268,8 +268,10 @@ def ds_scan_images(
|
|
|
268
268
|
"""Scan a datastore for deployable images (OVA/ISO/OVF/VMDK)."""
|
|
269
269
|
from vmware_storage.ops.datastore_browser import scan_images
|
|
270
270
|
|
|
271
|
-
|
|
272
|
-
|
|
271
|
+
conn_mgr = ConnectionManager(load_config(Path(config) if config else None))
|
|
272
|
+
name = conn_mgr.resolve_target_name(target)
|
|
273
|
+
si = conn_mgr.connect(name)
|
|
274
|
+
result = scan_images(si, ds_name, target=name)["items"]
|
|
273
275
|
_audit.log_query(target=target or "default", resource=ds_name, query_type="scan_images")
|
|
274
276
|
_print_json(result)
|
|
275
277
|
|
|
@@ -128,6 +128,11 @@ def _check_env_permissions() -> None:
|
|
|
128
128
|
_check_env_permissions()
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
|
|
132
|
+
class UnknownTargetError(KeyError):
|
|
133
|
+
"""A target name that is not in config.yaml. A KeyError, as callers expect,
|
|
134
|
+
but its own type so the MCP hint can tell it from a programming slip."""
|
|
135
|
+
|
|
131
136
|
class ConfigError(OSError):
|
|
132
137
|
"""A configuration problem the operator can fix, safe to show an agent.
|
|
133
138
|
|
|
@@ -224,9 +229,9 @@ class AppConfig:
|
|
|
224
229
|
if t.name == name:
|
|
225
230
|
return t
|
|
226
231
|
available = ", ".join(t.name for t in self.targets) or "(none configured)"
|
|
227
|
-
raise
|
|
228
|
-
f"Target '{name}' not found in config.yaml. Pass
|
|
229
|
-
f"
|
|
232
|
+
raise UnknownTargetError(
|
|
233
|
+
f"Target '{name}' not found in config.yaml. Pass target=<one of the "
|
|
234
|
+
f"names below> (CLI: --target), or add a targets: entry to config.yaml (see "
|
|
230
235
|
f"config.example.yaml) and re-run 'vmware-storage doctor'. "
|
|
231
236
|
f"Available: {available}"
|
|
232
237
|
)
|