multicloud-cli 0.1.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.
- multicloud_cli/__init__.py +1 -0
- multicloud_cli/api.py +152 -0
- multicloud_cli/auth.py +547 -0
- multicloud_cli/catalog.py +311 -0
- multicloud_cli/cloud_account.py +401 -0
- multicloud_cli/config.py +114 -0
- multicloud_cli/duration.py +30 -0
- multicloud_cli/exitcodes.py +30 -0
- multicloud_cli/main.py +90 -0
- multicloud_cli/orgopt.py +28 -0
- multicloud_cli/output.py +40 -0
- multicloud_cli/pool.py +454 -0
- multicloud_cli/sso.py +265 -0
- multicloud_cli-0.1.0.dist-info/METADATA +148 -0
- multicloud_cli-0.1.0.dist-info/RECORD +18 -0
- multicloud_cli-0.1.0.dist-info/WHEEL +4 -0
- multicloud_cli-0.1.0.dist-info/entry_points.txt +2 -0
- multicloud_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""``mctl catalog``: find servers and prices in the Multicloud catalog.
|
|
2
|
+
|
|
3
|
+
These commands call ``/api/v1/catalog/...`` (the catalog belongs to the platform, not to one organization). An API
|
|
4
|
+
key needs the ``catalog`` scope. The key comes from ``mctl auth`` (or ``MULTICLOUD_API_KEY``, which needs no org);
|
|
5
|
+
a stored key is found by ``--org``/``MULTICLOUD_ORG`` or the saved config. An SSO sign-in needs no org.
|
|
6
|
+
The filter options of ``catalog search`` carry the API parameter name verbatim (``--min-vcpus`` and ``--min_vcpus``).
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from . import api, exitcodes
|
|
15
|
+
from .orgopt import org_group
|
|
16
|
+
from .output import dump as _dump, json_option, no_headers_option, plain as _plain, quiet_option, run, table as _table
|
|
17
|
+
|
|
18
|
+
SCOPE = "catalog"
|
|
19
|
+
|
|
20
|
+
# One entry per field of the catalog API's ServerFilter: the API name, the type, the help.
|
|
21
|
+
# The contract test fails when this list and the API drift apart, in either direction.
|
|
22
|
+
FILTERS = [
|
|
23
|
+
("provider", str, "Cloud provider (case-insensitive)"),
|
|
24
|
+
("region", str, "Region name; a regex such as 'eu-.*' works"),
|
|
25
|
+
("instance_type", str, "Instance type; a regex such as 't3.*' works"),
|
|
26
|
+
("min_vcpus", int, "Minimum number of vCPUs"),
|
|
27
|
+
("max_vcpus", int, "Maximum number of vCPUs"),
|
|
28
|
+
("min_memory_gb", float, "Minimum RAM in GB"),
|
|
29
|
+
("max_memory_gb", float, "Maximum RAM in GB"),
|
|
30
|
+
("min_storage_gb", float, "Minimum storage in GB"),
|
|
31
|
+
("max_storage_gb", float, "Maximum storage in GB"),
|
|
32
|
+
("max_price_per_hour", float, "Maximum price per hour in USD"),
|
|
33
|
+
("max_price_per_month", float, "Maximum estimated price per month in USD"),
|
|
34
|
+
("processor_architecture", str, "CPU architecture (x86_64, arm64)"),
|
|
35
|
+
("processor_vendor", str, "Processor vendor"),
|
|
36
|
+
("processor_min_ghz", float, "Minimum processor speed in GHz"),
|
|
37
|
+
("min_single_core_benchmark", float, "Minimum single-core benchmark score"),
|
|
38
|
+
("min_multi_core_benchmark", float, "Minimum multi-core benchmark score"),
|
|
39
|
+
("accelerator_name", str, "GPU name; a regex such as 'A100.*' works"),
|
|
40
|
+
("accelerator_vendor", str, "GPU vendor"),
|
|
41
|
+
("min_accelerator_count", int, "Minimum number of GPUs"),
|
|
42
|
+
("min_accelerator_memory_gb", float, "Minimum memory per GPU in GB"),
|
|
43
|
+
("min_total_accelerator_memory_gb", float, "Minimum total GPU memory in GB"),
|
|
44
|
+
("has_accelerator", bool, "Only servers with a GPU (--has-accelerator) or without one (--no-accelerator)"),
|
|
45
|
+
("min_accelerator_fraction", float, "Minimum GPU fraction (1 = whole GPUs only)"),
|
|
46
|
+
("price_type", str, "SPOT or ON_DEMAND"),
|
|
47
|
+
("max_risk", int, "Maximum preemption risk of spot servers, 0-5 (servers with unknown risk are left out)"),
|
|
48
|
+
("min_availability_score", int, "Minimum spot availability score, 1-10"),
|
|
49
|
+
("sort_by", str, "Field to sort by (default: price_per_hour)"),
|
|
50
|
+
("sort_order", str, "asc or desc (default: asc)"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CatalogError(exitcodes.CliError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _call(org, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
59
|
+
try:
|
|
60
|
+
return api.call("GET", org, path, scope=SCOPE, org_scoped=False, params=params)
|
|
61
|
+
except api.ApiError as ex:
|
|
62
|
+
text = str(ex)
|
|
63
|
+
if ex.status == 403 and "scope" not in text:
|
|
64
|
+
# An SSO sign-in: the API key hint of api.call does not apply.
|
|
65
|
+
text += " (HTTP 403. An API key with the 'catalog' scope can read the catalog.)"
|
|
66
|
+
raise CatalogError(text, ex.exit_code)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@click.group(cls=org_group("catalog_org"))
|
|
70
|
+
@click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG)")
|
|
71
|
+
@click.pass_context
|
|
72
|
+
def catalog(ctx, org):
|
|
73
|
+
"""Find servers and prices (sign in first: mctl auth login)."""
|
|
74
|
+
ctx.ensure_object(dict)
|
|
75
|
+
ctx.obj["catalog_org"] = org
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run(ctx, fn):
|
|
79
|
+
return run(ctx.obj.get("catalog_org"), fn, (CatalogError, api.ApiError))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _one_output(as_json: bool, quiet: bool) -> None:
|
|
83
|
+
if as_json and quiet:
|
|
84
|
+
raise click.UsageError("Use --json or -q, not both.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dash(value, fmt: str = "") -> str:
|
|
88
|
+
return "-" if value is None else format(value, fmt)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _number(value) -> str:
|
|
92
|
+
"""A metric value without exponent form (1234567.5, not 1.23457e+06)."""
|
|
93
|
+
if isinstance(value, float):
|
|
94
|
+
return f"{value:.4f}".rstrip("0").rstrip(".")
|
|
95
|
+
return _dash(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _price(value) -> str:
|
|
99
|
+
return _dash(value, ".4f")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _gpu(s: Dict[str, Any]) -> str:
|
|
103
|
+
name = s.get("accelerator_name")
|
|
104
|
+
if not name:
|
|
105
|
+
return "-"
|
|
106
|
+
count = s.get("accelerator_count")
|
|
107
|
+
return f"{count}x {name}" if count else str(name)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _footer(data: Dict[str, Any]) -> None:
|
|
111
|
+
meta = data.get("metadata") or {}
|
|
112
|
+
if meta and not meta.get("total_results"):
|
|
113
|
+
click.echo("0 results.", err=True)
|
|
114
|
+
elif meta:
|
|
115
|
+
click.echo(f"Page {meta.get('current_page')} of {meta.get('total_pages')}, "
|
|
116
|
+
f"{meta.get('total_results')} results.", err=True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def format_servers(servers: List[Dict[str, Any]], headers: bool = True) -> str:
|
|
120
|
+
return _table(("ID", "PROVIDER", "REGION", "TYPE", "VCPU", "MEM_GB", "GPU", "PRICE/H", "PRICE_TYPE"), [
|
|
121
|
+
(str(s.get("catalog_id")), str(s.get("provider") or "-"), str(s.get("region") or "-"),
|
|
122
|
+
str(s.get("instance_type") or "-"), _dash(s.get("vcpus")), _dash(s.get("memory_gb"), "g"), _gpu(s),
|
|
123
|
+
_price(s.get("price_per_hour")), str(s.get("price_type") or "-"))
|
|
124
|
+
for s in servers], headers)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _filter_options(fn):
|
|
128
|
+
"""One option per ServerFilter field. The click parameter name is the API name verbatim; the option has a
|
|
129
|
+
hyphenated spelling (--min-vcpus) and the API spelling (--min_vcpus). ``has_accelerator`` is a tri-state."""
|
|
130
|
+
for name, kind, help_ in reversed(FILTERS):
|
|
131
|
+
dashed = name.replace("_", "-")
|
|
132
|
+
if kind is bool:
|
|
133
|
+
decls = ["--has-accelerator/--no-accelerator", "--has_accelerator/--no_accelerator", name]
|
|
134
|
+
opt = click.option(*decls, default=None, help=help_)
|
|
135
|
+
else:
|
|
136
|
+
decls = [f"--{dashed}"] + ([f"--{name}"] if dashed != name else []) + [name]
|
|
137
|
+
opt = click.option(*decls, type=kind, default=None, help=help_)
|
|
138
|
+
fn = opt(fn)
|
|
139
|
+
return fn
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _query(given: Dict[str, Any]) -> Dict[str, Any]:
|
|
143
|
+
"""The query parameters: only what the user gave, a bool as true or false."""
|
|
144
|
+
return {k: ("true" if v is True else "false" if v is False else v) for k, v in given.items() if v is not None}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
page_option = click.option("--page", type=click.IntRange(1, 1000), default=None, help="Page number (default: 1)")
|
|
148
|
+
per_page_option = click.option("--per-page", "--per_page", "per_page", type=click.IntRange(1, 100), default=None,
|
|
149
|
+
help="Results per page, 1-100 (default: 20)")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@catalog.command()
|
|
153
|
+
@_filter_options
|
|
154
|
+
@page_option
|
|
155
|
+
@per_page_option
|
|
156
|
+
@json_option
|
|
157
|
+
@quiet_option
|
|
158
|
+
@no_headers_option
|
|
159
|
+
@click.pass_context
|
|
160
|
+
def search(ctx, page, per_page, as_json, quiet, no_headers, **filters):
|
|
161
|
+
"""Search servers and prices. Only the options you give are sent."""
|
|
162
|
+
_one_output(as_json, quiet)
|
|
163
|
+
params = _query({**filters, "page": page, "per_page": per_page})
|
|
164
|
+
data = _run(ctx, lambda org: _call(org, "/catalog/servers", params))
|
|
165
|
+
if as_json:
|
|
166
|
+
return _dump(data)
|
|
167
|
+
if quiet:
|
|
168
|
+
for s in data.get("data", []):
|
|
169
|
+
if s.get("catalog_id") is not None:
|
|
170
|
+
click.echo(s["catalog_id"])
|
|
171
|
+
return
|
|
172
|
+
click.echo(format_servers(data.get("data", []), not no_headers))
|
|
173
|
+
_footer(data)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _readable(s: Dict[str, Any]) -> str:
|
|
177
|
+
cpu = " ".join(str(x) for x in (s.get("processor_vendor"), s.get("processor_architecture")) if x)
|
|
178
|
+
if s.get("processor_max_speed_ghz") is not None:
|
|
179
|
+
cpu += f" {s['processor_max_speed_ghz']:g} GHz"
|
|
180
|
+
gpu = _gpu(s) if s.get("accelerator_name") else None
|
|
181
|
+
if gpu and s.get("accelerator_memory_mb"):
|
|
182
|
+
gpu += f" ({s['accelerator_memory_mb'] / 1024:g} GB each)"
|
|
183
|
+
lines = [
|
|
184
|
+
f"Server {s.get('catalog_id')} ({s.get('provider')} {s.get('region')} {s.get('instance_type')})",
|
|
185
|
+
("Zone", s.get("zone")),
|
|
186
|
+
("vCPUs", s.get("vcpus")),
|
|
187
|
+
("Memory", None if s.get("memory_gb") is None else f"{s['memory_gb']:g} GB"),
|
|
188
|
+
("Storage", None if s.get("storage_gb") is None else f"{s['storage_gb']:g} GB"),
|
|
189
|
+
("Processor", cpu.strip() or None),
|
|
190
|
+
("GPU", gpu),
|
|
191
|
+
("Price type", s.get("price_type")),
|
|
192
|
+
("Price per hour", None if s.get("price_per_hour") is None else f"{s['price_per_hour']:.4f} USD"),
|
|
193
|
+
("Price per month", None if s.get("price_per_month") is None else f"{s['price_per_month']:.2f} USD"),
|
|
194
|
+
("Risk", s.get("risk")),
|
|
195
|
+
("Risk source", s.get("risk_source")),
|
|
196
|
+
("Availability score", s.get("availability_score")),
|
|
197
|
+
("Launch score", s.get("launch_score")),
|
|
198
|
+
("Single-core benchmark", s.get("single_core_benchmark")),
|
|
199
|
+
("Multi-core benchmark", s.get("multi_core_benchmark")),
|
|
200
|
+
("Updated", s.get("updated_at")),
|
|
201
|
+
]
|
|
202
|
+
return "\n".join(line if isinstance(line, str) else f"{line[0]}: {line[1]}"
|
|
203
|
+
for line in lines if isinstance(line, str) or line[1] not in (None, ""))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@catalog.command()
|
|
207
|
+
@click.argument("catalog_id", type=int, metavar="ID")
|
|
208
|
+
@json_option
|
|
209
|
+
@click.pass_context
|
|
210
|
+
def show(ctx, catalog_id, as_json):
|
|
211
|
+
"""Show one server listing (the ID is in the first column of: mctl catalog search)."""
|
|
212
|
+
data = _run(ctx, lambda org: _call(org, f"/catalog/servers/{catalog_id}"))
|
|
213
|
+
if as_json:
|
|
214
|
+
return _dump(data)
|
|
215
|
+
click.echo(_readable(data.get("data") or {}))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@catalog.command()
|
|
219
|
+
@click.argument("catalog_ids", type=int, nargs=-1, metavar="ID ID...")
|
|
220
|
+
@json_option
|
|
221
|
+
@no_headers_option
|
|
222
|
+
@click.pass_context
|
|
223
|
+
def compare(ctx, catalog_ids, as_json, no_headers):
|
|
224
|
+
"""Compare two or more server listings by ID, metric by metric."""
|
|
225
|
+
catalog_ids = tuple(dict.fromkeys(catalog_ids)) # the API drops a repeated ID
|
|
226
|
+
if len(catalog_ids) < 2:
|
|
227
|
+
raise click.UsageError("Give at least two different IDs to compare.")
|
|
228
|
+
data = _run(ctx, lambda org: _call(org, "/catalog/compare-servers", {"catalog_ids": list(catalog_ids)}))
|
|
229
|
+
if as_json:
|
|
230
|
+
return _dump(data)
|
|
231
|
+
servers = data.get("data") or []
|
|
232
|
+
metrics: List[str] = []
|
|
233
|
+
for s in servers:
|
|
234
|
+
for m in s.get("comparisonResults") or []:
|
|
235
|
+
if m.get("metric") not in metrics:
|
|
236
|
+
metrics.append(m.get("metric"))
|
|
237
|
+
header = ("METRIC", *(str(s.get("catalog_id")) for s in servers))
|
|
238
|
+
rows = []
|
|
239
|
+
for metric in metrics:
|
|
240
|
+
cells = []
|
|
241
|
+
for s in servers:
|
|
242
|
+
found = [m.get("value") for m in s.get("comparisonResults") or [] if m.get("metric") == metric]
|
|
243
|
+
cells.append(_number(found[0]) if found else "-")
|
|
244
|
+
rows.append((str(metric), *cells))
|
|
245
|
+
if servers:
|
|
246
|
+
click.echo(_table(header, rows, not no_headers))
|
|
247
|
+
meta = data.get("metadata") or {}
|
|
248
|
+
for key, text in (("notFoundIds", "Not found"), ("invalidIds", "Invalid")):
|
|
249
|
+
if meta.get(key):
|
|
250
|
+
click.echo(f"{text}: {', '.join(str(i) for i in meta[key])}.", err=True)
|
|
251
|
+
if not servers:
|
|
252
|
+
click.echo("Error: none of the IDs was found.", err=True)
|
|
253
|
+
sys.exit(exitcodes.NOT_FOUND)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@catalog.command()
|
|
257
|
+
@click.option("--provider", default=None, help="Cloud provider")
|
|
258
|
+
@click.option("--region", default=None, help="Region name")
|
|
259
|
+
@click.option("--zone", default=None, help="Zone name")
|
|
260
|
+
@click.option("--sort-by", "--sort_by", "sort_by", default=None, help="provider, region or zone")
|
|
261
|
+
@click.option("--sort-order", "--sort_order", "sort_order", default=None, help="asc or desc")
|
|
262
|
+
@page_option
|
|
263
|
+
@per_page_option
|
|
264
|
+
@json_option
|
|
265
|
+
@quiet_option
|
|
266
|
+
@no_headers_option
|
|
267
|
+
@click.pass_context
|
|
268
|
+
def locations(ctx, provider, region, zone, sort_by, sort_order, page, per_page, as_json, quiet, no_headers):
|
|
269
|
+
"""List the locations (provider, region, zone) of the catalog."""
|
|
270
|
+
_one_output(as_json, quiet)
|
|
271
|
+
params = _query(dict(provider=provider, region=region, zone=zone, sort_by=sort_by, sort_order=sort_order,
|
|
272
|
+
page=page, per_page=per_page))
|
|
273
|
+
data = _run(ctx, lambda org: _call(org, "/catalog/locations", params))
|
|
274
|
+
if as_json:
|
|
275
|
+
return _dump(data)
|
|
276
|
+
if quiet:
|
|
277
|
+
for loc in data.get("data", []):
|
|
278
|
+
if loc.get("id") is not None:
|
|
279
|
+
click.echo(loc["id"])
|
|
280
|
+
return
|
|
281
|
+
click.echo(_table(("ID", "PROVIDER", "REGION", "ZONE", "NAME"), [
|
|
282
|
+
(str(loc.get("id")), str(loc.get("provider") or "-"), str(loc.get("region") or "-"),
|
|
283
|
+
str(loc.get("zone") or "-"), str(loc.get("display_name") or "-")) for loc in data.get("data", [])],
|
|
284
|
+
not no_headers))
|
|
285
|
+
_footer(data)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@catalog.command()
|
|
289
|
+
@json_option
|
|
290
|
+
@click.pass_context
|
|
291
|
+
def providers(ctx, as_json):
|
|
292
|
+
"""List the cloud providers of the catalog."""
|
|
293
|
+
data = _run(ctx, lambda org: _call(org, "/catalog/providers"))
|
|
294
|
+
names = data.get("body") or []
|
|
295
|
+
if as_json:
|
|
296
|
+
return click.echo(json.dumps(names, indent=2))
|
|
297
|
+
for name in names:
|
|
298
|
+
click.echo(name)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@catalog.command(name="field-values")
|
|
302
|
+
@json_option
|
|
303
|
+
@click.pass_context
|
|
304
|
+
def field_values(ctx, as_json):
|
|
305
|
+
"""List the values the filters accept (providers, processor architectures, vendors, price types)."""
|
|
306
|
+
data = _run(ctx, lambda org: _call(org, "/catalog/field-values"))
|
|
307
|
+
if as_json:
|
|
308
|
+
return _dump(data)
|
|
309
|
+
for key in sorted(_plain(data)):
|
|
310
|
+
values = data[key] if isinstance(data[key], list) else [] if data[key] is None else [data[key]]
|
|
311
|
+
click.echo(f"{key}: {', '.join(str(v) for v in values)}")
|