vcf-super-cli 0.4.0__tar.gz → 0.5.0__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.
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/CHANGELOG.md +19 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/PKG-INFO +3 -2
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/README.md +2 -1
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/commands.md +11 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/design.md +6 -2
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/index.md +6 -4
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/profiles.md +4 -0
- vcf_super_cli-0.5.0/docs/superpowers/specs/2026-06-08-vcf-super-cli-v0.5-find-vms-design.md +78 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/pyproject.toml +1 -1
- vcf_super_cli-0.5.0/tests/test_pyvmomi_find.py +342 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/uv.lock +1 -1
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/_version.py +1 -1
- vcf_super_cli-0.5.0/vsc/pyvmomi/find.py +187 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/inventory.py +60 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/skill/assets/SKILL.md +6 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/docs.yml +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/e2e.yml +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/lint.yml +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/release.yml +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/test.yml +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.gitignore +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/AGENTS.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/LICENSE +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/install.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/superpowers/specs/2026-06-05-vcf-super-cli-design.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.2-writes-design.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.3-ergonomics-design.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/usage.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/writes.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/mkdocs.yml +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/README.md +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/conftest.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/test_read_smoke.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_builder.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_cli.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_complete.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_complete_cache.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_complete_dynamic.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_config.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_discover.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_errors.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_filter_pagination_cli.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_filters.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_paginate.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_params.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_preview.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_events.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_inventory.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_perf.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_tasks.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_render.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_resources.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_session.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_skill.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_version.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_vmomi.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_write_errors.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/app.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/profiles.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/skill.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/config/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/config/schema.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/config/store.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/session.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/targets.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/vmomi.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/builder.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/complete.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/complete_cache.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/complete_dynamic.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/discover.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/filters.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/model.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/paginate.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/params.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/preview.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/resources.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/logging_config.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/errors.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/exit_codes.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/render.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/events.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/perf.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/runner.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/tasks.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/skill/__init__.py +0 -0
- {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/skill/export.py +0 -0
|
@@ -7,6 +7,25 @@ versions may include breaking changes.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## v0.5.0 — 2026-06-08
|
|
11
|
+
|
|
12
|
+
Find VMs by attribute. Answers the everyday *"which VM has `10.20.3.41`?"* — a
|
|
13
|
+
reverse lookup the REST `vm list` filter can't do, because guest networking lives
|
|
14
|
+
only on the pyVmomi `guest.*` properties.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`vsc vsphere inventory find`** (#54) — locate VMs by guest/runtime attribute
|
|
19
|
+
without knowing the moid: `--ip` (exact or CIDR, IPv4/IPv6, across the primary and
|
|
20
|
+
every NIC address), `--name`, `--hostname`, `--guest-os` (case-insensitive
|
|
21
|
+
substring or glob), `--mac` (exact), and `--power-state`. Flags **AND** together; a
|
|
22
|
+
repeated flag **ORs** within its field; at least one match flag is required (it
|
|
23
|
+
refuses to dump the whole inventory). `--props` (repeatable) widens each hit's
|
|
24
|
+
output only — never a match criterion. One PropertyCollector round-trip over a
|
|
25
|
+
container view (always destroyed); a pure, pyVmomi-free matcher does the filtering.
|
|
26
|
+
Reads only — no `--apply` — and emits the same JSON / error envelope / exit codes
|
|
27
|
+
as the other pyVmomi fallback commands.
|
|
28
|
+
|
|
10
29
|
## v0.4.0 — 2026-06-08
|
|
11
30
|
|
|
12
31
|
Live resource-id completion. Pressing `<TAB>` on an id-typed argument can now
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vcf-super-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Modern, agent-friendly CLI for VMware Cloud Foundation 9, with a command tree generated dynamically from the vcf-sdk vAPI bindings.
|
|
5
5
|
Project-URL: Homepage, https://github.com/thomaschristory/vcf-super-cli
|
|
6
6
|
Project-URL: Documentation, https://thomaschristory.github.io/vcf-super-cli/
|
|
@@ -74,7 +74,8 @@ $ vsc vsphere power stop vm-42 --apply # writes are dry-run without --app
|
|
|
74
74
|
| NSX **Policy API** read (`vsc nsx …`) | ✅ v0.1 |
|
|
75
75
|
| Writes — dry-run by default + `--apply` (`vsc vsphere …` / `vsc nsx …`) | ✅ v0.2 |
|
|
76
76
|
| Ergonomics — offline shell completion, per-field filter flags + paging, pyVmomi fallback (`perf`/`events`/`tasks`/`inventory`) | ✅ v0.3 |
|
|
77
|
-
| Live resource-id completion |
|
|
77
|
+
| Live resource-id completion | ✅ v0.4 |
|
|
78
|
+
| Find VMs by attribute — IP / hostname / MAC / guest OS / power (`vsc vsphere inventory find`) | ✅ v0.5 |
|
|
78
79
|
| NSX Manager / Global-Manager, SDDC Manager, Operations, LCM | deferred |
|
|
79
80
|
|
|
80
81
|
## Install
|
|
@@ -42,7 +42,8 @@ $ vsc vsphere power stop vm-42 --apply # writes are dry-run without --app
|
|
|
42
42
|
| NSX **Policy API** read (`vsc nsx …`) | ✅ v0.1 |
|
|
43
43
|
| Writes — dry-run by default + `--apply` (`vsc vsphere …` / `vsc nsx …`) | ✅ v0.2 |
|
|
44
44
|
| Ergonomics — offline shell completion, per-field filter flags + paging, pyVmomi fallback (`perf`/`events`/`tasks`/`inventory`) | ✅ v0.3 |
|
|
45
|
-
| Live resource-id completion |
|
|
45
|
+
| Live resource-id completion | ✅ v0.4 |
|
|
46
|
+
| Find VMs by attribute — IP / hostname / MAC / guest OS / power (`vsc vsphere inventory find`) | ✅ v0.5 |
|
|
46
47
|
| NSX Manager / Global-Manager, SDDC Manager, Operations, LCM | deferred |
|
|
47
48
|
|
|
48
49
|
## Install
|
|
@@ -51,6 +51,17 @@ API. Those commands live under `vsc vsphere` alongside the generated ones, are
|
|
|
51
51
|
managed-object properties the REST list ops omit (device tree, custom attributes),
|
|
52
52
|
via the PropertyCollector. `--props` is repeatable (e.g. `--props config.hardware`);
|
|
53
53
|
with none, a small per-type summary set is returned.
|
|
54
|
+
- `vsc vsphere inventory find [--ip …] [--name …] [--hostname …] [--mac …] [--guest-os …] [--power-state …] [--props …]`
|
|
55
|
+
— **find VMs by guest/runtime attribute without knowing the moid.** This is the
|
|
56
|
+
answer to *"which VM has `10.20.3.41`?"*: guest networking isn't in the REST
|
|
57
|
+
`vm list` filter, so this sweeps every VM's `guest.*` properties in one round-trip.
|
|
58
|
+
`--ip` accepts an exact address or a CIDR (`10.20.3.0/24`, IPv4 or IPv6) and matches
|
|
59
|
+
across the primary and every NIC address; `--name`/`--hostname`/`--guest-os` are
|
|
60
|
+
case-insensitive substring or glob; `--mac` is exact; `--power-state` is one of
|
|
61
|
+
`poweredOn`/`poweredOff`/`suspended`. Flags **AND** together, a repeated flag **ORs**
|
|
62
|
+
within its field, and at least one match flag is required. `--props` (repeatable)
|
|
63
|
+
widens each hit's output only — it never affects matching. Powered-off VMs and those
|
|
64
|
+
without VMware Tools report no guest IP and won't match `--ip`.
|
|
54
65
|
|
|
55
66
|
## `vsc nsx …` (NSX Policy)
|
|
56
67
|
|
|
@@ -68,8 +68,11 @@ SOAP API. Those are **hand-written read-only commands** mounted under
|
|
|
68
68
|
and exit codes (`vsc/pyvmomi/runner.py`).
|
|
69
69
|
|
|
70
70
|
Commands: `perf` (PerformanceManager counters), `events` / `tasks`
|
|
71
|
-
(Event/Task managers), and `inventory`
|
|
72
|
-
|
|
71
|
+
(Event/Task managers), and `inventory` — a PropertyCollector property walk
|
|
72
|
+
(`inventory vm`/`host`) plus `inventory find`, which sweeps every VM's `guest.*`
|
|
73
|
+
properties in one round-trip to locate a VM by IP / name / hostname / MAC / guest
|
|
74
|
+
OS / power state (the fields the REST `vm list` filter can't reach). All are
|
|
75
|
+
reads — no `--apply`.
|
|
73
76
|
|
|
74
77
|
## Design specs
|
|
75
78
|
|
|
@@ -78,6 +81,7 @@ The authoritative, milestone-by-milestone designs live in the repository:
|
|
|
78
81
|
- [v0.1 — dynamic read-only CLI](https://github.com/thomaschristory/vcf-super-cli/blob/main/docs/superpowers/specs/2026-06-05-vcf-super-cli-design.md)
|
|
79
82
|
- [v0.2 — writes](https://github.com/thomaschristory/vcf-super-cli/blob/main/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.2-writes-design.md)
|
|
80
83
|
- [v0.3 — ergonomics](https://github.com/thomaschristory/vcf-super-cli/blob/main/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.3-ergonomics-design.md)
|
|
84
|
+
- [v0.5 — find VMs by attribute](https://github.com/thomaschristory/vcf-super-cli/blob/main/docs/superpowers/specs/2026-06-08-vcf-super-cli-v0.5-find-vms-design.md)
|
|
81
85
|
|
|
82
86
|
## Contracts
|
|
83
87
|
|
|
@@ -19,11 +19,13 @@ $ vsc vsphere perf vm vm-42 --metric cpu.usage
|
|
|
19
19
|
|
|
20
20
|
- **Mirrors the real API** — commands come from the SDK's vAPI metadata, covering
|
|
21
21
|
vCenter and NSX from one generator.
|
|
22
|
-
- **Ergonomic** —
|
|
23
|
-
|
|
24
|
-
`--max-items` /
|
|
22
|
+
- **Ergonomic** — tab-completion (enums, formats, profiles, filter choices, and
|
|
23
|
+
opt-in [live resource ids](usage.md#live-resource-id-completion-opt-in)),
|
|
24
|
+
per-field `--<field>` filter flags, and paging (`--all` / `--max-items` /
|
|
25
|
+
`--limit`).
|
|
25
26
|
- **pyVmomi fallback** — read-only `perf`, `events`, `tasks`, and `inventory`
|
|
26
|
-
commands for areas the REST/vAPI surface doesn't cover
|
|
27
|
+
commands for areas the REST/vAPI surface doesn't cover, including
|
|
28
|
+
`inventory find` to locate a VM by IP / hostname / MAC / guest OS.
|
|
27
29
|
- **Safe by default** — writes are dry-run unless `--apply`; a dry-run never connects.
|
|
28
30
|
- **Agent-friendly** — JSON output, stable error envelope, documented exit codes,
|
|
29
31
|
bundled agent Skill.
|
|
@@ -56,6 +56,10 @@ VSC_PROFILE=prod vsc nsx segments list
|
|
|
56
56
|
| `VSC_VSPHERE_INSECURE` / `VSC_NSX_INSECURE` | `1`/`true` to skip TLS verification (lab/self-signed) |
|
|
57
57
|
| `VSC_CONFIG_FILE` | Override the config file location |
|
|
58
58
|
| `VSC_LOG_LEVEL` | Log level on stderr (default `WARNING`) |
|
|
59
|
+
| `VSC_COMPLETE_DYNAMIC` | `1`/`true` to enable [live resource-id completion](usage.md#live-resource-id-completion-opt-in) (off by default) |
|
|
60
|
+
| `VSC_COMPLETE_TTL` | Live-completion cache TTL in seconds (default `60`) |
|
|
61
|
+
| `VSC_COMPLETE_TIMEOUT` | Hard timeout for a live-completion fetch in seconds (default `2`) |
|
|
62
|
+
| `VSC_CACHE_DIR` | Override the cache directory (where live-completion results are cached) |
|
|
59
63
|
|
|
60
64
|
!!! warning "TLS"
|
|
61
65
|
Verification is **on by default**. Only set `VSC_*_INSECURE` (or
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# v0.5 — find VMs by attribute
|
|
2
|
+
|
|
3
|
+
**Milestone:** v0.5 — find VMs · **Issue:** #54 · **Date:** 2026-06-08
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
You can `vsc vsphere vm list` and filter on what the vAPI `VM.FilterSpec` exposes
|
|
8
|
+
(names, power states, hosts, clusters, datacenters, folders, resource pools).
|
|
9
|
+
None of the **guest/runtime network fields** are filterable there, so the everyday
|
|
10
|
+
question *"which VM has `10.20.3.41`?"* is unanswerable from the CLI. Guest
|
|
11
|
+
networking lives only on the pyVmomi `guest.*` properties; the per-VM REST guest
|
|
12
|
+
API needs the moid first, useless for a reverse lookup.
|
|
13
|
+
|
|
14
|
+
## Command
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
vsc vsphere inventory find [--ip ...] [--name ...] [--hostname ...] [--mac ...]
|
|
18
|
+
[--guest-os ...] [--power-state ...] [--props ...] [-o ...]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Third command under `inventory_app`, next to `vm`/`host`. VM-focused for v1.
|
|
22
|
+
|
|
23
|
+
### Match semantics
|
|
24
|
+
|
|
25
|
+
- Flags **AND** together; a repeated flag **ORs** within that field.
|
|
26
|
+
- `--ip` matches any address across `guest.ipAddress` + every `guest.net[].ipAddress`,
|
|
27
|
+
as an **exact IP or CIDR** via stdlib `ipaddress` (IPv4 and IPv6). An exact IP is
|
|
28
|
+
just a `/32` (or `/128`) network, so one code path covers both.
|
|
29
|
+
- `--name`, `--hostname`, `--guest-os` are case-insensitive **substring**, or
|
|
30
|
+
**glob** when the pattern has `*?[`. `--name` is fully client-side (one matcher).
|
|
31
|
+
- `--mac` exact, case-insensitive, against `guest.net[].macAddress`.
|
|
32
|
+
- `--power-state` ∈ {poweredOn, poweredOff, suspended} against `runtime.powerState`.
|
|
33
|
+
- **No match flag → usage error** (exit 2); we refuse to dump the whole inventory.
|
|
34
|
+
`--props`/`-o` alone do not count.
|
|
35
|
+
|
|
36
|
+
### `--props` passthrough (output only)
|
|
37
|
+
|
|
38
|
+
Repeatable; mirrors `inventory vm --props`. Appends arbitrary property paths to the
|
|
39
|
+
single PropertyCollector retrieve so each **matched** VM also surfaces them under a
|
|
40
|
+
`properties` sub-dict — bridging search→inspect in one call. Never a match criterion.
|
|
41
|
+
|
|
42
|
+
## Implementation
|
|
43
|
+
|
|
44
|
+
`vsc/pyvmomi/find.py`:
|
|
45
|
+
|
|
46
|
+
- **Pure matcher** (`Criteria`, `matches`, `summarize`, `validate_criteria`, plus
|
|
47
|
+
`_ip_match`/`_text_match`/`_addresses`/`_macs`) — operates only on a plain
|
|
48
|
+
props dict, no pyVmomi import in the hot path, so it unit-tests in isolation.
|
|
49
|
+
- **One round-trip** (`find_matches` → `_retrieve_all_vms`): a
|
|
50
|
+
`CreateContainerView(rootFolder, [vim.VirtualMachine], recursive=True)` plus a
|
|
51
|
+
single `RetrieveContents` with a `TraversalSpec` pulling the fixed search paths
|
|
52
|
+
**plus** any `--props`. The container view is **always destroyed** in a `finally`.
|
|
53
|
+
|
|
54
|
+
`vsc/pyvmomi/inventory.py` adds the thin `find` command: builds `Criteria` from the
|
|
55
|
+
flags, rejects an empty criteria set and malformed `--ip`/`--power-state` as usage
|
|
56
|
+
errors, and emits through the shared `run_read` runner (same JSON / error envelope /
|
|
57
|
+
exit codes as the other pyVmomi reads).
|
|
58
|
+
|
|
59
|
+
### Per-hit shape
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "obj": {"type": "VirtualMachine", "value": "vm-101"},
|
|
63
|
+
"name": "web-1", "power_state": "poweredOn",
|
|
64
|
+
"ip_addresses": ["10.20.3.41"], "hostname": "web-1.corp",
|
|
65
|
+
"guest_os": "Ubuntu Linux (64-bit)",
|
|
66
|
+
"properties": { "config.version": "vmx-19" } } // only with --props
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
No matches → empty array, exit 0.
|
|
70
|
+
|
|
71
|
+
## Scope / non-goals (v1)
|
|
72
|
+
|
|
73
|
+
- vSphere/pyVmomi only; NSX has its own search surface.
|
|
74
|
+
- VMs only (no host/network/datastore search yet).
|
|
75
|
+
- Single `RetrieveContents` (no paging). Very large inventories may later want
|
|
76
|
+
`RetrievePropertiesEx` + `maxObjects` paging — a scaling follow-up.
|
|
77
|
+
- VMs without VMware Tools / powered-off report no (or stale) guest IP and won't
|
|
78
|
+
match `--ip`; expected, noted in the docs.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "vcf-super-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Modern, agent-friendly CLI for VMware Cloud Foundation 9, with a command tree generated dynamically from the vcf-sdk vAPI bindings."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""`vsc vsphere inventory find` — pure matcher + container-view retrieval + CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import typer
|
|
10
|
+
from pyVmomi import vim, vmodl
|
|
11
|
+
from typer.testing import CliRunner
|
|
12
|
+
|
|
13
|
+
from vsc.pyvmomi.find import (
|
|
14
|
+
Criteria,
|
|
15
|
+
find_matches,
|
|
16
|
+
matches,
|
|
17
|
+
summarize,
|
|
18
|
+
validate_criteria,
|
|
19
|
+
)
|
|
20
|
+
from vsc.pyvmomi.inventory import inventory_app
|
|
21
|
+
|
|
22
|
+
runner = CliRunner()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
# props-dict fixtures (the matcher only ever sees plain dicts)
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _props(
|
|
31
|
+
*,
|
|
32
|
+
name: str = "web-1",
|
|
33
|
+
power: str = "poweredOn",
|
|
34
|
+
primary_ip: str | None = "10.20.3.41",
|
|
35
|
+
nics: list[dict[str, Any]] | None = None,
|
|
36
|
+
hostname: str | None = "web-1.corp",
|
|
37
|
+
guest_os: str | None = "Ubuntu Linux (64-bit)",
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
props: dict[str, Any] = {"name": name, "runtime.powerState": power}
|
|
40
|
+
if primary_ip is not None:
|
|
41
|
+
props["guest.ipAddress"] = primary_ip
|
|
42
|
+
if nics is not None:
|
|
43
|
+
props["guest.net"] = nics
|
|
44
|
+
if hostname is not None:
|
|
45
|
+
props["guest.hostName"] = hostname
|
|
46
|
+
if guest_os is not None:
|
|
47
|
+
props["guest.guestFullName"] = guest_os
|
|
48
|
+
return props
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------------- #
|
|
52
|
+
# matcher — IP
|
|
53
|
+
# --------------------------------------------------------------------------- #
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_match_exact_ip() -> None:
|
|
57
|
+
assert matches(_props(), Criteria(ip=("10.20.3.41",)))
|
|
58
|
+
assert not matches(_props(), Criteria(ip=("10.20.3.42",)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_match_cidr() -> None:
|
|
62
|
+
assert matches(_props(), Criteria(ip=("10.20.3.0/24",)))
|
|
63
|
+
assert not matches(_props(), Criteria(ip=("10.20.4.0/24",)))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_match_ipv6() -> None:
|
|
67
|
+
props = _props(primary_ip=None, nics=[{"ipAddress": ["fe80::1", "2001:db8::5"]}])
|
|
68
|
+
assert matches(props, Criteria(ip=("2001:db8::5",)))
|
|
69
|
+
assert matches(props, Criteria(ip=("2001:db8::/32",)))
|
|
70
|
+
assert not matches(props, Criteria(ip=("2001:dead::/32",)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_match_ip_across_multiple_nics() -> None:
|
|
74
|
+
props = _props(
|
|
75
|
+
primary_ip="10.0.0.1",
|
|
76
|
+
nics=[{"ipAddress": ["10.0.0.1"]}, {"ipAddress": ["192.168.5.9"]}],
|
|
77
|
+
)
|
|
78
|
+
assert matches(props, Criteria(ip=("192.168.5.9",)))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_no_tools_vm_has_no_ip() -> None:
|
|
82
|
+
bare = {"name": "db-1", "runtime.powerState": "poweredOff"}
|
|
83
|
+
assert not matches(bare, Criteria(ip=("10.20.3.41",)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_ip_or_within_field() -> None:
|
|
87
|
+
# Repeated --ip is OR: the VM only needs one of them.
|
|
88
|
+
assert matches(_props(), Criteria(ip=("10.0.0.9", "10.20.3.41")))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_garbage_guest_address_does_not_crash() -> None:
|
|
92
|
+
props = _props(primary_ip="not-an-ip", nics=[{"ipAddress": ["10.20.3.41"]}])
|
|
93
|
+
assert matches(props, Criteria(ip=("10.20.3.41",)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_ip_family_mismatch_is_no_match_not_crash() -> None:
|
|
97
|
+
# An IPv6 pattern against an IPv4-only guest must simply not match — the
|
|
98
|
+
# stdlib membership test short-circuits on version, so no exception leaks.
|
|
99
|
+
assert not matches(_props(primary_ip="10.20.3.41"), Criteria(ip=("2001:db8::1",)))
|
|
100
|
+
assert not matches(_props(primary_ip="10.20.3.41"), Criteria(ip=("2001:db8::/32",)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# matcher — text / mac / power
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_name_substring_case_insensitive() -> None:
|
|
109
|
+
assert matches(_props(name="PROD-web-1"), Criteria(name=("web",)))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_name_glob() -> None:
|
|
113
|
+
assert matches(_props(name="web-prod-1"), Criteria(name=("web-*-1",)))
|
|
114
|
+
assert not matches(_props(name="web-prod-2"), Criteria(name=("web-*-1",)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_hostname_and_guest_os_match() -> None:
|
|
118
|
+
assert matches(_props(), Criteria(hostname=("corp",)))
|
|
119
|
+
assert matches(_props(), Criteria(guest_os=("ubuntu",)))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_mac_exact_case_insensitive() -> None:
|
|
123
|
+
props = _props(nics=[{"macAddress": "00:50:56:AA:BB:CC"}])
|
|
124
|
+
assert matches(props, Criteria(mac=("00:50:56:aa:bb:cc",)))
|
|
125
|
+
assert not matches(props, Criteria(mac=("00:50:56:aa:bb:cd",)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_power_state_match() -> None:
|
|
129
|
+
assert matches(_props(power="poweredOff"), Criteria(power_state=("poweredOff",)))
|
|
130
|
+
assert not matches(_props(power="poweredOn"), Criteria(power_state=("poweredOff",)))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------------------------- #
|
|
134
|
+
# matcher — AND across fields, no-match
|
|
135
|
+
# --------------------------------------------------------------------------- #
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_and_across_fields() -> None:
|
|
139
|
+
crit = Criteria(ip=("10.20.3.41",), power_state=("poweredOn",), name=("web",))
|
|
140
|
+
assert matches(_props(), crit)
|
|
141
|
+
# One failing field fails the whole match (AND).
|
|
142
|
+
assert not matches(_props(power="poweredOff"), crit)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_no_match_returns_false() -> None:
|
|
146
|
+
assert not matches(_props(), Criteria(name=("does-not-exist",)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------------------- #
|
|
150
|
+
# Criteria / validation
|
|
151
|
+
# --------------------------------------------------------------------------- #
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_is_empty() -> None:
|
|
155
|
+
assert Criteria().is_empty
|
|
156
|
+
assert not Criteria(ip=("10.0.0.1",)).is_empty
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_validate_rejects_bad_ip() -> None:
|
|
160
|
+
with pytest.raises(ValueError, match="invalid --ip"):
|
|
161
|
+
validate_criteria(Criteria(ip=("999.1.1.1",)))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_validate_rejects_bad_power_state() -> None:
|
|
165
|
+
with pytest.raises(ValueError, match="invalid --power-state"):
|
|
166
|
+
validate_criteria(Criteria(power_state=("on",)))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_validate_accepts_cidr_and_exact() -> None:
|
|
170
|
+
validate_criteria(Criteria(ip=("10.20.3.0/24", "10.20.3.41"), power_state=("poweredOn",)))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
# summarize
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_summarize_shape_and_extra_props() -> None:
|
|
179
|
+
props = _props(nics=[{"ipAddress": ["10.20.3.41", "fe80::1"]}])
|
|
180
|
+
props["config.hardware.numCPU"] = 4
|
|
181
|
+
hit = summarize({"type": "VirtualMachine", "value": "vm-1"}, props, ["config.hardware.numCPU"])
|
|
182
|
+
assert hit["name"] == "web-1"
|
|
183
|
+
assert hit["power_state"] == "poweredOn"
|
|
184
|
+
assert "10.20.3.41" in hit["ip_addresses"]
|
|
185
|
+
assert hit["hostname"] == "web-1.corp"
|
|
186
|
+
assert hit["properties"] == {"config.hardware.numCPU": 4}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_summarize_no_extra_props_omits_properties_key() -> None:
|
|
190
|
+
hit = summarize({"type": "VirtualMachine", "value": "vm-1"}, _props(), [])
|
|
191
|
+
assert "properties" not in hit
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------- #
|
|
195
|
+
# retrieval over a mocked PropertyCollector (single round-trip, view destroyed)
|
|
196
|
+
# --------------------------------------------------------------------------- #
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _object_content(moid: str, **props: Any) -> Any:
|
|
200
|
+
return vmodl.query.PropertyCollector.ObjectContent(
|
|
201
|
+
obj=vim.VirtualMachine(moid, None),
|
|
202
|
+
propSet=[vmodl.DynamicProperty(name=k, val=v) for k, v in props.items()],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _RecStub:
|
|
207
|
+
"""Records managed-method invocations so we can assert the view was destroyed."""
|
|
208
|
+
|
|
209
|
+
def __init__(self) -> None:
|
|
210
|
+
self.destroyed = False
|
|
211
|
+
|
|
212
|
+
def InvokeMethod(self, *args: Any, **kwargs: Any) -> None:
|
|
213
|
+
self.destroyed = True # ContainerView.Destroy() is the only call we make
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class _FakeView:
|
|
217
|
+
"""A real ContainerView (so ObjectSpec.obj type-checks) over a recording stub."""
|
|
218
|
+
|
|
219
|
+
def __init__(self) -> None:
|
|
220
|
+
self.stub = _RecStub()
|
|
221
|
+
self.view = vim.view.ContainerView("view-1", self.stub)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def destroyed(self) -> bool:
|
|
225
|
+
return self.stub.destroyed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _FakeViewManager:
|
|
229
|
+
def __init__(self, view: _FakeView) -> None:
|
|
230
|
+
self._view = view
|
|
231
|
+
|
|
232
|
+
def CreateContainerView(self, container: Any, type_: Any, recursive: Any) -> Any:
|
|
233
|
+
return self._view.view
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _FakePC:
|
|
237
|
+
def __init__(self, contents: list[Any]) -> None:
|
|
238
|
+
self.contents = contents
|
|
239
|
+
self.calls = 0
|
|
240
|
+
self.captured: Any = None
|
|
241
|
+
|
|
242
|
+
def RetrieveContents(self, specSet: Any) -> list[Any]:
|
|
243
|
+
self.calls += 1
|
|
244
|
+
self.captured = specSet
|
|
245
|
+
return self.contents
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _fake_si(pc: _FakePC, view: _FakeView) -> Any:
|
|
249
|
+
content = type(
|
|
250
|
+
"C",
|
|
251
|
+
(),
|
|
252
|
+
{
|
|
253
|
+
"propertyCollector": pc,
|
|
254
|
+
"viewManager": _FakeViewManager(view),
|
|
255
|
+
"rootFolder": object(),
|
|
256
|
+
},
|
|
257
|
+
)()
|
|
258
|
+
return type("SI", (), {"content": content, "_stub": object()})()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_find_matches_single_round_trip_and_destroys_view() -> None:
|
|
262
|
+
contents = [
|
|
263
|
+
_object_content("vm-1", **{"name": "web-1", "guest.ipAddress": "10.20.3.41"}),
|
|
264
|
+
_object_content("vm-2", **{"name": "db-1", "guest.ipAddress": "10.20.9.9"}),
|
|
265
|
+
]
|
|
266
|
+
pc, view = _FakePC(contents), _FakeView()
|
|
267
|
+
hits = find_matches(_fake_si(pc, view), Criteria(ip=("10.20.3.41",)), [])
|
|
268
|
+
assert pc.calls == 1 # one RetrieveContents for the whole inventory
|
|
269
|
+
assert view.destroyed # container view torn down in finally
|
|
270
|
+
assert [h["obj"]["value"] for h in hits] == ["vm-1"]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_find_matches_appends_extra_props_to_path_set() -> None:
|
|
274
|
+
pc, view = _FakePC([]), _FakeView()
|
|
275
|
+
find_matches(_fake_si(pc, view), Criteria(name=("x",)), ["config.hardware.numCPU"])
|
|
276
|
+
path_set = pc.captured[0].propSet[0].pathSet
|
|
277
|
+
assert "config.hardware.numCPU" in path_set
|
|
278
|
+
assert "guest.net" in path_set # fixed search paths still present
|
|
279
|
+
assert path_set.count("config.hardware.numCPU") == 1 # deduped
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --------------------------------------------------------------------------- #
|
|
283
|
+
# CLI
|
|
284
|
+
# --------------------------------------------------------------------------- #
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _app() -> typer.Typer:
|
|
288
|
+
app = typer.Typer()
|
|
289
|
+
app.add_typer(inventory_app, name="inventory")
|
|
290
|
+
return app
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_find_cli_by_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
294
|
+
contents = [_object_content("vm-1", **{"name": "web-1", "guest.ipAddress": "10.20.3.41"})]
|
|
295
|
+
pc, view = _FakePC(contents), _FakeView()
|
|
296
|
+
monkeypatch.setattr("vsc.pyvmomi.runner.connect_vmomi", lambda: _fake_si(pc, view))
|
|
297
|
+
result = runner.invoke(_app(), ["inventory", "find", "--ip", "10.20.3.41"])
|
|
298
|
+
assert result.exit_code == 0, result.stdout
|
|
299
|
+
out = json.loads(result.stdout)
|
|
300
|
+
assert out[0]["obj"]["value"] == "vm-1"
|
|
301
|
+
assert out[0]["ip_addresses"] == ["10.20.3.41"]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_find_cli_with_props(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
305
|
+
contents = [
|
|
306
|
+
_object_content(
|
|
307
|
+
"vm-1",
|
|
308
|
+
**{"name": "web-1", "guest.ipAddress": "10.20.3.41", "config.version": "vmx-19"},
|
|
309
|
+
)
|
|
310
|
+
]
|
|
311
|
+
pc, view = _FakePC(contents), _FakeView()
|
|
312
|
+
monkeypatch.setattr("vsc.pyvmomi.runner.connect_vmomi", lambda: _fake_si(pc, view))
|
|
313
|
+
result = runner.invoke(
|
|
314
|
+
_app(), ["inventory", "find", "--name", "web", "--props", "config.version"]
|
|
315
|
+
)
|
|
316
|
+
assert result.exit_code == 0, result.stdout
|
|
317
|
+
out = json.loads(result.stdout)
|
|
318
|
+
assert out[0]["properties"] == {"config.version": "vmx-19"}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_find_cli_no_match_flag_is_usage_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
322
|
+
pc, view = _FakePC([]), _FakeView()
|
|
323
|
+
monkeypatch.setattr("vsc.pyvmomi.runner.connect_vmomi", lambda: _fake_si(pc, view))
|
|
324
|
+
# --props alone does not count as a match flag.
|
|
325
|
+
result = runner.invoke(_app(), ["inventory", "find", "--props", "config.version"])
|
|
326
|
+
assert result.exit_code == 2, result.stdout
|
|
327
|
+
assert pc.calls == 0 # refused before any retrieve
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_find_cli_bad_ip_is_usage_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
331
|
+
pc, view = _FakePC([]), _FakeView()
|
|
332
|
+
monkeypatch.setattr("vsc.pyvmomi.runner.connect_vmomi", lambda: _fake_si(pc, view))
|
|
333
|
+
result = runner.invoke(_app(), ["inventory", "find", "--ip", "999.1.1.1"])
|
|
334
|
+
assert result.exit_code == 2, result.stdout
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_find_cli_no_matches_empty_array(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
338
|
+
pc, view = _FakePC([]), _FakeView()
|
|
339
|
+
monkeypatch.setattr("vsc.pyvmomi.runner.connect_vmomi", lambda: _fake_si(pc, view))
|
|
340
|
+
result = runner.invoke(_app(), ["inventory", "find", "--ip", "10.20.3.41"])
|
|
341
|
+
assert result.exit_code == 0, result.stdout
|
|
342
|
+
assert json.loads(result.stdout) == []
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Find VMs by guest/runtime attribute (IP, name, hostname, MAC, guest OS, power).
|
|
2
|
+
|
|
3
|
+
The REST ``VM.FilterSpec`` exposes none of the guest-network fields, so "which VM
|
|
4
|
+
has ``10.20.3.41``?" is unanswerable there. Those fields only live on the pyVmomi
|
|
5
|
+
``guest.*`` properties, so this is a single ``PropertyCollector`` sweep over every
|
|
6
|
+
VM (one round-trip via a container view) plus a **pure matcher** that decides which
|
|
7
|
+
hits to keep. The matcher takes a plain props dict — no pyVmomi — so it unit-tests
|
|
8
|
+
in isolation; only :func:`find_matches` touches SOAP.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
import ipaddress
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pyVmomi import vim, vmodl
|
|
19
|
+
|
|
20
|
+
from vsc.connect.vmomi import vmomi_jsonable
|
|
21
|
+
|
|
22
|
+
# Fixed property paths the matcher inspects, retrieved for every VM. ``--props``
|
|
23
|
+
# paths are appended to this set (output only) — never a match criterion.
|
|
24
|
+
SEARCH_PATHS: tuple[str, ...] = (
|
|
25
|
+
"name",
|
|
26
|
+
"runtime.powerState",
|
|
27
|
+
"guest.ipAddress",
|
|
28
|
+
"guest.net",
|
|
29
|
+
"guest.hostName",
|
|
30
|
+
"guest.guestFullName",
|
|
31
|
+
)
|
|
32
|
+
POWER_STATES = frozenset({"poweredOn", "poweredOff", "suspended"})
|
|
33
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Criteria:
|
|
38
|
+
"""One value per match flag; repeated flags arrive as multiple entries.
|
|
39
|
+
|
|
40
|
+
Fields **AND** together (every non-empty field must match); values within a
|
|
41
|
+
field **OR** (any one is enough). ``--props``/``-o`` are not represented here —
|
|
42
|
+
they never participate in matching.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
ip: tuple[str, ...] = ()
|
|
46
|
+
name: tuple[str, ...] = ()
|
|
47
|
+
hostname: tuple[str, ...] = ()
|
|
48
|
+
mac: tuple[str, ...] = ()
|
|
49
|
+
guest_os: tuple[str, ...] = ()
|
|
50
|
+
power_state: tuple[str, ...] = ()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_empty(self) -> bool:
|
|
54
|
+
"""True when no match flag was given (we refuse to dump the whole inventory)."""
|
|
55
|
+
return not (
|
|
56
|
+
self.ip or self.name or self.hostname or self.mac or self.guest_os or self.power_state
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_criteria(criteria: Criteria) -> None:
|
|
61
|
+
"""Raise ``ValueError`` for malformed input so it surfaces as a usage error."""
|
|
62
|
+
for value in criteria.ip:
|
|
63
|
+
try:
|
|
64
|
+
ipaddress.ip_network(value, strict=False)
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
raise ValueError(f"invalid --ip {value!r} (expected an address or CIDR)") from exc
|
|
67
|
+
bad = [state for state in criteria.power_state if state not in POWER_STATES]
|
|
68
|
+
if bad:
|
|
69
|
+
allowed = ", ".join(sorted(POWER_STATES))
|
|
70
|
+
raise ValueError(f"invalid --power-state {bad[0]!r} (one of: {allowed})")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _addresses(props: dict[str, Any]) -> list[str]:
|
|
74
|
+
"""All guest IPs: the primary ``guest.ipAddress`` plus every NIC address, deduped."""
|
|
75
|
+
out: list[str] = []
|
|
76
|
+
primary = props.get("guest.ipAddress")
|
|
77
|
+
if isinstance(primary, str):
|
|
78
|
+
out.append(primary)
|
|
79
|
+
for nic in props.get("guest.net") or []:
|
|
80
|
+
for addr in (nic.get("ipAddress") if isinstance(nic, dict) else None) or []:
|
|
81
|
+
if isinstance(addr, str):
|
|
82
|
+
out.append(addr)
|
|
83
|
+
# Preserve order, drop duplicates (primary often repeats a NIC address).
|
|
84
|
+
return list(dict.fromkeys(out))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _macs(props: dict[str, Any]) -> list[str]:
|
|
88
|
+
"""MAC addresses across every NIC in ``guest.net``."""
|
|
89
|
+
return [
|
|
90
|
+
nic["macAddress"]
|
|
91
|
+
for nic in props.get("guest.net") or []
|
|
92
|
+
if isinstance(nic, dict) and isinstance(nic.get("macAddress"), str)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _text_match(value: Any, pattern: str) -> bool:
|
|
97
|
+
"""Case-insensitive substring match, or glob when the pattern has metachars."""
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
return False
|
|
100
|
+
haystack, needle = value.lower(), pattern.lower()
|
|
101
|
+
if _GLOB_CHARS & set(needle):
|
|
102
|
+
return fnmatch.fnmatchcase(haystack, needle)
|
|
103
|
+
return needle in haystack
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ip_match(addresses: list[str], pattern: str) -> bool:
|
|
107
|
+
"""True if any guest address falls within ``pattern`` (exact IP or CIDR)."""
|
|
108
|
+
try:
|
|
109
|
+
network = ipaddress.ip_network(pattern, strict=False)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
for addr in addresses:
|
|
113
|
+
try:
|
|
114
|
+
if ipaddress.ip_address(addr) in network:
|
|
115
|
+
return True
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue # zone-scoped/garbage guest address — skip, don't crash
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _mac_match(props: dict[str, Any], wanted: tuple[str, ...]) -> bool:
|
|
122
|
+
have = {mac.lower() for mac in _macs(props)}
|
|
123
|
+
return any(p.lower() in have for p in wanted)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def matches(props: dict[str, Any], criteria: Criteria) -> bool:
|
|
127
|
+
"""Decide whether one VM's properties satisfy every criterion (AND of ORs).
|
|
128
|
+
|
|
129
|
+
Each field that was given must match at least one of its values; a field left
|
|
130
|
+
unset imposes no constraint. ``all`` over the per-field results yields the AND.
|
|
131
|
+
"""
|
|
132
|
+
return all(
|
|
133
|
+
(
|
|
134
|
+
not criteria.power_state or props.get("runtime.powerState") in criteria.power_state,
|
|
135
|
+
not criteria.name or any(_text_match(props.get("name"), p) for p in criteria.name),
|
|
136
|
+
not criteria.hostname
|
|
137
|
+
or any(_text_match(props.get("guest.hostName"), p) for p in criteria.hostname),
|
|
138
|
+
not criteria.guest_os
|
|
139
|
+
or any(_text_match(props.get("guest.guestFullName"), p) for p in criteria.guest_os),
|
|
140
|
+
not criteria.mac or _mac_match(props, criteria.mac),
|
|
141
|
+
not criteria.ip or any(_ip_match(_addresses(props), p) for p in criteria.ip),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def summarize(obj: Any, props: dict[str, Any], extra_props: list[str]) -> dict[str, Any]:
|
|
147
|
+
"""Shape one matched VM into a directly-useful summary (pipes into ``vm get``)."""
|
|
148
|
+
hit: dict[str, Any] = {
|
|
149
|
+
"obj": obj,
|
|
150
|
+
"name": props.get("name"),
|
|
151
|
+
"power_state": props.get("runtime.powerState"),
|
|
152
|
+
"ip_addresses": _addresses(props),
|
|
153
|
+
"hostname": props.get("guest.hostName"),
|
|
154
|
+
"guest_os": props.get("guest.guestFullName"),
|
|
155
|
+
}
|
|
156
|
+
if extra_props:
|
|
157
|
+
hit["properties"] = {path: props.get(path) for path in extra_props}
|
|
158
|
+
return hit
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _retrieve_all_vms(si: Any, paths: list[str]) -> list[Any]:
|
|
162
|
+
"""One ``RetrieveContents`` over every VM via a container view (destroyed after)."""
|
|
163
|
+
content = si.content
|
|
164
|
+
view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True)
|
|
165
|
+
try:
|
|
166
|
+
pc = vmodl.query.PropertyCollector
|
|
167
|
+
traversal = pc.TraversalSpec(
|
|
168
|
+
name="toVm", type=vim.view.ContainerView, path="view", skip=False
|
|
169
|
+
)
|
|
170
|
+
obj_spec = pc.ObjectSpec(obj=view, skip=True, selectSet=[traversal])
|
|
171
|
+
prop_spec = pc.PropertySpec(type=vim.VirtualMachine, pathSet=paths, all=False)
|
|
172
|
+
filter_spec = pc.FilterSpec(objectSet=[obj_spec], propSet=[prop_spec])
|
|
173
|
+
return content.propertyCollector.RetrieveContents(specSet=[filter_spec]) or []
|
|
174
|
+
finally:
|
|
175
|
+
view.Destroy()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def find_matches(si: Any, criteria: Criteria, extra_props: list[str]) -> list[dict[str, Any]]:
|
|
179
|
+
"""Sweep all VMs once, keep those satisfying ``criteria``, shape each hit."""
|
|
180
|
+
# Fixed search paths first, then any output-only --props (deduped, order kept).
|
|
181
|
+
paths = list(dict.fromkeys([*SEARCH_PATHS, *extra_props]))
|
|
182
|
+
out: list[dict[str, Any]] = []
|
|
183
|
+
for content in _retrieve_all_vms(si, paths):
|
|
184
|
+
props = {dp.name: vmomi_jsonable(dp.val) for dp in (content.propSet or [])}
|
|
185
|
+
if matches(props, criteria):
|
|
186
|
+
out.append(summarize(vmomi_jsonable(content.obj), props, extra_props))
|
|
187
|
+
return out
|
|
@@ -15,6 +15,7 @@ from pyVmomi import vim, vmodl
|
|
|
15
15
|
from vsc.connect.vmomi import vmomi_jsonable
|
|
16
16
|
from vsc.gen.complete import output_format_completer
|
|
17
17
|
from vsc.output.render import OutputFormat
|
|
18
|
+
from vsc.pyvmomi.find import Criteria, find_matches, validate_criteria
|
|
18
19
|
from vsc.pyvmomi.runner import run_read
|
|
19
20
|
|
|
20
21
|
inventory_app = typer.Typer(no_args_is_help=True, help="Property walk (pyVmomi fallback).")
|
|
@@ -89,3 +90,62 @@ def inventory_host(
|
|
|
89
90
|
) -> None:
|
|
90
91
|
"""Retrieve properties of an ESXi host."""
|
|
91
92
|
_run(host, "host", props or _DEFAULT_PROPS["host"], output.value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_FIND_PROPS_HELP = "Extra property to surface per hit (repeatable). Output only — never matches."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@inventory_app.command("find")
|
|
99
|
+
def inventory_find(
|
|
100
|
+
ip: list[str] = typer.Option(
|
|
101
|
+
None, "--ip", help="Guest IP, exact or CIDR (e.g. 10.20.3.41 or 10.20.3.0/24). Repeatable."
|
|
102
|
+
),
|
|
103
|
+
name: list[str] = typer.Option(
|
|
104
|
+
None, "--name", help="VM name, substring or glob (case-insensitive). Repeatable."
|
|
105
|
+
),
|
|
106
|
+
hostname: list[str] = typer.Option(
|
|
107
|
+
None, "--hostname", help="Guest hostname, substring or glob. Repeatable."
|
|
108
|
+
),
|
|
109
|
+
mac: list[str] = typer.Option(
|
|
110
|
+
None, "--mac", help="NIC MAC address, exact (case-insensitive). Repeatable."
|
|
111
|
+
),
|
|
112
|
+
guest_os: list[str] = typer.Option(
|
|
113
|
+
None, "--guest-os", help="Guest OS name, substring or glob. Repeatable."
|
|
114
|
+
),
|
|
115
|
+
power_state: list[str] = typer.Option(
|
|
116
|
+
None, "--power-state", help="poweredOn | poweredOff | suspended. Repeatable."
|
|
117
|
+
),
|
|
118
|
+
props: list[str] = typer.Option(None, "--props", help=_FIND_PROPS_HELP),
|
|
119
|
+
output: OutputFormat = typer.Option(
|
|
120
|
+
OutputFormat.json,
|
|
121
|
+
"--output",
|
|
122
|
+
"-o",
|
|
123
|
+
help="Output format.",
|
|
124
|
+
autocompletion=output_format_completer(),
|
|
125
|
+
),
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Find VMs by guest/runtime attribute, without knowing the moid.
|
|
128
|
+
|
|
129
|
+
Flags AND together; repeat a flag to OR within that field. At least one match
|
|
130
|
+
flag is required (--props/-o alone do not count). Powered-off VMs and those
|
|
131
|
+
without VMware Tools report no guest IP and won't match --ip.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def build(si: Any) -> list[dict[str, Any]]:
|
|
135
|
+
criteria = Criteria(
|
|
136
|
+
ip=tuple(ip or ()),
|
|
137
|
+
name=tuple(name or ()),
|
|
138
|
+
hostname=tuple(hostname or ()),
|
|
139
|
+
mac=tuple(mac or ()),
|
|
140
|
+
guest_os=tuple(guest_os or ()),
|
|
141
|
+
power_state=tuple(power_state or ()),
|
|
142
|
+
)
|
|
143
|
+
if criteria.is_empty:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"give at least one match flag "
|
|
146
|
+
"(--ip/--name/--hostname/--mac/--guest-os/--power-state)"
|
|
147
|
+
)
|
|
148
|
+
validate_criteria(criteria)
|
|
149
|
+
return find_matches(si, criteria, props or [])
|
|
150
|
+
|
|
151
|
+
run_read(output.value, build)
|
|
@@ -103,6 +103,12 @@ vsc --profile prod vsphere events list --vm vm-42 --since 1h
|
|
|
103
103
|
vsc --profile prod vsphere tasks list --max-count 20
|
|
104
104
|
vsc --profile prod vsphere inventory vm vm-42 --props config.hardware
|
|
105
105
|
|
|
106
|
+
# Find a VM by IP/hostname/MAC/guest-OS/power when you don't know its moid.
|
|
107
|
+
# Flags AND together; repeat a flag to OR; --ip takes an exact address or a CIDR.
|
|
108
|
+
vsc --profile prod vsphere inventory find --ip 10.20.3.41 # "which VM has this IP?"
|
|
109
|
+
vsc --profile prod vsphere inventory find --ip 10.20.3.0/24 --power-state poweredOn
|
|
110
|
+
vsc --profile prod vsphere inventory find --name 'web-*' --props config.version
|
|
111
|
+
|
|
106
112
|
# Writes — preview first (dry-run), then --apply
|
|
107
113
|
vsc --profile prod vsphere power stop vm-42 # preview
|
|
108
114
|
vsc --profile prod vsphere power stop vm-42 --apply # execute
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|