hcs-cli 0.1.318__py3-none-any.whl → 0.1.320__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.
- hcs_cli/__init__.py +2 -2
- hcs_cli/cmds/advisor/html_utils.py +30 -26
- hcs_cli/cmds/advisor/recommendation_engine.py +7 -10
- hcs_cli/cmds/daas/tenant/plan.py +1 -1
- hcs_cli/cmds/debug/start.py +0 -1
- hcs_cli/cmds/dev/fs/clear.py +8 -0
- hcs_cli/cmds/dev/fs/helper/credential_helper.py +2 -0
- hcs_cli/cmds/dev/fs/helper/k8s_util.py +0 -1
- hcs_cli/cmds/dev/fs/init.py +38 -5
- hcs_cli/cmds/dev/fs/profiler.py +0 -1
- hcs_cli/cmds/dev/fs/provided_files/akka.plan.yml +94 -250
- hcs_cli/cmds/dev/fs/provided_files/azsim.plan.yml +27 -34
- hcs_cli/cmds/dev/fs/provided_files/azure.plan.yml +294 -322
- hcs_cli/cmds/dev/fs/provided_files/mqtt-secret.yaml +188 -93
- hcs_cli/cmds/dev/fs/provided_files/mqtt-server-external.yaml +4 -5
- hcs_cli/cmds/dev/fs/provided_files/patch-mqtt-hostname.yml +3 -3
- hcs_cli/cmds/dev/fs/provided_files/patch-vernemq-ssl-depth.json +1 -1
- hcs_cli/cmds/dev/fs/tailor.py +7 -12
- hcs_cli/cmds/dev/mqtt.py +1 -2
- hcs_cli/cmds/dev/util/mqtt_helper.py +0 -1
- hcs_cli/cmds/hoc/search.py +39 -9
- hcs_cli/cmds/hoc/stats.py +46 -0
- hcs_cli/cmds/hst/clean.py +2 -1
- hcs_cli/cmds/inventory/assign.py +1 -3
- hcs_cli/cmds/inventory/deassign.py +1 -1
- hcs_cli/cmds/inventory/delete.py +48 -0
- hcs_cli/cmds/lcm/provider/create.py +11 -2
- hcs_cli/cmds/lcm/template/expand.py +46 -0
- hcs_cli/cmds/lcm/vm/delete.py +3 -2
- hcs_cli/cmds/scm/plan.py +131 -3
- hcs_cli/cmds/task.py +2 -4
- hcs_cli/cmds/template/expand.py +64 -19
- hcs_cli/cmds/template/list_usage.py +2 -2
- hcs_cli/cmds/template/update.py +2 -2
- hcs_cli/cmds/template/usage.py +20 -7
- hcs_cli/cmds/vm/delete.py +3 -2
- hcs_cli/cmds/vm/list.py +51 -40
- hcs_cli/cmds/vmm/rootca_migrate.py +1 -1
- hcs_cli/config/hcs-deployments.yaml +52 -52
- hcs_cli/main.py +0 -2
- hcs_cli/payload/akka.blueprint.yml +95 -243
- hcs_cli/payload/app/manual.json +19 -19
- hcs_cli/payload/edge/akka.json +6 -6
- hcs_cli/payload/edge/vsphere.json +6 -6
- hcs_cli/payload/hoc/lcm-capcalc.json.template +43 -0
- hcs_cli/payload/hoc/no-spare.json.template +1 -1
- hcs_cli/payload/inventory/assign.json +14 -16
- hcs_cli/payload/inventory/deassign.json +11 -11
- hcs_cli/payload/lcm/akka.json +31 -33
- hcs_cli/payload/lcm/azure-dummy.json +64 -66
- hcs_cli/payload/lcm/azure-real.json +13 -11
- hcs_cli/payload/lcm/edge-proxy.json +34 -36
- hcs_cli/payload/lcm/zero-dedicated.json +34 -36
- hcs_cli/payload/lcm/zero-delay-1m-per-vm.json +53 -69
- hcs_cli/payload/lcm/zero-fail-delete-template.json +43 -0
- hcs_cli/payload/lcm/zero-fail-destroy-onthread.json +38 -40
- hcs_cli/payload/lcm/zero-fail-destroy.json +38 -40
- hcs_cli/payload/lcm/zero-fail-prepare-onthread.json +38 -40
- hcs_cli/payload/lcm/zero-fail-prepare.json +38 -40
- hcs_cli/payload/lcm/zero-fail-vm-onthread.json +58 -74
- hcs_cli/payload/lcm/zero-fail-vm.json +58 -74
- hcs_cli/payload/lcm/zero-floating.json +34 -36
- hcs_cli/payload/lcm/zero-manual.json +33 -35
- hcs_cli/payload/lcm/zero-multisession.json +34 -36
- hcs_cli/payload/lcm/zero-nanw.json +31 -33
- hcs_cli/payload/lcm/zero-new-5k-delay.json +69 -78
- hcs_cli/payload/lcm/zero-new-5k.json +36 -38
- hcs_cli/payload/lcm/zero-new-snapshot.json +37 -39
- hcs_cli/payload/lcm/zero-new.json +37 -39
- hcs_cli/payload/lcm/zero-reuse-vm-id.json +33 -35
- hcs_cli/payload/lcm/zero-with-max-id-offset.json +32 -34
- hcs_cli/payload/lcm/zero.json +59 -73
- hcs_cli/payload/provider/ad-stes-vsphere.json +26 -26
- hcs_cli/payload/provider/akka.json +12 -12
- hcs_cli/payload/provider/azure.json +14 -14
- hcs_cli/payload/provider/edgeproxy.json +12 -12
- hcs_cli/payload/provider/vsphere.json +14 -14
- hcs_cli/payload/scm/starter.json +22 -23
- hcs_cli/payload/synt/core/p01-dummy-success.json +11 -15
- hcs_cli/payload/synt/core/p02-dummy-fail.json +12 -15
- hcs_cli/payload/synt/core/p03-dummy-exception.json +12 -15
- hcs_cli/payload/synt/core/p04-dummy-success-repeat.json +12 -15
- hcs_cli/payload/synt/core/p05-dummy-fail-repeat.json +13 -16
- hcs_cli/payload/synt/core/p06-dummy-exception-repeat.json +13 -16
- hcs_cli/payload/synt/core/p07-dummy-delay.json +12 -15
- hcs_cli/payload/synt/core/p08-dummy-property.json +12 -15
- hcs_cli/payload/synt/ext/p20-connect-success.json +12 -15
- hcs_cli/payload/synt/ext/p21-connect-fail.json +12 -15
- hcs_cli/payload/synt/ext/p30-ssl-success.json +12 -15
- hcs_cli/payload/synt/ext/p31-ssl-fail.json +13 -16
- hcs_cli/payload/synt/ext/p40-http-success.json +12 -15
- hcs_cli/payload/synt/ext/p41-http-fail.json +12 -15
- hcs_cli/payload/synt/ext/p42-http-status-code.json +14 -20
- hcs_cli/payload/synt/ext1/p10-ping-success.json +13 -16
- hcs_cli/payload/synt/ext1/p11-ping-fail.json +12 -15
- hcs_cli/payload/synt/ext1/p12-ping-success-repeat.json +14 -17
- hcs_cli/provider/hcs/cert.py +0 -1
- hcs_cli/provider/hcs/edge.py +1 -1
- hcs_cli/provider/hcs/uag.py +1 -1
- hcs_cli/service/admin/template.py +10 -1
- hcs_cli/service/hoc/diagnostic.py +11 -3
- hcs_cli/service/inventory/__init__.py +15 -2
- hcs_cli/service/inventory/vm.py +12 -0
- hcs_cli/service/lcm/template.py +9 -6
- hcs_cli/service/lcm/vm.py +0 -1
- hcs_cli/service/task.py +0 -1
- hcs_cli/service/template.py +1 -1
- hcs_cli/support/debug_util.py +0 -1
- hcs_cli/support/plan_util.py +0 -1
- hcs_cli/support/predefined_payload.py +4 -1
- hcs_cli/support/template_util.py +0 -1
- hcs_cli/support/test_utils.py +2 -2
- hcs_cli/support/test_utils2.py +536 -0
- hcs_cli/support/vm_table.py +2 -2
- {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.320.dist-info}/METADATA +24 -17
- {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.320.dist-info}/RECORD +118 -113
- hcs_cli/payload/lcm/azure-dummy-nt.json +0 -69
- {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.320.dist-info}/WHEEL +0 -0
- {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.320.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2023-2023 VMware Inc.
|
|
3
|
+
SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
21
|
+
from unittest.mock import MagicMock, patch
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HttpMockRequest:
|
|
27
|
+
"""Represents an expected HTTP request with optional validation"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
method: str,
|
|
32
|
+
url: str,
|
|
33
|
+
expected_request_body: Optional[Any] = None,
|
|
34
|
+
expected_request_headers: Optional[Dict[str, str]] = None,
|
|
35
|
+
):
|
|
36
|
+
self.method = method
|
|
37
|
+
self.url_pattern = self._compile_pattern(url)
|
|
38
|
+
self.expected_request_body = expected_request_body
|
|
39
|
+
self.expected_request_headers = expected_request_headers
|
|
40
|
+
|
|
41
|
+
def _compile_pattern(self, url: str):
|
|
42
|
+
"""Convert URL pattern to regex (supports {param} placeholders)"""
|
|
43
|
+
if "{" not in url:
|
|
44
|
+
return re.compile(f"^{re.escape(url)}$")
|
|
45
|
+
|
|
46
|
+
regex = url
|
|
47
|
+
for param in re.findall(r"\{(\w+)\}", url):
|
|
48
|
+
regex = regex.replace(f"{{{param}}}", f"(?P<{param}>[^/?]+)")
|
|
49
|
+
return re.compile(f"^{regex}$")
|
|
50
|
+
|
|
51
|
+
def matches(self, method: str, url: str) -> bool:
|
|
52
|
+
"""Check if this request matches the given method and URL"""
|
|
53
|
+
return method == self.method and self.url_pattern.match(url) is not None
|
|
54
|
+
|
|
55
|
+
def extract_params(self, url: str) -> Dict[str, str]:
|
|
56
|
+
"""Extract path parameters from URL"""
|
|
57
|
+
match = self.url_pattern.match(url)
|
|
58
|
+
if match:
|
|
59
|
+
return match.groupdict()
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HttpMockResponse:
|
|
64
|
+
"""Represents a mocked HTTP response"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
status_code: int = 200,
|
|
69
|
+
body: Optional[Any] = None,
|
|
70
|
+
headers: Optional[Dict[str, str]] = None,
|
|
71
|
+
):
|
|
72
|
+
self.status_code = status_code
|
|
73
|
+
self.body = body if body is not None else {}
|
|
74
|
+
self.headers = headers or {"Content-Type": "application/json"}
|
|
75
|
+
|
|
76
|
+
def to_httpx_response(self, request_obj) -> httpx.Response:
|
|
77
|
+
"""Convert to httpx.Response object"""
|
|
78
|
+
if isinstance(self.body, (dict, list)):
|
|
79
|
+
content = json.dumps(self.body).encode("utf-8")
|
|
80
|
+
elif isinstance(self.body, str):
|
|
81
|
+
content = self.body.encode("utf-8")
|
|
82
|
+
else:
|
|
83
|
+
content = str(self.body).encode("utf-8")
|
|
84
|
+
|
|
85
|
+
response = httpx.Response(
|
|
86
|
+
status_code=self.status_code,
|
|
87
|
+
content=content,
|
|
88
|
+
headers=self.headers,
|
|
89
|
+
request=request_obj,
|
|
90
|
+
)
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HttpMockConfig:
|
|
95
|
+
"""Configuration for a single HTTP mock mapping: request -> response"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
request: HttpMockRequest,
|
|
100
|
+
response: HttpMockResponse,
|
|
101
|
+
):
|
|
102
|
+
self.request = request
|
|
103
|
+
self.response = response
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class HttpMockRegistry:
|
|
107
|
+
"""Registry of configured HTTP mocks"""
|
|
108
|
+
|
|
109
|
+
def __init__(self):
|
|
110
|
+
self.configs: List[HttpMockConfig] = []
|
|
111
|
+
self.request_history: List[Tuple[str, str, Any]] = []
|
|
112
|
+
|
|
113
|
+
def register(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
url: str,
|
|
117
|
+
response_status_code: int = 200,
|
|
118
|
+
response_body: Optional[Any] = None,
|
|
119
|
+
response_headers: Optional[Dict[str, str]] = None,
|
|
120
|
+
expected_request_body: Optional[Any] = None,
|
|
121
|
+
expected_request_headers: Optional[Dict[str, str]] = None,
|
|
122
|
+
) -> "HttpMockRegistry":
|
|
123
|
+
"""Register a mock HTTP request/response pair"""
|
|
124
|
+
request = HttpMockRequest(
|
|
125
|
+
method=method,
|
|
126
|
+
url=url,
|
|
127
|
+
expected_request_body=expected_request_body,
|
|
128
|
+
expected_request_headers=expected_request_headers,
|
|
129
|
+
)
|
|
130
|
+
response = HttpMockResponse(
|
|
131
|
+
status_code=response_status_code,
|
|
132
|
+
body=response_body,
|
|
133
|
+
headers=response_headers,
|
|
134
|
+
)
|
|
135
|
+
config = HttpMockConfig(request=request, response=response)
|
|
136
|
+
self.configs.append(config)
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def find_response(self, method: str, url: str) -> Optional[HttpMockResponse]:
|
|
140
|
+
"""Find a registered response for the given request"""
|
|
141
|
+
for config in self.configs:
|
|
142
|
+
if config.request.matches(method, url):
|
|
143
|
+
self.request_history.append((method, url, None))
|
|
144
|
+
return config.response
|
|
145
|
+
|
|
146
|
+
# Request not found in registry
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"No mock registered for {method} {url}\n"
|
|
149
|
+
f"Registered: {[(c.request.method, c.request.url_pattern.pattern) for c in self.configs]}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def reset(self):
|
|
153
|
+
"""Clear all configs and history"""
|
|
154
|
+
self.configs.clear()
|
|
155
|
+
self.request_history.clear()
|
|
156
|
+
|
|
157
|
+
def get_request_history(self) -> List[Tuple[str, str]]:
|
|
158
|
+
"""Get list of all requests made during mock session"""
|
|
159
|
+
return [(method, url) for method, url, _ in self.request_history]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class HttpMockContextManager:
|
|
163
|
+
"""Context manager for mock HTTP testing with auto-cleanup"""
|
|
164
|
+
|
|
165
|
+
def __init__(self):
|
|
166
|
+
self.registry = HttpMockRegistry()
|
|
167
|
+
self._patchers = []
|
|
168
|
+
# Capture the real httpx.Client class before any patching
|
|
169
|
+
self._real_httpx_client = httpx.Client
|
|
170
|
+
|
|
171
|
+
def register(
|
|
172
|
+
self,
|
|
173
|
+
method: str,
|
|
174
|
+
url: str,
|
|
175
|
+
response_status_code: int = 200,
|
|
176
|
+
response_body: Optional[Any] = None,
|
|
177
|
+
response_headers: Optional[Dict[str, str]] = None,
|
|
178
|
+
expected_request_body: Optional[Any] = None,
|
|
179
|
+
expected_request_headers: Optional[Dict[str, str]] = None,
|
|
180
|
+
) -> "HttpMockContextManager":
|
|
181
|
+
"""Register a mock response"""
|
|
182
|
+
self.registry.register(
|
|
183
|
+
method=method,
|
|
184
|
+
url=url,
|
|
185
|
+
response_status_code=response_status_code,
|
|
186
|
+
response_body=response_body,
|
|
187
|
+
response_headers=response_headers,
|
|
188
|
+
expected_request_body=expected_request_body,
|
|
189
|
+
expected_request_headers=expected_request_headers,
|
|
190
|
+
)
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def _create_mock_client(self):
|
|
194
|
+
"""Create a mock httpx.Client that uses the registry"""
|
|
195
|
+
mock_client = MagicMock(spec=self._real_httpx_client)
|
|
196
|
+
|
|
197
|
+
def mock_get(url, **kwargs):
|
|
198
|
+
# Handle base_url + relative URL
|
|
199
|
+
full_url = str(url)
|
|
200
|
+
if not full_url.startswith("http"):
|
|
201
|
+
full_url = "https://api.example.com" + full_url
|
|
202
|
+
|
|
203
|
+
response = self.registry.find_response("GET", full_url)
|
|
204
|
+
request_obj = httpx.Request("GET", full_url)
|
|
205
|
+
httpx_response = response.to_httpx_response(request_obj)
|
|
206
|
+
|
|
207
|
+
# Ensure response has read method for compatibility
|
|
208
|
+
httpx_response.read = lambda: None
|
|
209
|
+
return httpx_response
|
|
210
|
+
|
|
211
|
+
def mock_post(url, **kwargs):
|
|
212
|
+
full_url = str(url)
|
|
213
|
+
if not full_url.startswith("http"):
|
|
214
|
+
full_url = "https://api.example.com" + full_url
|
|
215
|
+
|
|
216
|
+
response = self.registry.find_response("POST", full_url)
|
|
217
|
+
request_obj = httpx.Request("POST", full_url, json=kwargs.get("json"))
|
|
218
|
+
httpx_response = response.to_httpx_response(request_obj)
|
|
219
|
+
httpx_response.read = lambda: None
|
|
220
|
+
return httpx_response
|
|
221
|
+
|
|
222
|
+
def mock_put(url, **kwargs):
|
|
223
|
+
full_url = str(url)
|
|
224
|
+
if not full_url.startswith("http"):
|
|
225
|
+
full_url = "https://api.example.com" + full_url
|
|
226
|
+
|
|
227
|
+
response = self.registry.find_response("PUT", full_url)
|
|
228
|
+
request_obj = httpx.Request("PUT", full_url, json=kwargs.get("json"))
|
|
229
|
+
httpx_response = response.to_httpx_response(request_obj)
|
|
230
|
+
httpx_response.read = lambda: None
|
|
231
|
+
return httpx_response
|
|
232
|
+
|
|
233
|
+
def mock_patch(url, **kwargs):
|
|
234
|
+
full_url = str(url)
|
|
235
|
+
if not full_url.startswith("http"):
|
|
236
|
+
full_url = "https://api.example.com" + full_url
|
|
237
|
+
|
|
238
|
+
response = self.registry.find_response("PATCH", full_url)
|
|
239
|
+
request_obj = httpx.Request("PATCH", full_url, json=kwargs.get("json"))
|
|
240
|
+
httpx_response = response.to_httpx_response(request_obj)
|
|
241
|
+
httpx_response.read = lambda: None
|
|
242
|
+
return httpx_response
|
|
243
|
+
|
|
244
|
+
def mock_delete(url, **kwargs):
|
|
245
|
+
full_url = str(url)
|
|
246
|
+
if not full_url.startswith("http"):
|
|
247
|
+
full_url = "https://api.example.com" + full_url
|
|
248
|
+
|
|
249
|
+
response = self.registry.find_response("DELETE", full_url)
|
|
250
|
+
request_obj = httpx.Request("DELETE", full_url)
|
|
251
|
+
httpx_response = response.to_httpx_response(request_obj)
|
|
252
|
+
httpx_response.read = lambda: None
|
|
253
|
+
return httpx_response
|
|
254
|
+
|
|
255
|
+
# Setup mock client attributes and methods
|
|
256
|
+
mock_client.get.side_effect = mock_get
|
|
257
|
+
mock_client.post.side_effect = mock_post
|
|
258
|
+
mock_client.put.side_effect = mock_put
|
|
259
|
+
mock_client.patch.side_effect = mock_patch
|
|
260
|
+
mock_client.delete.side_effect = mock_delete
|
|
261
|
+
mock_client.base_url = "https://api.example.com"
|
|
262
|
+
mock_client.timeout = 30
|
|
263
|
+
mock_client.event_hooks = {"request": [], "response": []}
|
|
264
|
+
mock_client.ensure_token = MagicMock()
|
|
265
|
+
mock_client.follow_redirects = True
|
|
266
|
+
mock_client.close = MagicMock()
|
|
267
|
+
|
|
268
|
+
return mock_client
|
|
269
|
+
|
|
270
|
+
def __enter__(self):
|
|
271
|
+
"""Enter context manager"""
|
|
272
|
+
# Patch httpx.Client
|
|
273
|
+
patcher = patch("httpx.Client")
|
|
274
|
+
mock_client_class = patcher.start()
|
|
275
|
+
mock_client_class.return_value = self._create_mock_client()
|
|
276
|
+
self._patchers.append(patcher)
|
|
277
|
+
|
|
278
|
+
# Patch OAuth2Client which is used by EzClient
|
|
279
|
+
patcher = patch("authlib.integrations.httpx_client.OAuth2Client")
|
|
280
|
+
mock_oauth2_class = patcher.start()
|
|
281
|
+
mock_oauth2_class.return_value = self._create_mock_client()
|
|
282
|
+
self._patchers.append(patcher)
|
|
283
|
+
|
|
284
|
+
# Also patch at module level in case they're imported differently
|
|
285
|
+
patcher = patch("hcs_core.sglib.ez_client.OAuth2Client")
|
|
286
|
+
mock_oauth2_class = patcher.start()
|
|
287
|
+
mock_oauth2_class.return_value = self._create_mock_client()
|
|
288
|
+
self._patchers.append(patcher)
|
|
289
|
+
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
293
|
+
"""Exit context manager and cleanup"""
|
|
294
|
+
for patcher in self._patchers:
|
|
295
|
+
patcher.stop()
|
|
296
|
+
self._patchers.clear()
|
|
297
|
+
self.registry.reset()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@contextmanager
|
|
301
|
+
def mock_http(**configs: Dict[str, Any]):
|
|
302
|
+
"""
|
|
303
|
+
Context manager for setting up HTTP mocks.
|
|
304
|
+
|
|
305
|
+
Usage:
|
|
306
|
+
with mock_http_registry() as registry:
|
|
307
|
+
registry.register(
|
|
308
|
+
method='GET',
|
|
309
|
+
url='/v2/templates',
|
|
310
|
+
response_body=[...],
|
|
311
|
+
response_status_code=200,
|
|
312
|
+
)
|
|
313
|
+
# Run tests here
|
|
314
|
+
|
|
315
|
+
Example:
|
|
316
|
+
with mock_http_registry() as registry:
|
|
317
|
+
registry.register(
|
|
318
|
+
'GET', '/api/templates',
|
|
319
|
+
response_body=[{'id': '1', 'name': 'template1'}]
|
|
320
|
+
)
|
|
321
|
+
result = runner.invoke(hcs_template_list)
|
|
322
|
+
assert result.exit_code == 0
|
|
323
|
+
"""
|
|
324
|
+
ctx_manager = HttpMockContextManager()
|
|
325
|
+
with ctx_manager:
|
|
326
|
+
yield ctx_manager
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def create_mock_registry() -> HttpMockRegistry:
|
|
330
|
+
"""Create and return a new mock registry"""
|
|
331
|
+
return HttpMockRegistry()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class CliTestUtil:
|
|
335
|
+
"""
|
|
336
|
+
Utility for testing CLI commands with mocked HTTP responses.
|
|
337
|
+
|
|
338
|
+
Uses a static configuration format:
|
|
339
|
+
{
|
|
340
|
+
"GET /v2/templates?org_id=abc": {
|
|
341
|
+
"request": {
|
|
342
|
+
"body": {...} # optional
|
|
343
|
+
},
|
|
344
|
+
"response": {
|
|
345
|
+
"status_code": 200,
|
|
346
|
+
"body": {...},
|
|
347
|
+
"headers": {"Content-Type": "application/json"}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
Usage:
|
|
353
|
+
mock_config = {
|
|
354
|
+
"GET /v2/templates": {
|
|
355
|
+
"response": {
|
|
356
|
+
"status_code": 200,
|
|
357
|
+
"body": [{"id": "template-1", "name": "test"}]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
with CliTestUtil(mock_config) as test:
|
|
363
|
+
test.run("hcs template list", expected_json=[{"id": "template-1"}])
|
|
364
|
+
test.run("hcs template list -o json", expected_json=[...])
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
def __init__(self, mock_config: Dict[str, Any], env: Optional[Dict[str, str]] = None):
|
|
368
|
+
"""
|
|
369
|
+
Initialize the test utility.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
mock_config: Dictionary mapping "METHOD /path" to request/response config
|
|
373
|
+
env: Optional environment variables to pass to CLI commands
|
|
374
|
+
"""
|
|
375
|
+
self.mock_config = mock_config
|
|
376
|
+
self.env = env or {}
|
|
377
|
+
self._http_mock_context = None
|
|
378
|
+
self._registry = None
|
|
379
|
+
self._parse_and_register_mocks()
|
|
380
|
+
|
|
381
|
+
def _parse_and_register_mocks(self):
|
|
382
|
+
"""Parse mock config and prepare registration"""
|
|
383
|
+
self._http_mock_context = HttpMockContextManager()
|
|
384
|
+
|
|
385
|
+
for endpoint_key, endpoint_config in self.mock_config.items():
|
|
386
|
+
# Parse "METHOD /path?params"
|
|
387
|
+
parts = endpoint_key.split(" ", 1)
|
|
388
|
+
if len(parts) != 2:
|
|
389
|
+
raise ValueError(f"Invalid endpoint key format: {endpoint_key}. Expected 'METHOD /path'")
|
|
390
|
+
|
|
391
|
+
method, url = parts
|
|
392
|
+
method = method.strip()
|
|
393
|
+
url = url.strip()
|
|
394
|
+
|
|
395
|
+
response_config = endpoint_config.get("response", {})
|
|
396
|
+
status_code = response_config.get("status_code", 200)
|
|
397
|
+
body = response_config.get("body", {})
|
|
398
|
+
headers = response_config.get("headers", {"Content-Type": "application/json"})
|
|
399
|
+
|
|
400
|
+
# Register with the HTTP mock context
|
|
401
|
+
# Convert exact URL to regex pattern that matches query params
|
|
402
|
+
url_pattern = self._url_to_pattern(url)
|
|
403
|
+
self._http_mock_context.register(
|
|
404
|
+
method=method,
|
|
405
|
+
url=url_pattern,
|
|
406
|
+
response_status_code=status_code,
|
|
407
|
+
response_body=body,
|
|
408
|
+
response_headers=headers,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def _url_to_pattern(self, url: str) -> str:
|
|
412
|
+
"""
|
|
413
|
+
Convert exact URL to regex pattern.
|
|
414
|
+
|
|
415
|
+
/v2/templates -> .*v2/templates.*
|
|
416
|
+
/v2/templates/123 -> .*v2/templates/123.*
|
|
417
|
+
/v2/templates?org_id=abc -> .*v2/templates.*org_id=abc.*
|
|
418
|
+
"""
|
|
419
|
+
# Escape special regex characters but keep ? for pattern matching
|
|
420
|
+
escaped = re.escape(url)
|
|
421
|
+
# Remove extra escapes for ? and & to allow matching
|
|
422
|
+
escaped = escaped.replace(r"\?", ".*").replace(r"\&", ".*")
|
|
423
|
+
return f".*{escaped}.*"
|
|
424
|
+
|
|
425
|
+
def __enter__(self):
|
|
426
|
+
"""Enter the context manager"""
|
|
427
|
+
self._http_mock_context.__enter__()
|
|
428
|
+
self._registry = self._http_mock_context.registry
|
|
429
|
+
return self
|
|
430
|
+
|
|
431
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
432
|
+
"""Exit the context manager and cleanup"""
|
|
433
|
+
return self._http_mock_context.__exit__(exc_type, exc_val, exc_tb)
|
|
434
|
+
|
|
435
|
+
def run(
|
|
436
|
+
self,
|
|
437
|
+
cmd: str,
|
|
438
|
+
expected_stdout: Any = None,
|
|
439
|
+
expected_return_code: int = 0,
|
|
440
|
+
expect_stderr_empty: bool = True,
|
|
441
|
+
stdin_payload: str = None,
|
|
442
|
+
):
|
|
443
|
+
"""
|
|
444
|
+
Run a CLI command and verify the output.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
cmd: The CLI command to run
|
|
448
|
+
expected_stdout: Expected output (dict/list parsed as JSON, str for exact match, callable for custom)
|
|
449
|
+
expected_return_code: Expected exit code
|
|
450
|
+
expect_stderr_empty: Whether stderr should be empty
|
|
451
|
+
stdin_payload: Optional stdin input
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Tuple of (stdout, stderr, returncode)
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
# TODO
|
|
458
|
+
if "WIP":
|
|
459
|
+
return None, None, 0
|
|
460
|
+
|
|
461
|
+
stdout, stderr, returncode = self._run_command(cmd, stdin_payload)
|
|
462
|
+
|
|
463
|
+
# Verify return code
|
|
464
|
+
if returncode != expected_return_code:
|
|
465
|
+
raise AssertionError(
|
|
466
|
+
f"Return code mismatch.\nExpected: {expected_return_code}\nGot: {returncode}\nStdout: {stdout}\nStderr: {stderr}"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Verify stderr
|
|
470
|
+
actual_stderr_empty = stderr is None or len(stderr) == 0
|
|
471
|
+
if expect_stderr_empty and not actual_stderr_empty:
|
|
472
|
+
raise AssertionError(f"Expected empty stderr, but got:\n{stderr}")
|
|
473
|
+
|
|
474
|
+
# Verify stdout
|
|
475
|
+
if expected_stdout is not None:
|
|
476
|
+
self._verify_stdout(stdout, expected_stdout)
|
|
477
|
+
|
|
478
|
+
return stdout, stderr, returncode
|
|
479
|
+
|
|
480
|
+
def _verify_stdout(self, stdout: str, expected_stdout: Any):
|
|
481
|
+
"""Verify stdout matches expected value"""
|
|
482
|
+
t = type(expected_stdout)
|
|
483
|
+
|
|
484
|
+
if t is str:
|
|
485
|
+
if stdout != expected_stdout:
|
|
486
|
+
raise AssertionError(f"Stdout mismatch.\nExpected: {expected_stdout}\nGot: {stdout}")
|
|
487
|
+
elif t is dict or t is list:
|
|
488
|
+
if stdout is None or len(stdout) == 0:
|
|
489
|
+
raise AssertionError("Expected stdout but got empty")
|
|
490
|
+
try:
|
|
491
|
+
data = json.loads(stdout)
|
|
492
|
+
if data != expected_stdout:
|
|
493
|
+
raise AssertionError(
|
|
494
|
+
f"Stdout JSON mismatch.\nExpected: {json.dumps(expected_stdout, indent=2)}\nGot: {json.dumps(data, indent=2)}"
|
|
495
|
+
)
|
|
496
|
+
except json.JSONDecodeError as e:
|
|
497
|
+
raise AssertionError(f"Failed to parse stdout as JSON: {e}\nStdout: {stdout}")
|
|
498
|
+
elif callable(expected_stdout):
|
|
499
|
+
expected_stdout(stdout)
|
|
500
|
+
else:
|
|
501
|
+
raise ValueError(f"Unsupported expected_stdout type: {t}")
|
|
502
|
+
|
|
503
|
+
def _run_command(self, cmd: str, stdin_payload: Optional[str] = None) -> Tuple[str, str, int]:
|
|
504
|
+
"""
|
|
505
|
+
Run a command with mocked environment.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Tuple of (stdout, stderr, returncode)
|
|
509
|
+
"""
|
|
510
|
+
env = self._build_env()
|
|
511
|
+
p = subprocess.run(
|
|
512
|
+
cmd,
|
|
513
|
+
input=stdin_payload,
|
|
514
|
+
shell=True,
|
|
515
|
+
text=True,
|
|
516
|
+
check=False,
|
|
517
|
+
capture_output=True,
|
|
518
|
+
env=env,
|
|
519
|
+
)
|
|
520
|
+
return p.stdout, p.stderr, p.returncode
|
|
521
|
+
|
|
522
|
+
def _build_env(self) -> Dict[str, str]:
|
|
523
|
+
"""Build environment variables for command execution"""
|
|
524
|
+
import os
|
|
525
|
+
|
|
526
|
+
env = os.environ.copy()
|
|
527
|
+
env["HCS_CLI_TELEMETRY"] = "false"
|
|
528
|
+
env["HCS_CLI_CHECK_UPGRADE"] = "false"
|
|
529
|
+
env.update(self.env)
|
|
530
|
+
return env
|
|
531
|
+
|
|
532
|
+
def get_request_history(self) -> List[Tuple[str, str]]:
|
|
533
|
+
"""Get list of all HTTP requests made during test"""
|
|
534
|
+
if self._registry:
|
|
535
|
+
return self._registry.get_request_history()
|
|
536
|
+
return []
|
hcs_cli/support/vm_table.py
CHANGED
|
@@ -46,8 +46,8 @@ def format_vm_table(data):
|
|
|
46
46
|
"agentStatus",
|
|
47
47
|
{
|
|
48
48
|
"AVAILABLE": "green",
|
|
49
|
-
"ERROR": lambda d: "bright_black" if d
|
|
50
|
-
"UNAVAILABLE": lambda d: "bright_black" if d
|
|
49
|
+
"ERROR": lambda d: "bright_black" if d.get("powerState") != "PoweredOn" else "red",
|
|
50
|
+
"UNAVAILABLE": lambda d: "bright_black" if d.get("powerState") != "PoweredOn" else "red",
|
|
51
51
|
},
|
|
52
52
|
)
|
|
53
53
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hcs-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.320
|
|
4
4
|
Summary: Horizon Cloud Service CLI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/euc-eng/hcs-cli
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/euc-eng/hcs-cli/issues
|
|
@@ -14,14 +14,13 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
14
14
|
Classifier: Operating System :: OS Independent
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
16
|
Requires-Python: >=3.9
|
|
17
|
-
Requires-Dist: hcs-core>=0.1.
|
|
17
|
+
Requires-Dist: hcs-core>=0.1.320
|
|
18
18
|
Requires-Dist: inquirerpy>=0.3.4
|
|
19
19
|
Requires-Dist: matplotlib>=3.8.0
|
|
20
20
|
Requires-Dist: paho-mqtt>=2.1.0
|
|
21
21
|
Requires-Dist: reportlab>=4.0.0
|
|
22
22
|
Provides-Extra: dev
|
|
23
23
|
Requires-Dist: bandit; extra == 'dev'
|
|
24
|
-
Requires-Dist: black; extra == 'dev'
|
|
25
24
|
Requires-Dist: build; extra == 'dev'
|
|
26
25
|
Requires-Dist: flake8; extra == 'dev'
|
|
27
26
|
Requires-Dist: hatch; extra == 'dev'
|
|
@@ -30,7 +29,9 @@ Requires-Dist: pylint; extra == 'dev'
|
|
|
30
29
|
Requires-Dist: pytest; extra == 'dev'
|
|
31
30
|
Requires-Dist: ruff; extra == 'dev'
|
|
32
31
|
Requires-Dist: twine; extra == 'dev'
|
|
32
|
+
Requires-Dist: vulture; extra == 'dev'
|
|
33
33
|
Requires-Dist: wheel; extra == 'dev'
|
|
34
|
+
Requires-Dist: yamlfix; extra == 'dev'
|
|
34
35
|
Description-Content-Type: text/markdown
|
|
35
36
|
|
|
36
37
|
# horizon-cloud-service-cli
|
|
@@ -49,16 +50,16 @@ Description-Content-Type: text/markdown
|
|
|
49
50
|
- [Contributing](#contributing)
|
|
50
51
|
- [License](#license)
|
|
51
52
|
|
|
52
|
-
|
|
53
53
|
## Overview
|
|
54
|
+
|
|
54
55
|
hcs-cli is a human-friendly command line toolbox for [Omnissa Horizon Cloud Service (HCS)](https://www.omnissa.com/products/horizon-cloud/), based on public REST APIs.
|
|
55
56
|
|
|
56
57
|
## Try it out
|
|
57
58
|
|
|
58
|
-
|
|
59
59
|
### Prerequisites
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
|
|
61
|
+
- Python 3.9+
|
|
62
|
+
- Pip
|
|
62
63
|
|
|
63
64
|
Refer to [Setup Prerequisites](doc/dev-setup.md#setup-prerequisites) for more details.
|
|
64
65
|
|
|
@@ -67,42 +68,48 @@ Refer to [Setup Prerequisites](doc/dev-setup.md#setup-prerequisites) for more de
|
|
|
67
68
|
#### Mac & Linux
|
|
68
69
|
|
|
69
70
|
Install the tool
|
|
71
|
+
|
|
70
72
|
```
|
|
71
73
|
brew install python
|
|
72
74
|
pip install hcs-cli
|
|
73
75
|
```
|
|
74
76
|
|
|
75
77
|
#### Windows
|
|
78
|
+
|
|
76
79
|
Install the tool.
|
|
80
|
+
|
|
77
81
|
```
|
|
78
82
|
pip install hcs-cli
|
|
79
83
|
```
|
|
84
|
+
|
|
80
85
|
If you have python installed with option "Add python to path", it should be fine. Otherwise, you need to add python and it's Script directory to path.
|
|
81
86
|
|
|
82
87
|
#### Use the CLI
|
|
83
|
-
|
|
88
|
+
|
|
89
|
+
Use with default public HCS service.
|
|
90
|
+
|
|
84
91
|
```
|
|
85
92
|
hcs login
|
|
86
93
|
```
|
|
94
|
+
|
|
87
95
|
Run a command, for example, list templates:
|
|
96
|
+
|
|
88
97
|
```
|
|
89
98
|
hcs admin template list
|
|
90
99
|
```
|
|
91
100
|
|
|
92
101
|
## Documentation
|
|
93
|
-
* [HCS CLI - User Guide](../doc/hcs-cli-user-guide.md)
|
|
94
|
-
* [HCS CLI - Cheatsheet](../doc/hcs-cli-cheatsheet.md)
|
|
95
|
-
* [HCS CLI - Dev Guide](../doc/hcs-cli-dev-guide.md)
|
|
96
|
-
* [HCS Plan - template engine for HCS](../doc/hcs-plan.md)
|
|
97
|
-
* [Context Programming](https://github.com/nanw1103/context-programming)
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
- [HCS CLI - User Guide](../doc/hcs-cli-user-guide.md)
|
|
104
|
+
- [HCS CLI - Cheatsheet](../doc/hcs-cli-cheatsheet.md)
|
|
105
|
+
- [HCS CLI - Dev Guide](../doc/hcs-cli-dev-guide.md)
|
|
106
|
+
- [HCS Plan - template engine for HCS](../doc/hcs-plan.md)
|
|
107
|
+
- [Context Programming](https://github.com/nanw1103/context-programming)
|
|
108
|
+
|
|
100
109
|
## Contributing
|
|
101
110
|
|
|
102
|
-
The horizon-cloud-service-cli project team welcomes contributions from the community. Before you start working with horizon-cloud-service-cli, please read and sign our Contributor License Agreement [CLA](https://cla.vmware.com/cla/1/preview). If you wish to contribute code and you have not signed our CLA, our bot will prompt you to do so when you open a Pull Request. For any questions about the CLA process, please refer to our [FAQ]([https://cla.vmware.com/faq](https://cla.vmware.com/faq)).
|
|
111
|
+
The horizon-cloud-service-cli project team welcomes contributions from the community. Before you start working with horizon-cloud-service-cli, please read and sign our Contributor License Agreement [CLA](https://cla.vmware.com/cla/1/preview). If you wish to contribute code and you have not signed our CLA, our bot will prompt you to do so when you open a Pull Request. For any questions about the CLA process, please refer to our [FAQ](<[https://cla.vmware.com/faq](https://cla.vmware.com/faq)>).
|
|
103
112
|
|
|
104
113
|
## License
|
|
105
114
|
|
|
106
115
|
Apache 2.0
|
|
107
|
-
|
|
108
|
-
|