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.
@@ -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)}")