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,28 @@
1
+ """``--org`` on a command group and on each of its subcommands.
2
+
3
+ The error for several organizations says "Name one with --org". A user types it
4
+ after the subcommand (``mctl pool list --org acme``), so every subcommand takes it
5
+ too. The one after the subcommand wins over the one on the group.
6
+ """
7
+ import click
8
+
9
+
10
+ def _store(key):
11
+ def callback(ctx, param, value):
12
+ if value is not None:
13
+ ctx.ensure_object(dict)
14
+ ctx.obj[key] = value
15
+ return None
16
+ return callback
17
+
18
+
19
+ def org_group(key):
20
+ """A click Group class whose subcommands accept --org and store it in ctx.obj[key]."""
21
+ class OrgGroup(click.Group):
22
+ def add_command(self, cmd, name=None):
23
+ if not any("--org" in getattr(p, "opts", ()) for p in cmd.params):
24
+ cmd.params.append(click.Option(
25
+ ["--org"], default=None, expose_value=False, callback=_store(key),
26
+ help="Organization slug (default: MULTICLOUD_ORG)"))
27
+ super().add_command(cmd, name)
28
+ return OrgGroup
@@ -0,0 +1,40 @@
1
+ """Output helpers shared by the command groups: the plain table, the JSON dump and the common options."""
2
+ import json
3
+ import sys
4
+ from typing import Any, Callable, Dict, List, Tuple
5
+
6
+ import click
7
+
8
+ from . import exitcodes
9
+
10
+
11
+ def plain(data: Dict[str, Any]) -> Dict[str, Any]:
12
+ data = dict(data)
13
+ data.pop("_status", None)
14
+ return data
15
+
16
+
17
+ def dump(data: Dict[str, Any]) -> None:
18
+ click.echo(json.dumps(plain(data), indent=2, sort_keys=True))
19
+
20
+
21
+ def table(header: Tuple[str, ...], rows: List[Tuple[str, ...]], headers: bool = True) -> str:
22
+ rows = ([header] if headers else []) + rows
23
+ if not rows:
24
+ return ""
25
+ widths = [max(len(r[i]) for r in rows) for i in range(len(header))]
26
+ return "\n".join(" ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip() for r in rows)
27
+
28
+
29
+ def run(org, fn: Callable[[Any], Any], errors: Tuple[type, ...]) -> Any:
30
+ """Run ``fn(org)``; an error of ``errors`` prints ``Error: <text>`` and exits with its exit code."""
31
+ try:
32
+ return fn(org)
33
+ except errors as ex:
34
+ click.echo(f"Error: {ex}", err=True)
35
+ sys.exit(exitcodes.of(ex))
36
+
37
+
38
+ json_option = click.option("--json", "as_json", is_flag=True, help="Print the raw JSON")
39
+ quiet_option = click.option("-q", "--quiet", is_flag=True, help="Print only the IDs, one per line")
40
+ no_headers_option = click.option("--no-headers", is_flag=True, help="Leave the header line out of the table")
multicloud_cli/pool.py ADDED
@@ -0,0 +1,454 @@
1
+ """``mctl pool``: create and manage the CI pools of an organization.
2
+
3
+ These commands call ``/api/v1/orgs/{slug}/pools`` with an API key. Reads need ``org:read``, create and
4
+ update need ``org:write``, delete, hibernate, wake and rotate-credentials need ``org:admin``.
5
+ The key comes from ``mctl auth`` (or ``MULTICLOUD_API_KEY``); org and URL from ``--org``/``MULTICLOUD_ORG``/
6
+ ``MULTICLOUD_API_URL`` or the saved config.
7
+ A ``<pool>`` argument is the numeric id or the exact name of the pool.
8
+ """
9
+ import json
10
+ import sys
11
+ import time
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Dict, Optional, Tuple
15
+
16
+ import click
17
+
18
+ from . import api, exitcodes
19
+ from .duration import duration_option, interval_option, parse_duration # noqa: F401 (parse_duration: tests)
20
+ from .orgopt import org_group
21
+ from .output import dump as _dump, json_option, no_headers_option, plain as _plain, quiet_option, run, table as _table
22
+
23
+ READ, WRITE, ADMIN = "org:read", "org:write", "org:admin"
24
+ EXIT_OK, EXIT_TIMEOUT, EXIT_DELETED = exitcodes.OK, exitcodes.PENDING, exitcodes.GONE
25
+ _sleep = time.sleep # tests patch this name
26
+ # PoolView.status values in which a wake is finished, and in which the pool is gone.
27
+ UP = ("ready", "at_cost_cap")
28
+ GONE = ("deleting", "deleted")
29
+
30
+
31
+ class PoolError(exitcodes.CliError):
32
+ pass
33
+
34
+
35
+ def _stdin_is_tty() -> bool:
36
+ return sys.stdin.isatty()
37
+
38
+
39
+ def _fail(ex: api.ApiError, text: Optional[str] = None) -> PoolError:
40
+ return PoolError(text or str(ex), ex.exit_code)
41
+
42
+
43
+ def _call(org, method: str, path: str, scope: str, **kw) -> Dict[str, Any]:
44
+ try:
45
+ return api.call(method, org, path, scope=scope, **kw)
46
+ except api.ApiError as ex:
47
+ raise _fail(ex)
48
+
49
+
50
+ def resolve(org, ref: str) -> int:
51
+ """The id for a numeric ``ref``, else for the pool with that exact name (one list call)."""
52
+ if ref.isascii() and ref.isdigit():
53
+ return int(ref)
54
+ pools = _call(org, "GET", "/pools", READ).get("pools", [])
55
+ found = [p for p in pools if p.get("name") == ref]
56
+ if not found:
57
+ raise PoolError(f"No pool named '{ref}'. Run: mctl pool list", exitcodes.NOT_FOUND)
58
+ if len(found) > 1:
59
+ ids = ", ".join(str(p.get("pool_id")) for p in found)
60
+ raise PoolError(f"Several pools are named '{ref}' (ids {ids}). Use the id.")
61
+ return int(found[0]["pool_id"])
62
+
63
+
64
+ def _money(value) -> str:
65
+ return "-" if value is None else f"{value:.2f}"
66
+
67
+
68
+ def _status(p: Dict[str, Any]) -> str:
69
+ out = str(p.get("status", "unknown"))
70
+ if p.get("status_message"):
71
+ out += f" ({p['status_message']})"
72
+ return out
73
+
74
+
75
+ def _clouds(p: Dict[str, Any]) -> str:
76
+ parts = []
77
+ for c in p.get("clouds") or []:
78
+ regions = c.get("regions") or []
79
+ part = f"{c.get('type')}: " + (", ".join(regions) if regions else "all regions")
80
+ if c.get("exclude_regions"):
81
+ part += " except " + ", ".join(c["exclude_regions"])
82
+ parts.append(part)
83
+ return "; ".join(parts)
84
+
85
+
86
+ def _readable(p: Dict[str, Any]) -> str:
87
+ lines = [
88
+ f"Pool {p.get('pool_id')} ({p.get('name')})",
89
+ ("Type", p.get("type")),
90
+ f"Status: {_status(p)}",
91
+ ("Clouds", _clouds(p)),
92
+ ("Classes", ", ".join(p["classes"]) if p.get("classes") else None),
93
+ ("GitHub org", p.get("github_org")),
94
+ ("Spot policy", p.get("spot_policy")),
95
+ ("CPU units requests", p.get("cpu_units_requests")),
96
+ ("Nodes", p.get("node_count")),
97
+ ("Cost per hour", f"{_money(p.get('current_cost_per_hour'))} of {_money(p.get('max_cost_per_hour'))} allowed"),
98
+ ("Hibernated since", p.get("hibernated_since")),
99
+ ("Hibernated reason", p.get("hibernated_reason")),
100
+ ]
101
+ return "\n".join(l if isinstance(l, str) else f"{l[0]}: {l[1]}"
102
+ for l in lines if isinstance(l, str) or l[1] not in (None, ""))
103
+
104
+
105
+ def format_pools(pools, headers: bool = True) -> str:
106
+ return _table(("ID", "NAME", "TYPE", "STATUS", "NODES", "COST/H"), [
107
+ (str(p.get("pool_id")), str(p.get("name")), str(p.get("type")), str(p.get("status")),
108
+ str(p.get("node_count")), f"{_money(p.get('current_cost_per_hour'))}/{_money(p.get('max_cost_per_hour'))}")
109
+ for p in pools], headers)
110
+
111
+
112
+ @click.group(cls=org_group("pool_org"))
113
+ @click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG)")
114
+ @click.pass_context
115
+ def pool(ctx, org):
116
+ """Manage CI pools (sign in first: mctl auth login)."""
117
+ ctx.ensure_object(dict)
118
+ ctx.obj["pool_org"] = org
119
+
120
+
121
+ def _run(ctx, fn):
122
+ return run(ctx.obj.get("pool_org"), fn, (PoolError, api.ApiError))
123
+
124
+
125
+ @pool.command()
126
+ @json_option
127
+ @no_headers_option
128
+ @click.pass_context
129
+ def classes(ctx, as_json, no_headers):
130
+ """List the runner classes a pool can use."""
131
+ data = _run(ctx, lambda org: _call(org, "GET", "/pool-classes", READ))
132
+ if as_json:
133
+ return _dump(data)
134
+ rows = []
135
+ for c in data.get("classes", []):
136
+ price = c.get("price_per_hour")
137
+ gpu = c.get("gpu")
138
+ size = f"{gpu['count']}x {gpu['name']}" if gpu else f"{c.get('cores')} cores"
139
+ rows.append((str(c.get("name")), str(c.get("kind")), size, str(c.get("purchase")),
140
+ "yes" if c.get("enabled") else "no",
141
+ f"{price['min']:.2f}-{price['max']:.2f}" if price else "-"))
142
+ click.echo(_table(("CLASS", "KIND", "SIZE", "PURCHASE", "ENABLED", "USD/H"), rows, not no_headers))
143
+
144
+
145
+ @pool.command(name="list")
146
+ @json_option
147
+ @quiet_option
148
+ @no_headers_option
149
+ @click.pass_context
150
+ def list_(ctx, as_json, quiet, no_headers):
151
+ """List the pools of the organization."""
152
+ data = _run(ctx, lambda org: _call(org, "GET", "/pools", READ))
153
+ if as_json:
154
+ return _dump(data)
155
+ if quiet:
156
+ for p in data.get("pools", []):
157
+ click.echo(p.get("pool_id"))
158
+ return
159
+ click.echo(format_pools(data.get("pools", []), not no_headers))
160
+
161
+
162
+ @pool.command()
163
+ @click.argument("pool_ref", metavar="POOL")
164
+ @json_option
165
+ @click.pass_context
166
+ def show(ctx, pool_ref, as_json):
167
+ """Show one pool (id or name)."""
168
+ def go(org):
169
+ return _call(org, "GET", f"/pools/{resolve(org, pool_ref)}", READ)
170
+ data = _run(ctx, go)
171
+ click.echo(json.dumps(_plain(data), indent=2, sort_keys=True) if as_json else _readable(data))
172
+
173
+
174
+ def _read_stdin() -> str:
175
+ return sys.stdin.read()
176
+
177
+
178
+ def _json_value(ctx, param, value):
179
+ """A JSON value, or ``@path`` to read it from a file (``@-``: standard input)."""
180
+ if value is None:
181
+ return None
182
+ try:
183
+ if value == "@-":
184
+ text = _read_stdin()
185
+ else:
186
+ text = Path(value[1:]).read_text() if value.startswith("@") else value
187
+ return json.loads(text)
188
+ except (OSError, ValueError) as ex:
189
+ raise click.BadParameter(f"Not valid JSON ({ex}). Give JSON, or @file.json (@- for standard input).")
190
+
191
+
192
+ def _classes_list(ctx, param, value):
193
+ names = [n.strip() for v in value for n in v.split(",") if n.strip()]
194
+ return names or None
195
+
196
+
197
+ def _collect(**given) -> Dict[str, Any]:
198
+ return {k: v for k, v in given.items() if v is not None}
199
+
200
+
201
+ @pool.command()
202
+ @click.argument("name", required=False)
203
+ @click.option("--github-org", default=None, help="GitHub organization the runners serve")
204
+ @click.option("--clouds", callback=_json_value, default=None,
205
+ help="JSON list of clouds, or @file.json. Cloud keys belong in --from-file, not in a command line")
206
+ @click.option("--classes", "classes_", multiple=True, callback=_classes_list,
207
+ help="Runner class; repeat or separate with commas (see: mctl pool classes)")
208
+ @click.option("--max-cost-per-hour", type=float, default=None, help="Cost ceiling in USD per hour")
209
+ @click.option("--image-by-class", callback=_json_value, default=None, help="JSON object, or @file.json")
210
+ @click.option("--egress-by-class", callback=_json_value, default=None, help="JSON object, or @file.json")
211
+ @click.option("--class-headroom", callback=_json_value, default=None, help="JSON object, or @file.json")
212
+ @click.option("--spot-policy", default=None, help="Spot policy name")
213
+ @click.option("--cpu-units-requests/--no-cpu-units-requests", default=None,
214
+ help="Runner pods request cpu-units instead of cpu")
215
+ @click.option("--from-file", "from_file", type=click.Path(dir_okay=False, allow_dash=True), default=None,
216
+ help="JSON file with the request body (- for standard input); flags override its fields")
217
+ @click.option("--idempotency-key", default=None,
218
+ help="Same key and body within 24 hours creates one pool (default: a new random key)")
219
+ @json_option
220
+ @click.pass_context
221
+ def create(ctx, name, github_org, clouds, classes_, max_cost_per_hour, image_by_class, egress_by_class,
222
+ class_headroom, spot_policy, cpu_units_requests, from_file, idempotency_key, as_json):
223
+ """Create a CI pool."""
224
+ body: Dict[str, Any] = {}
225
+ if from_file:
226
+ try:
227
+ body = json.loads(_read_stdin() if from_file == "-" else Path(from_file).read_text())
228
+ except (OSError, ValueError) as ex:
229
+ raise click.BadParameter(f"Could not read {from_file} as JSON: {ex}", param_hint="--from-file")
230
+ if not isinstance(body, dict):
231
+ raise click.BadParameter("The file must hold a JSON object.", param_hint="--from-file")
232
+ body.update(_collect(name=name, github_org=github_org, clouds=clouds, classes=classes_,
233
+ max_cost_per_hour=max_cost_per_hour, image_by_class=image_by_class,
234
+ egress_by_class=egress_by_class, class_headroom=class_headroom,
235
+ spot_policy=spot_policy, cpu_units_requests=cpu_units_requests))
236
+ missing = [f for f in ("name", "github_org", "clouds", "classes", "max_cost_per_hour") if f not in body]
237
+ if missing:
238
+ raise click.UsageError("Missing: " + ", ".join("--" + m.replace("_", "-") if m != "name" else "NAME"
239
+ for m in missing))
240
+ key = idempotency_key or str(uuid.uuid4())
241
+
242
+ def go(org):
243
+ try:
244
+ return api.call("POST", org, "/pools", body=body, headers={"Idempotency-Key": key}, scope=WRITE)
245
+ except api.ApiError as ex:
246
+ if ex.status is None or ex.status >= 500:
247
+ # The pool may exist already: the same key and body make the retry safe.
248
+ raise _fail(ex, f"{ex} To retry safely, run the same command with --idempotency-key {key}")
249
+ raise _fail(ex)
250
+ made = _run(ctx, go)
251
+ if as_json:
252
+ return _dump(made)
253
+ click.echo(f"Pool {made.get('pool_id')} ({made.get('name')}) created. Status: {_status(made)}")
254
+ click.echo(f"Next: mctl pool show {made.get('pool_id')}")
255
+
256
+
257
+ @pool.command()
258
+ @click.argument("pool_ref", metavar="POOL")
259
+ @click.option("--spot-policy", default=None, help="Spot policy name")
260
+ @click.option("--cpu-units-requests/--no-cpu-units-requests", default=None,
261
+ help="Runner pods request cpu-units instead of cpu")
262
+ @click.option("--max-cost-per-hour", type=float, default=None, help="Cost ceiling in USD per hour")
263
+ @click.option("--classes", "classes_", multiple=True, callback=_classes_list,
264
+ help="Runner classes of the pool; repeat or separate with commas")
265
+ @click.option("--clouds", callback=_json_value, default=None,
266
+ help="JSON list of {type, regions, exclude_regions} for clouds the pool has, or @file.json")
267
+ @json_option
268
+ @click.pass_context
269
+ def update(ctx, pool_ref, spot_policy, cpu_units_requests, max_cost_per_hour, classes_, clouds, as_json):
270
+ """Change a pool. Only the options you give are sent."""
271
+ body = _collect(spot_policy=spot_policy, cpu_units_requests=cpu_units_requests,
272
+ max_cost_per_hour=max_cost_per_hour, classes=classes_, clouds=clouds)
273
+ if not body:
274
+ raise click.UsageError("Give at least one option to change.")
275
+
276
+ def go(org):
277
+ return _call(org, "PATCH", f"/pools/{resolve(org, pool_ref)}", WRITE, body=body)
278
+ data = _run(ctx, go)
279
+ if as_json:
280
+ return _dump(data)
281
+ click.echo(f"Pool {data.get('pool_id')} updated ({', '.join(sorted(body))}).")
282
+
283
+
284
+ @pool.command()
285
+ @click.argument("pool_ref", metavar="POOL")
286
+ @json_option
287
+ @click.pass_context
288
+ def hibernate(ctx, pool_ref, as_json):
289
+ """Pause a pool: remove its nodes and stop the control plane. The pool keeps its name."""
290
+ def go(org):
291
+ return _call(org, "POST", f"/pools/{resolve(org, pool_ref)}/hibernate", ADMIN)
292
+ data = _run(ctx, go)
293
+ if as_json:
294
+ return _dump(data)
295
+ click.echo(f"Pool {data.get('pool_id')}: {_status(data)}. Resume it with: mctl pool wake {data.get('pool_id')}")
296
+
297
+
298
+ def wait_for_wake(fetch: Callable[[], Dict[str, Any]], *, timeout_s: int,
299
+ interval_s: int = 10) -> Tuple[int, str, Dict[str, Any]]:
300
+ """Poll the pool until it is ready (exit 0), deleted meanwhile (exit 6) or ``timeout_s`` passed (exit 5).
301
+
302
+ A poll without an answer or with a 5xx is retried until the timeout; other errors raise. Progress goes to
303
+ stderr, so stdout holds only the result."""
304
+ waited = 0
305
+ p: Dict[str, Any] = {}
306
+ while True:
307
+ try:
308
+ p = fetch()
309
+ except api.ApiError as ex:
310
+ if ex.status is not None and ex.status < 500:
311
+ raise
312
+ click.echo(f"Check failed ({ex}). Retrying.", err=True)
313
+ else:
314
+ status = p.get("status")
315
+ if status in UP:
316
+ return EXIT_OK, f"Ready: pool {p.get('pool_id')} is {status}.", p
317
+ if status in GONE:
318
+ return EXIT_DELETED, f"Pool {p.get('pool_id')} is {status}. It cannot wake.", p
319
+ if waited >= timeout_s:
320
+ return EXIT_TIMEOUT, (f"Timed out after {waited}s. Last status: {_status(p) if p else 'unknown'}. "
321
+ f"Check later with: mctl pool show {p.get('pool_id', '')}".rstrip()), p
322
+ step = max(1, min(interval_s, timeout_s - waited))
323
+ if p:
324
+ click.echo(f"Status {_status(p)}. Checking again in {step}s ...", err=True)
325
+ _sleep(step)
326
+ waited += step
327
+
328
+
329
+ @pool.command()
330
+ @click.argument("pool_ref", metavar="POOL")
331
+ @click.option("-w", "--wait", is_flag=True, help="Wait until the pool is ready")
332
+ @click.option("-t", "--timeout", default="15m", show_default=True, callback=duration_option,
333
+ help="How long to wait with --wait (90, 90s, 15m, 1h)")
334
+ @click.option("--interval", default="10s", show_default=True, callback=interval_option,
335
+ help="Time between checks with --wait (90, 90s, 15m, 1h)")
336
+ @json_option
337
+ @click.pass_context
338
+ def wake(ctx, pool_ref, wait, timeout, interval, as_json):
339
+ """Wake a hibernated pool. Exit 0 ready, 5 --wait timeout, 6 pool deleted, 1 other error."""
340
+ def go(org):
341
+ pool_id = resolve(org, pool_ref)
342
+ data = _call(org, "POST", f"/pools/{pool_id}/wake", ADMIN)
343
+ if as_json and not wait:
344
+ _dump(data)
345
+ return EXIT_OK
346
+ click.echo(f"Pool {pool_id}: {_status(data)}.", err=as_json)
347
+ if not wait:
348
+ click.echo(f"Wait for it with: mctl pool wake {pool_id} --wait")
349
+ return EXIT_OK
350
+ try:
351
+ code, text, last = wait_for_wake(lambda: api.call("GET", org, f"/pools/{pool_id}", scope=READ),
352
+ timeout_s=timeout, interval_s=interval)
353
+ except api.ApiError as ex:
354
+ raise _fail(ex)
355
+ if as_json:
356
+ click.echo(text, err=True)
357
+ if last:
358
+ _dump(last)
359
+ else:
360
+ click.echo(text, err=code != EXIT_OK)
361
+ return code
362
+ sys.exit(_run(ctx, go))
363
+
364
+
365
+ @pool.command()
366
+ @click.argument("pool_ref", metavar="POOL")
367
+ @click.option("-y", "--yes", is_flag=True, help="Delete without asking")
368
+ @json_option
369
+ @click.pass_context
370
+ def delete(ctx, pool_ref, yes, as_json):
371
+ """Delete a pool for good: its nodes stop and are removed. To pause it instead, use: mctl pool hibernate."""
372
+ if not yes and not _stdin_is_tty():
373
+ click.echo("Error: Refusing to delete without confirmation. Run again with --yes.", err=True)
374
+ sys.exit(exitcodes.ERROR)
375
+
376
+ def go(org):
377
+ pool_id = resolve(org, pool_ref)
378
+ if not yes and not click.confirm(
379
+ f"This deletes pool {pool_ref} and stops its nodes. It cannot be undone "
380
+ f"(to pause it, use: mctl pool hibernate {pool_ref}). Delete it?", default=False):
381
+ click.echo("Nothing deleted.", err=True)
382
+ return exitcodes.ERROR
383
+ data = _call(org, "DELETE", f"/pools/{pool_id}", ADMIN)
384
+ if as_json:
385
+ _dump(data)
386
+ else:
387
+ click.echo(f"Pool {pool_id}: {_status(data)}. The nodes are removed in the background.")
388
+ return 0
389
+ sys.exit(_run(ctx, go))
390
+
391
+
392
+ @pool.command()
393
+ @click.argument("pool_ref", metavar="POOL")
394
+ @json_option
395
+ @no_headers_option
396
+ @click.pass_context
397
+ def cluster(ctx, pool_ref, as_json, no_headers):
398
+ """Show the cluster of a pool and its nodes."""
399
+ def go(org):
400
+ return _call(org, "GET", f"/pools/{resolve(org, pool_ref)}/cluster", READ)
401
+ data = _run(ctx, go)
402
+ if as_json:
403
+ return _dump(data)
404
+ click.echo(f"Pool {data.get('pool_id')} ({data.get('pool_name')}): {_status(data)}")
405
+ click.echo(f"Nodes: {data.get('node_count')}. Cost per hour: {_money(data.get('current_cost_per_hour'))} "
406
+ f"of {_money(data.get('max_cost_per_hour'))} allowed.")
407
+ nodes = data.get("nodes") or []
408
+ if nodes:
409
+ click.echo(_table(("NODE", "SERVER TYPE", "CLOUD", "REGION", "CAPACITY", "STATUS", "USD/H"), [
410
+ (str(n.get("name")), str(n.get("server_type") or "-"), str(n.get("cloud") or "-"),
411
+ str(n.get("region") or "-"), str(n.get("capacity") or "-"), str(n.get("status")),
412
+ _money(n.get("price_per_hour"))) for n in nodes], not no_headers))
413
+
414
+
415
+ def _rotation_text(r: Dict[str, Any]) -> str:
416
+ steps = r.get("steps") or {}
417
+ out = f"Rotation {r.get('id')}: {r.get('status')}"
418
+ if r.get("message"):
419
+ out += f" ({r['message']})"
420
+ done = ", ".join(f"{k} {v}" for k, v in steps.items())
421
+ return out + (f"\nSteps: {done}" if done else "")
422
+
423
+
424
+ def _rotation(ctx, pool_ref, method, as_json):
425
+ def go(org):
426
+ return _call(org, method, f"/pools/{resolve(org, pool_ref)}/rotate-credentials", ADMIN)
427
+ data = _run(ctx, go)
428
+ if as_json:
429
+ return _dump(data)
430
+ click.echo(_rotation_text(data))
431
+
432
+
433
+ @pool.command(name="rotate-credentials")
434
+ @click.argument("pool_ref", metavar="POOL")
435
+ @click.option("--status", "only_status", is_flag=True, hidden=True,
436
+ help="Deprecated: use 'mctl pool rotation-status POOL'")
437
+ @json_option
438
+ @click.pass_context
439
+ def rotate_credentials(ctx, pool_ref, only_status, as_json):
440
+ """Start a rotation of the pool's credentials. To see its progress, use: mctl pool rotation-status."""
441
+ if only_status:
442
+ click.echo(f"Note: --status is deprecated. Use: mctl pool rotation-status {pool_ref}", err=True)
443
+ _rotation(ctx, pool_ref, "GET" if only_status else "POST", as_json)
444
+ if not only_status and not as_json:
445
+ click.echo(f"Check progress with: mctl pool rotation-status {pool_ref}")
446
+
447
+
448
+ @pool.command(name="rotation-status")
449
+ @click.argument("pool_ref", metavar="POOL")
450
+ @json_option
451
+ @click.pass_context
452
+ def rotation_status(ctx, pool_ref, as_json):
453
+ """Show the latest rotation of the pool's credentials."""
454
+ _rotation(ctx, pool_ref, "GET", as_json)