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.
Files changed (95) hide show
  1. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/CHANGELOG.md +19 -0
  2. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/PKG-INFO +3 -2
  3. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/README.md +2 -1
  4. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/commands.md +11 -0
  5. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/design.md +6 -2
  6. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/index.md +6 -4
  7. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/profiles.md +4 -0
  8. vcf_super_cli-0.5.0/docs/superpowers/specs/2026-06-08-vcf-super-cli-v0.5-find-vms-design.md +78 -0
  9. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/pyproject.toml +1 -1
  10. vcf_super_cli-0.5.0/tests/test_pyvmomi_find.py +342 -0
  11. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/uv.lock +1 -1
  12. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/_version.py +1 -1
  13. vcf_super_cli-0.5.0/vsc/pyvmomi/find.py +187 -0
  14. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/inventory.py +60 -0
  15. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/skill/assets/SKILL.md +6 -0
  16. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/docs.yml +0 -0
  17. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/e2e.yml +0 -0
  18. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/lint.yml +0 -0
  19. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/release.yml +0 -0
  20. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.github/workflows/test.yml +0 -0
  21. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/.gitignore +0 -0
  22. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/AGENTS.md +0 -0
  23. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/LICENSE +0 -0
  24. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/install.md +0 -0
  25. {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
  26. {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
  27. {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
  28. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/usage.md +0 -0
  29. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/docs/writes.md +0 -0
  30. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/mkdocs.yml +0 -0
  31. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/__init__.py +0 -0
  32. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/README.md +0 -0
  33. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/__init__.py +0 -0
  34. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/conftest.py +0 -0
  35. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/e2e/test_read_smoke.py +0 -0
  36. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_builder.py +0 -0
  37. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_cli.py +0 -0
  38. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_complete.py +0 -0
  39. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_complete_cache.py +0 -0
  40. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_complete_dynamic.py +0 -0
  41. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_config.py +0 -0
  42. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_discover.py +0 -0
  43. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_errors.py +0 -0
  44. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_filter_pagination_cli.py +0 -0
  45. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_filters.py +0 -0
  46. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_paginate.py +0 -0
  47. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_params.py +0 -0
  48. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_preview.py +0 -0
  49. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_events.py +0 -0
  50. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_inventory.py +0 -0
  51. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_perf.py +0 -0
  52. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_pyvmomi_tasks.py +0 -0
  53. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_render.py +0 -0
  54. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_resources.py +0 -0
  55. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_session.py +0 -0
  56. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_skill.py +0 -0
  57. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_version.py +0 -0
  58. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_vmomi.py +0 -0
  59. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/tests/test_write_errors.py +0 -0
  60. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/__init__.py +0 -0
  61. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/__init__.py +0 -0
  62. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/app.py +0 -0
  63. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/profiles.py +0 -0
  64. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/cli/skill.py +0 -0
  65. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/config/__init__.py +0 -0
  66. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/config/schema.py +0 -0
  67. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/config/store.py +0 -0
  68. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/__init__.py +0 -0
  69. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/session.py +0 -0
  70. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/targets.py +0 -0
  71. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/connect/vmomi.py +0 -0
  72. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/__init__.py +0 -0
  73. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/builder.py +0 -0
  74. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/complete.py +0 -0
  75. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/complete_cache.py +0 -0
  76. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/complete_dynamic.py +0 -0
  77. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/discover.py +0 -0
  78. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/filters.py +0 -0
  79. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/model.py +0 -0
  80. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/paginate.py +0 -0
  81. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/params.py +0 -0
  82. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/preview.py +0 -0
  83. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/gen/resources.py +0 -0
  84. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/logging_config.py +0 -0
  85. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/__init__.py +0 -0
  86. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/errors.py +0 -0
  87. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/exit_codes.py +0 -0
  88. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/output/render.py +0 -0
  89. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/__init__.py +0 -0
  90. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/events.py +0 -0
  91. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/perf.py +0 -0
  92. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/runner.py +0 -0
  93. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/pyvmomi/tasks.py +0 -0
  94. {vcf_super_cli-0.4.0 → vcf_super_cli-0.5.0}/vsc/skill/__init__.py +0 -0
  95. {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.4.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 | planned |
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 | planned |
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` (a PropertyCollector property walk). All
72
- are reads no `--apply`.
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** — offline tab-completion (enums, formats, profiles, filter
23
- choices), per-field `--<field>` filter flags, and paging (`--all` /
24
- `--max-items` / `--limit`).
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.4.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) == []
@@ -1326,7 +1326,7 @@ wheels = [
1326
1326
 
1327
1327
  [[package]]
1328
1328
  name = "vcf-super-cli"
1329
- version = "0.4.0"
1329
+ version = "0.5.0"
1330
1330
  source = { editable = "." }
1331
1331
  dependencies = [
1332
1332
  { name = "keyring" },
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.4.0"
7
+ __version__ = "0.5.0"
@@ -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