vcf-super-cli 0.2.0__py3-none-any.whl

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.
vsc/output/errors.py ADDED
@@ -0,0 +1,106 @@
1
+ """Map SDK/transport exceptions to the stable error envelope + exit codes.
2
+
3
+ vAPI errors are both ``Exception`` and ``VapiStruct``; dispatch on the stable
4
+ ``error_type`` string rather than ``isinstance`` chains. Transport-layer failures
5
+ (``requests`` connection/TLS errors) happen before the vAPI layer and are mapped
6
+ separately.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import Any
14
+
15
+ import com.vmware.vapi.std.errors_client as verr
16
+
17
+ from vsc.output.exit_codes import ExitCode
18
+
19
+ # vAPI ``error_type`` -> exit code. Unlisted/None -> ExitCode.ERROR.
20
+ ERROR_TYPE_TO_EXIT: dict[str, ExitCode] = {
21
+ "UNAUTHENTICATED": ExitCode.AUTH,
22
+ "UNAUTHORIZED": ExitCode.AUTH,
23
+ "INVALID_ARGUMENT": ExitCode.USAGE,
24
+ "UNSUPPORTED": ExitCode.USAGE,
25
+ "UNEXPECTED_INPUT": ExitCode.USAGE,
26
+ "OPERATION_NOT_FOUND": ExitCode.USAGE,
27
+ "NOT_FOUND": ExitCode.NOT_FOUND,
28
+ "RESOURCE_INACCESSIBLE": ExitCode.NOT_FOUND,
29
+ "ALREADY_EXISTS": ExitCode.CONFLICT,
30
+ "ALREADY_IN_DESIRED_STATE": ExitCode.CONFLICT,
31
+ "RESOURCE_IN_USE": ExitCode.CONFLICT,
32
+ "FEATURE_IN_USE": ExitCode.CONFLICT,
33
+ "NOT_ALLOWED_IN_CURRENT_STATE": ExitCode.CONFLICT,
34
+ "CONCURRENT_CHANGE": ExitCode.CONFLICT,
35
+ "SERVICE_UNAVAILABLE": ExitCode.UNAVAILABLE,
36
+ "RESOURCE_BUSY": ExitCode.UNAVAILABLE,
37
+ "TIMED_OUT": ExitCode.UNAVAILABLE,
38
+ "UNABLE_TO_ALLOCATE_RESOURCE": ExitCode.UNAVAILABLE,
39
+ "INTERNAL_SERVER_ERROR": ExitCode.ERROR,
40
+ "CANCELED": ExitCode.ERROR,
41
+ }
42
+
43
+
44
+ def _messages(err: Any) -> str:
45
+ msgs = getattr(err, "messages", None) or []
46
+ texts = [getattr(m, "default_message", None) for m in msgs]
47
+ joined = "; ".join(t for t in texts if t)
48
+ return joined or str(err) or err.__class__.__name__
49
+
50
+
51
+ def envelope_for_vapi(err: Any) -> tuple[dict[str, Any], ExitCode]:
52
+ """Build the error envelope + exit code for a vAPI error."""
53
+ error_type = getattr(err, "error_type", None)
54
+ code = ERROR_TYPE_TO_EXIT.get(error_type or "", ExitCode.ERROR)
55
+ detail: Any
56
+ try:
57
+ detail = err.to_dict()
58
+ except Exception:
59
+ detail = None
60
+ env = {
61
+ "error": {
62
+ "code": int(code),
63
+ "kind": error_type or err.__class__.__name__,
64
+ "message": _messages(err),
65
+ "details": detail,
66
+ }
67
+ }
68
+ return env, code
69
+
70
+
71
+ def envelope_for_transport(err: Exception) -> tuple[dict[str, Any], ExitCode]:
72
+ """Build the error envelope + exit code for a transport-layer error.
73
+
74
+ TLS trust failures are a *connection* problem (failure to negotiate), not an
75
+ authentication failure — they get :class:`ExitCode.CONNECTION` plus a hint
76
+ pointing at the ``VSC_<BACKEND>_INSECURE`` escape hatch.
77
+ """
78
+ name = err.__class__.__name__
79
+ message = str(err) or name
80
+ if "SSL" in name:
81
+ message = (
82
+ f"TLS verification failed: {message} "
83
+ "(for a self-signed/lab cert, set VSC_<BACKEND>_INSECURE=1)"
84
+ )
85
+ env = {
86
+ "error": {
87
+ "code": int(ExitCode.CONNECTION),
88
+ "kind": name,
89
+ "message": message,
90
+ "details": None,
91
+ }
92
+ }
93
+ return env, ExitCode.CONNECTION
94
+
95
+
96
+ def render_error(env: dict[str, Any], fmt: str) -> None:
97
+ """Print the error envelope to stderr (JSON always; ``message`` for tables)."""
98
+ if fmt == "table":
99
+ print(env["error"]["message"], file=sys.stderr)
100
+ else:
101
+ print(json.dumps(env, default=str), file=sys.stderr)
102
+
103
+
104
+ def is_vapi_error(err: BaseException) -> bool:
105
+ """True if ``err`` is a vAPI ``Error`` (has the stable ``error_type``)."""
106
+ return isinstance(err, verr.Error)
@@ -0,0 +1,40 @@
1
+ """Documented, stable process exit codes.
2
+
3
+ These are part of the CLI's public contract for scripts and agents. Values are
4
+ frozen; add new codes rather than renumbering existing ones.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import IntEnum
10
+
11
+
12
+ class ExitCode(IntEnum):
13
+ """Stable exit codes returned by ``vsc``."""
14
+
15
+ OK = 0
16
+ """Success."""
17
+
18
+ ERROR = 1
19
+ """Generic/unexpected failure."""
20
+
21
+ USAGE = 2
22
+ """Invalid arguments or usage (Typer/Click default)."""
23
+
24
+ AUTH = 3
25
+ """Authentication or authorization failure."""
26
+
27
+ NOT_FOUND = 4
28
+ """A requested resource does not exist."""
29
+
30
+ CONNECTION = 5
31
+ """Could not reach or negotiate with the target (vCenter/NSX)."""
32
+
33
+ CONFIG = 6
34
+ """Missing or invalid configuration/profile."""
35
+
36
+ CONFLICT = 7
37
+ """Resource conflict (already exists, in use, wrong state, concurrent change)."""
38
+
39
+ UNAVAILABLE = 8
40
+ """Target temporarily unavailable, busy, or the request timed out."""
vsc/output/render.py ADDED
@@ -0,0 +1,134 @@
1
+ """Render SDK results to JSON (default) or a Rich table.
2
+
3
+ vAPI results are ``VapiStruct`` instances (or lists/maps of them). ``to_dict()``
4
+ parses floats as ``Decimal``, so JSON serialization uses ``default=str``. Dict
5
+ keys are wire/canonical names (e.g. ``memory_size_MiB``), not the snake_case
6
+ constructor kwargs — we surface them as-is.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import enum
12
+ import json
13
+ from typing import Any
14
+
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+ from vmware.vapi.bindings.struct import VapiStruct
18
+
19
+
20
+ class OutputFormat(str, enum.Enum):
21
+ """Supported ``--output`` formats."""
22
+
23
+ json = "json"
24
+ table = "table"
25
+
26
+
27
+ def jsonable(value: Any) -> Any:
28
+ """Convert a vAPI result into a JSON-serializable structure."""
29
+ if isinstance(value, VapiStruct):
30
+ return {k: jsonable(v) for k, v in value.to_dict().items()}
31
+ if isinstance(value, enum.Enum):
32
+ return value.value
33
+ if isinstance(value, (list, tuple, set, frozenset)):
34
+ return [jsonable(v) for v in value]
35
+ if isinstance(value, dict):
36
+ return {str(k): jsonable(v) for k, v in value.items()}
37
+ return value
38
+
39
+
40
+ def to_json(value: Any) -> str:
41
+ """Serialize a result to indented JSON text."""
42
+ return json.dumps(jsonable(value), indent=2, default=str, sort_keys=False)
43
+
44
+
45
+ def _rows(data: Any) -> list[dict[str, Any]]:
46
+ if isinstance(data, dict):
47
+ return [data]
48
+ if isinstance(data, list):
49
+ return [r for r in data if isinstance(r, dict)]
50
+ return []
51
+
52
+
53
+ def to_table(value: Any, console: Console) -> bool:
54
+ """Render ``value`` as a Rich table. Returns ``False`` if it isn't tabular."""
55
+ data = jsonable(value)
56
+ rows = _rows(data)
57
+ if not rows:
58
+ return False
59
+ columns: list[str] = []
60
+ for row in rows:
61
+ for key in row:
62
+ if key not in columns:
63
+ columns.append(key)
64
+ table = Table(show_header=True, header_style="bold")
65
+ for col in columns:
66
+ table.add_column(col)
67
+ for row in rows:
68
+ table.add_row(*[_cell(row.get(col)) for col in columns])
69
+ console.print(table)
70
+ return True
71
+
72
+
73
+ def _cell(value: Any) -> str:
74
+ if value is None:
75
+ return ""
76
+ if isinstance(value, (dict, list)):
77
+ return json.dumps(value, default=str)
78
+ return str(value)
79
+
80
+
81
+ def emit(value: Any, fmt: str | OutputFormat = "json", *, console: Console | None = None) -> None:
82
+ """Print a result in the requested format (``json`` or ``table``)."""
83
+ fmt = fmt.value if isinstance(fmt, OutputFormat) else fmt
84
+ if fmt == "table":
85
+ console = console or Console()
86
+ if to_table(value, console):
87
+ return
88
+ # Fall back to JSON for non-tabular payloads (e.g. a single scalar).
89
+ print(to_json(value))
90
+
91
+
92
+ _APPLY_HINT = "re-run with --apply to execute"
93
+
94
+
95
+ def write_envelope(plan: dict[str, Any], *, applied: bool, result: Any = None) -> dict[str, Any]:
96
+ """Build the stable write envelope shared by dry-run and apply.
97
+
98
+ Dry-run (``applied=False``) carries the plan and an apply hint and never a
99
+ result; apply (``applied=True``) carries the plan and the SDK response.
100
+ """
101
+ env: dict[str, Any] = {"applied": applied, "request": plan}
102
+ if applied:
103
+ env["result"] = jsonable(result)
104
+ else:
105
+ env["apply_hint"] = _APPLY_HINT
106
+ return env
107
+
108
+
109
+ def emit_request(
110
+ plan: dict[str, Any],
111
+ *,
112
+ applied: bool,
113
+ result: Any = None,
114
+ fmt: str | OutputFormat = "json",
115
+ console: Console | None = None,
116
+ ) -> None:
117
+ """Print the write envelope (dry-run preview or applied result)."""
118
+ fmt = fmt.value if isinstance(fmt, OutputFormat) else fmt
119
+ env = write_envelope(plan, applied=applied, result=result)
120
+ if fmt == "table":
121
+ console = console or Console()
122
+ status = "APPLIED" if applied else "DRY-RUN"
123
+ console.print(f"[bold]{status}[/bold] {plan['method']} {plan['url']}")
124
+ if not applied:
125
+ console.print(f"({_APPLY_HINT})")
126
+ return
127
+ # Applied: show the result as a table, or a clean scalar/empty line — never
128
+ # fall through to dumping the whole JSON envelope in table mode.
129
+ if result is None:
130
+ console.print("(no body)")
131
+ elif not to_table(result, console):
132
+ console.print(to_json(result))
133
+ return
134
+ print(to_json(env))
vsc/skill/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The bundled agent Skill and its export command."""
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: vcf-super-cli
3
+ description: Use when querying or changing VMware Cloud Foundation 9 (vSphere/vCenter and NSX Policy) from the command line via `vsc`. The command tree is generated from the vcf-sdk. Reads return JSON; writes are dry-run by default and require `--apply`.
4
+ ---
5
+
6
+ # vcf-super-cli (`vsc`)
7
+
8
+ `vsc` is a CLI for VMware Cloud Foundation 9. Its command tree is generated from
9
+ the `vcf-sdk` vAPI bindings and split into two product groups:
10
+
11
+ - `vsc vsphere …` — vCenter (vm, host, cluster, datacenter, datastore, folder, network,
12
+ resource-pool, and the VM power/hardware leaves: power, cpu, memory, disk, ethernet —
13
+ each takes the VM id as an argument, e.g. `vsc vsphere power stop <vm>`)
14
+ - `vsc nsx …` — NSX Policy (segments, tier0s, tier1s, services, groups, security-policies,
15
+ gateway-policies, ip-pools, dhcp-server-configs, dhcp-relay-configs, locale-services)
16
+
17
+ Discover the live surface with `vsc --help`, `vsc vsphere --help`, `vsc nsx --help`,
18
+ and `vsc vsphere vm --help`. Leaves expose `list`/`get <id>` reads and — where the SDK
19
+ provides them — write verbs (`create`, `delete`, `set`, `patch`, power actions, …).
20
+
21
+ ## Writes are dry-run by default
22
+
23
+ **Every write command previews and changes nothing unless you pass `--apply`.** This is
24
+ the core safety contract — a write without `--apply` never opens a connection.
25
+
26
+ ```sh
27
+ vsc --profile prod vsphere vm delete vm-42 # DRY-RUN: prints the plan, changes nothing
28
+ vsc --profile prod vsphere vm delete vm-42 --apply # executes the delete
29
+ ```
30
+
31
+ Dry-run emits the resolved request so you can see exactly what `--apply` would send:
32
+
33
+ ```json
34
+ { "applied": false,
35
+ "request": { "method": "DELETE", "url": "/vcenter/vm/vm-42",
36
+ "path_params": {"vm": "vm-42"}, "query": {}, "body": null,
37
+ "backend": "vsphere", "service": "vm", "operation": "delete" },
38
+ "apply_hint": "re-run with --apply to execute" }
39
+ ```
40
+
41
+ With `--apply`, the same envelope carries `"applied": true` and a `"result"` field
42
+ (the SDK response) instead of `apply_hint`. Branch on `applied`.
43
+
44
+ Bodies/specs are passed as JSON to the relevant option and built into the SDK struct:
45
+
46
+ ```sh
47
+ vsc --profile prod nsx segments set web --segment '{"display_name":"web"}' # dry-run
48
+ vsc --profile prod nsx segments set web --segment '{"display_name":"web"}' --apply # executes
49
+ ```
50
+
51
+ Write failures use the same error envelope + exit codes — notably `7` CONFLICT
52
+ (already-exists / in-use / wrong-state / concurrent-change) and `8` UNAVAILABLE
53
+ (busy / timed-out / temporarily unavailable).
54
+
55
+ ## Conventions for agents
56
+
57
+ - **Output is JSON by default** — parse `stdout`. Use `--output table` / `-o table`
58
+ only for human display.
59
+ - **Errors** go to `stderr` as `{ "error": { "code", "kind", "message", "details" } }`.
60
+ `stdout` is data only; logs/diagnostics go to `stderr`.
61
+ - **Exit codes** are stable — branch on these, not on message text:
62
+ `0` ok · `1` generic · `2` usage · `3` auth · `4` not-found · `5` connection ·
63
+ `6` config · `7` conflict · `8` unavailable.
64
+
65
+ ## Targeting an environment
66
+
67
+ Configure once, then select per command with `--profile/-p`:
68
+
69
+ ```sh
70
+ vsc profiles add prod --vsphere-server vc.example --vsphere-username administrator@vsphere.local
71
+ vsc profiles set-password prod vsphere # prompts; stored in the OS keyring
72
+ vsc --profile prod vsphere vm list
73
+ ```
74
+
75
+ Environment variables override the profile (useful in CI):
76
+ `VSC_VSPHERE_SERVER`, `VSC_VSPHERE_USERNAME`, `VSC_VSPHERE_PASSWORD`,
77
+ `VSC_VSPHERE_INSECURE`, and the `VSC_NSX_*` equivalents; `VSC_PROFILE` selects a profile.
78
+
79
+ ## Examples
80
+
81
+ ```sh
82
+ # Reads
83
+ vsc --profile prod vsphere vm list
84
+ vsc --profile prod vsphere vm get vm-42
85
+ vsc --profile prod vsphere host list -o table
86
+ vsc --profile prod nsx segments list
87
+ vsc --profile prod nsx tier1s get <tier1-id>
88
+
89
+ # Writes — preview first (dry-run), then --apply
90
+ vsc --profile prod vsphere power stop vm-42 # preview
91
+ vsc --profile prod vsphere power stop vm-42 --apply # execute
92
+ vsc --profile prod nsx tier1s set t1-web --tier1 '{"display_name":"web"}' --apply
93
+ ```
94
+
95
+ > This Skill ships with the package. Refresh an exported copy with
96
+ > `vsc skill export <dir> --apply`.
vsc/skill/export.py ADDED
@@ -0,0 +1,26 @@
1
+ """Read the bundled agent Skill and export it to a directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ from pathlib import Path
7
+
8
+ _SKILL_NAME = "vcf-super-cli"
9
+
10
+
11
+ def skill_text() -> str:
12
+ """Return the bundled SKILL.md content."""
13
+ return (files("vsc.skill") / "assets" / "SKILL.md").read_text(encoding="utf-8")
14
+
15
+
16
+ def export_path(dest_dir: Path) -> Path:
17
+ """The file that :func:`export_skill` would write under ``dest_dir``."""
18
+ return dest_dir / _SKILL_NAME / "SKILL.md"
19
+
20
+
21
+ def export_skill(dest_dir: Path) -> Path:
22
+ """Write the bundled SKILL.md to ``dest_dir/vcf-super-cli/SKILL.md``."""
23
+ target = export_path(dest_dir)
24
+ target.parent.mkdir(parents=True, exist_ok=True)
25
+ target.write_text(skill_text(), encoding="utf-8")
26
+ return target