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,401 @@
1
+ """``mctl cloud-account``: connect an AWS account to an organization.
2
+
3
+ These commands call the Multicloud API (``/api/v1/orgs/{slug}/cloud-accounts``) with an API key that
4
+ holds the ``infra`` scope. The key comes from
5
+ ``mctl auth`` (or ``MULTICLOUD_API_KEY``); org and URL from ``--org``/``MULTICLOUD_ORG``/``MULTICLOUD_API_URL``
6
+ or the saved config.
7
+ The connect token is printed only by ``token`` (to stderr). ``apply`` hands it to tofu/terraform through the
8
+ child environment variable ``TF_VAR_multicloud_connect_token``: never argv, never a file.
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Optional
19
+
20
+ import click
21
+ import requests # noqa: F401 (tests patch cloud_account.requests.request)
22
+
23
+ from . import api, exitcodes
24
+ from .output import run
25
+ from .duration import duration_option, interval_option
26
+ from .orgopt import org_group
27
+
28
+ TOKEN_VARIABLE = "multicloud_connect_token"
29
+ TOKEN_ENV = "TF_VAR_" + TOKEN_VARIABLE
30
+ TF_FILE = "multicloud.tf"
31
+ PLAN_PLACEHOLDER = "plan-placeholder"
32
+ _sleep = time.sleep # tests patch this name
33
+
34
+
35
+ def _stdin_is_tty() -> bool:
36
+ return sys.stdin.isatty()
37
+
38
+
39
+ class CloudAccountError(exitcodes.CliError):
40
+ pass
41
+
42
+
43
+ def call(method: str, org: Optional[str], tail: str = "", *, body: Optional[Dict[str, Any]] = None,
44
+ headers: Optional[Dict[str, str]] = None, accept=(200, 201, 202)) -> Dict[str, Any]:
45
+ """One API call. Returns the JSON body with ``_status`` added; raises on other statuses."""
46
+ try:
47
+ return api.call(method, org, "/cloud-accounts" + tail, body=body, headers=headers, accept=accept,
48
+ pass_codes=("VERIFY_FAILED",))
49
+ except api.ApiError as ex:
50
+ raise CloudAccountError(str(ex), ex.exit_code)
51
+
52
+
53
+ def describe_verify(answer: Dict[str, Any]) -> str:
54
+ """The text for a connect or verify answer, including the missing permissions of a 422."""
55
+ detail = answer.get("detail")
56
+ if isinstance(detail, dict) and detail.get("code") == "VERIFY_FAILED":
57
+ info = detail.get("details") or {}
58
+ lines = [f"Verification failed: {detail.get('message', '')}".rstrip()]
59
+ if info.get("reason"):
60
+ lines.append(f"Reason: {info['reason']}")
61
+ missing = info.get("missing") or []
62
+ if missing:
63
+ lines.append("Missing permissions:")
64
+ lines += [f" {m}" for m in missing]
65
+ return "\n".join(lines)
66
+ status = answer.get("status", "unknown")
67
+ out = f"Cloud account {answer.get('id', '')}: {status}"
68
+ if answer.get("status_reason"):
69
+ out += f" ({answer['status_reason']})"
70
+ return out
71
+
72
+
73
+ def terraform_outputs() -> Dict[str, Any]:
74
+ """``role_arn``, ``policy_version`` and ``module_version`` from ``tofu output -json`` in the cwd
75
+ (``terraform`` when ``tofu`` is not on the PATH, as for ``apply``)."""
76
+ try:
77
+ tool = next((t for t in ("tofu", "terraform") if shutil.which(t)), None) # the order of apply
78
+ if tool is None:
79
+ raise CloudAccountError("Neither 'tofu' nor 'terraform' is on the PATH.")
80
+ done = subprocess.run([tool, "output", "-json"], capture_output=True, text=True, check=True)
81
+ outputs = json.loads(done.stdout)
82
+ except (OSError, subprocess.CalledProcessError, ValueError) as ex:
83
+ raise CloudAccountError(f"Could not read 'terraform output -json' in this directory: {ex}")
84
+ values = {}
85
+ for name in ("role_arn", "policy_version", "module_version"):
86
+ item = outputs.get(name)
87
+ if not isinstance(item, dict) or item.get("value") in (None, ""):
88
+ raise CloudAccountError(f"The Terraform output '{name}' is missing. Run terraform apply first.")
89
+ values[name] = item["value"]
90
+ try:
91
+ values["policy_version"] = int(values["policy_version"])
92
+ except (TypeError, ValueError):
93
+ raise CloudAccountError("The Terraform output 'policy_version' is not a number.")
94
+ values["module_version"] = str(values["module_version"])
95
+ return values
96
+
97
+
98
+ def format_table(accounts, headers: bool = True) -> str:
99
+ rows = ([("ID", "CLOUD", "STATUS", "ACCOUNT")] if headers else []) + [
100
+ (str(a.get("id")), str(a.get("cloud")), str(a.get("status")), str(a.get("expected_native_id")))
101
+ for a in accounts]
102
+ if not rows:
103
+ return ""
104
+ widths = [max(len(r[i]) for r in rows) for i in range(4)]
105
+ return "\n".join(" ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip() for r in rows)
106
+
107
+
108
+ def _dump(data: Dict[str, Any]) -> None:
109
+ data = dict(data)
110
+ data.pop("_status", None)
111
+ click.echo(json.dumps(data, indent=2, sort_keys=True))
112
+
113
+
114
+ json_option = click.option("--json", "as_json", is_flag=True, help="Print the raw JSON")
115
+
116
+
117
+ def _tf_file_exists():
118
+ click.echo(f"Error: {TF_FILE} exists in this directory. Move it, or run again with --force to "
119
+ f"overwrite it. A new file holds a new external id, and 'terraform apply' would cut "
120
+ f"off a connected account.", err=True)
121
+ sys.exit(1)
122
+
123
+
124
+ @click.group(name="cloud-account", cls=org_group("cloud_account_org"))
125
+ @click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG)")
126
+ @click.pass_context
127
+ def cloud_account(ctx, org):
128
+ """Connect a customer AWS account (sign in first: mctl auth login)."""
129
+ ctx.ensure_object(dict)
130
+ ctx.obj["cloud_account_org"] = org
131
+
132
+
133
+ def _run(ctx, fn):
134
+ return run(ctx.obj.get("cloud_account_org"), fn, (CloudAccountError,))
135
+
136
+
137
+ @cloud_account.command()
138
+ @click.option("--cloud", required=True, type=click.Choice(["AWS"], case_sensitive=False))
139
+ @click.option("--account", required=True, help="The 12-digit AWS account id")
140
+ @click.option("--region", "regions", multiple=True, help="Region to allow; repeat for several")
141
+ @click.option("--force", is_flag=True, help="Overwrite an existing multicloud.tf")
142
+ @json_option
143
+ @click.pass_context
144
+ def create(ctx, cloud, account, regions, force, as_json):
145
+ """Register an account and write multicloud.tf. No token is printed.
146
+
147
+ An existing multicloud.tf is kept: a new external id in it would cut off a connected account."""
148
+ if Path(TF_FILE).exists() and not force:
149
+ _tf_file_exists()
150
+ body = {"cloud": cloud.upper(), "expected_native_id": account}
151
+ if regions:
152
+ body["regions"] = list(regions)
153
+ made = _run(ctx, lambda org: call("POST", org, body=body))
154
+ try:
155
+ with open(TF_FILE, "w" if force else "x") as f:
156
+ f.write(made["terraform"])
157
+ except FileExistsError: # created after the check above
158
+ _tf_file_exists()
159
+ acct_id = made["account"]["id"]
160
+ if as_json: # the created account; the Terraform file is in multicloud.tf
161
+ _dump(made["account"])
162
+ say = lambda text: click.echo(text, err=as_json) # noqa: E731
163
+ say(f"Cloud account {acct_id} created for AWS account {account}. Wrote {TF_FILE}.")
164
+ say(f"Next: mctl cloud-account apply {acct_id}")
165
+ say(f"By hand: mctl cloud-account token {acct_id}, then tofu apply (or terraform apply).")
166
+
167
+
168
+ @cloud_account.command()
169
+ @click.argument("account_id", type=int)
170
+ @click.pass_context
171
+ def token(ctx, account_id):
172
+ """Issue a new connect token (the old one stops working). The token goes to stderr only."""
173
+ made = _run(ctx, lambda org: call("POST", org, f"/{account_id}/connect-token"))
174
+ click.echo(f"export {TOKEN_ENV}={made['connect_token']}", err=True)
175
+ click.echo(f"New connect token for cloud account {account_id}, valid until "
176
+ f"{made.get('connect_token_expires_at')}. The old token no longer works. "
177
+ f"Run the export line in this shell, then tofu apply.")
178
+
179
+
180
+ @cloud_account.command(name="list")
181
+ @json_option
182
+ @click.option("-q", "--quiet", is_flag=True, help="Print only the IDs, one per line")
183
+ @click.option("--no-headers", is_flag=True, help="Leave the header line out of the table")
184
+ @click.pass_context
185
+ def list_(ctx, as_json, quiet, no_headers):
186
+ """List the cloud accounts of the organization."""
187
+ data = _run(ctx, lambda org: call("GET", org))
188
+ if as_json:
189
+ return _dump(data)
190
+ if quiet:
191
+ for a in data.get("cloud_accounts", []):
192
+ click.echo(a.get("id"))
193
+ return
194
+ click.echo(format_table(data.get("cloud_accounts", []), not no_headers))
195
+
196
+
197
+ def _readable(data: Dict[str, Any]) -> str:
198
+ status = str(data.get("status", "unknown"))
199
+ if status == "verified":
200
+ status += " (Ready)"
201
+ regions = data.get("regions")
202
+ lines = [
203
+ f"Cloud account {data.get('id')} ({data.get('cloud')})",
204
+ f"Status: {status}",
205
+ ("Account", data.get("native_id") or data.get("expected_native_id")),
206
+ ("Regions", ", ".join(regions) if regions else "all"),
207
+ ("Role", data.get("role_arn")),
208
+ ("Policy version", data.get("policy_version")),
209
+ ("Module version", data.get("module_version")),
210
+ ("Connected", data.get("connected_on")),
211
+ ("Verified", data.get("verified_on")),
212
+ ("Last check", data.get("last_probe_on")),
213
+ ("Reason", data.get("status_reason")),
214
+ ]
215
+ return "\n".join(l if isinstance(l, str) else f"{l[0]}: {l[1]}"
216
+ for l in lines if isinstance(l, str) or l[1] not in (None, ""))
217
+
218
+
219
+ @cloud_account.command()
220
+ @click.argument("account_id", type=int)
221
+ @json_option
222
+ @click.pass_context
223
+ def show(ctx, account_id, as_json):
224
+ """Show one cloud account."""
225
+ data = _run(ctx, lambda org: call("GET", org, f"/{account_id}"))
226
+ data.pop("_status", None)
227
+ if as_json:
228
+ click.echo(json.dumps(data, indent=2, sort_keys=True))
229
+ else:
230
+ click.echo(_readable(data))
231
+
232
+
233
+ def _verify_loop(org, account_id: int, wait: bool, timeout: int, interval: int, as_json: bool = False) -> int:
234
+ """Verify; with ``wait`` repeat while the answer is 202. Returns the exit code.
235
+
236
+ Notes about waiting go to stderr. With ``as_json`` the last answer is the only thing on stdout."""
237
+ say = lambda text, err=as_json: click.echo(text, err=err) # noqa: E731
238
+ note = lambda text: click.echo(text, err=True) # noqa: E731
239
+ waited = 0
240
+ while True:
241
+ data = call("POST", org, f"/{account_id}/verify")
242
+ status = data.pop("_status", 200)
243
+ if status == 422:
244
+ if as_json:
245
+ _dump(data)
246
+ say(describe_verify(data))
247
+ say(f"Fix: apply the module again (mctl cloud-account apply {account_id}), then verify.")
248
+ return exitcodes.ERROR
249
+ if status != 202 and data.get("status") != "verified":
250
+ if as_json:
251
+ _dump(data)
252
+ say(f"Not ready: {describe_verify(data)}")
253
+ return exitcodes.ERROR
254
+ if status != 202:
255
+ if as_json:
256
+ _dump(data)
257
+ native = data.get("native_id") or data.get("expected_native_id")
258
+ say(f"Ready: {data.get('cloud', 'AWS')} account {native} is connected "
259
+ f"(policy version {data.get('policy_version')}).")
260
+ return exitcodes.OK
261
+ note(f"Pending: the role is not usable yet ({data.get('status_reason') or 'no reason given'}). "
262
+ f"AWS can take up to 15 minutes to apply IAM changes.")
263
+ if not wait or waited >= timeout:
264
+ if as_json:
265
+ _dump(data)
266
+ if wait:
267
+ note(f"Still pending after {waited}s. Run again later: "
268
+ f"mctl cloud-account verify {account_id} --wait")
269
+ return exitcodes.PENDING
270
+ step = max(1, min(interval, timeout - waited))
271
+ note(f"Checking again in {step}s ...")
272
+ _sleep(step)
273
+ waited += step
274
+
275
+
276
+ @cloud_account.command()
277
+ @click.argument("account_id", type=int)
278
+ @click.option("-w", "--wait", is_flag=True, help="Check again while the role is not usable yet")
279
+ @click.option("-t", "--timeout", default="15m", show_default=True, callback=duration_option,
280
+ help="How long to wait with --wait (90, 90s, 15m, 1h)")
281
+ @click.option("--interval", default="30s", show_default=True, callback=interval_option,
282
+ help="Time between checks with --wait (90, 90s, 15m, 1h)")
283
+ @json_option
284
+ @click.pass_context
285
+ def verify(ctx, account_id, wait, timeout, interval, as_json):
286
+ """Probe the account again. Exit 1 when verification fails, 5 while pending."""
287
+ code = _run(ctx, lambda org: _verify_loop(org, account_id, wait, timeout, interval, as_json))
288
+ sys.exit(code)
289
+
290
+
291
+ def _find_tool(requested: Optional[str]) -> str:
292
+ if requested:
293
+ if not shutil.which(requested):
294
+ raise CloudAccountError(f"'{requested}' is not on the PATH.")
295
+ return requested
296
+ tool = next((t for t in ("tofu", "terraform") if shutil.which(t)), None)
297
+ if tool is None:
298
+ raise CloudAccountError("Neither 'tofu' nor 'terraform' is on the PATH. Install OpenTofu or Terraform.")
299
+ return tool
300
+
301
+
302
+ def _tool_run(tool: str, args, cwd: str, env: Dict[str, str]) -> int:
303
+ """Run the tool with inherited stdout/stderr. The environment carries the only copy of any token."""
304
+ try:
305
+ return subprocess.run([tool, *args], cwd=cwd, env=env).returncode
306
+ except OSError as ex:
307
+ raise CloudAccountError(f"Could not run {tool}: {ex}")
308
+
309
+
310
+ @cloud_account.command()
311
+ @click.argument("account_id", type=int)
312
+ @click.option("--dir", "directory", default=".", type=click.Path(file_okay=False), help="Folder with multicloud.tf")
313
+ @click.option("--tool", type=click.Choice(["tofu", "terraform"]), default=None, help="Default: tofu, then terraform")
314
+ @click.option("--plan-only", is_flag=True, help="Show the plan and stop; no token is issued")
315
+ @click.option("-y", "--yes", is_flag=True, help="Apply without asking")
316
+ @click.option("-t", "--timeout", default="15m", show_default=True, callback=duration_option,
317
+ help="How long to wait for the account to verify (90, 90s, 15m, 1h)")
318
+ @click.option("--interval", default="30s", show_default=True, callback=interval_option,
319
+ help="Time between checks (90, 90s, 15m, 1h)")
320
+ @click.pass_context
321
+ def apply(ctx, account_id, directory, tool, plan_only, yes, timeout, interval):
322
+ """Plan, apply multicloud.tf with a fresh connect token, then wait until the account verifies.
323
+
324
+ The token reaches tofu/terraform only through the environment of that one process."""
325
+ tf = Path(directory) / TF_FILE
326
+ if not tf.is_file():
327
+ click.echo(f"Error: {tf} does not exist. Run: mctl cloud-account create", err=True)
328
+ sys.exit(1)
329
+ found = re.search(r"account_row_id\s*=\s*(\d+)", tf.read_text())
330
+ if found and int(found.group(1)) != account_id:
331
+ click.echo(f"Error: {TF_FILE} in {directory} is for cloud account {found.group(1)}, not {account_id}.",
332
+ err=True)
333
+ sys.exit(1)
334
+ tool = _run(ctx, lambda org: _find_tool(tool))
335
+ # Terraform, its providers and the module's curl see this environment: no API key, no old token,
336
+ # and no TF_LOG (a debug log could record the token).
337
+ base_env = {k: v for k, v in os.environ.items()
338
+ if k not in (TOKEN_ENV, "MULTICLOUD_API_KEY") and not k.startswith("TF_LOG")}
339
+
340
+ def step(args, env, what):
341
+ code = _run(ctx, lambda org: _tool_run(tool, args, directory, env))
342
+ if code != 0:
343
+ click.echo(f"Error: {tool} {what} failed (exit {code}).", err=True)
344
+ sys.exit(1)
345
+
346
+ step(["init", "-input=false"], base_env, "init")
347
+ click.echo("The plan uses a placeholder connect token. The real token is issued only for the apply.")
348
+ step(["plan", "-input=false"], {**base_env, TOKEN_ENV: PLAN_PLACEHOLDER}, "plan")
349
+ if plan_only:
350
+ click.echo(f"Plan only: no token issued. To apply: mctl cloud-account apply {account_id} --yes")
351
+ return
352
+ if not yes:
353
+ if not _stdin_is_tty():
354
+ click.echo("Error: Refusing to apply without confirmation. Review the plan, then run again "
355
+ "with --yes.", err=True)
356
+ sys.exit(1)
357
+ if not click.confirm("Apply this plan to your AWS account?", default=False):
358
+ click.echo("Nothing applied.", err=True)
359
+ sys.exit(exitcodes.ERROR)
360
+ made = _run(ctx, lambda org: call("POST", org, f"/{account_id}/connect-token"))
361
+ code = _run(ctx, lambda org: _tool_run(
362
+ tool, ["apply", "-input=false", "-auto-approve"], directory,
363
+ {**base_env, TOKEN_ENV: made["connect_token"]}))
364
+ if code != 0:
365
+ click.echo(f"Error: {tool} apply failed (exit {code}).", err=True)
366
+ try:
367
+ state = _run(ctx, lambda org: call("GET", org, f"/{account_id}"))
368
+ state.pop("_status", None)
369
+ click.echo(f"Account status after the failed apply: {describe_verify(state)}", err=True)
370
+ except SystemExit:
371
+ pass
372
+ sys.exit(1)
373
+ sys.exit(_run(ctx, lambda org: _verify_loop(org, account_id, True, timeout, interval)))
374
+
375
+
376
+ @cloud_account.command()
377
+ @click.argument("account_id", type=int)
378
+ @click.option("--from-terraform-output", "from_tf", is_flag=True,
379
+ help="Read role_arn, policy_version and module_version from 'terraform output -json' "
380
+ "(the only input; this is the default)")
381
+ @click.pass_context
382
+ def connect(ctx, account_id, from_tf):
383
+ """Report the role Terraform created. The token comes from TF_VAR_multicloud_connect_token."""
384
+ token = os.environ.get(TOKEN_ENV)
385
+ if not token:
386
+ click.echo(f"Error: set {TOKEN_ENV} to a connect token: run 'mctl cloud-account token {account_id}'.", err=True)
387
+ sys.exit(1)
388
+
389
+ def post(org):
390
+ return call("POST", org, f"/{account_id}/connect", body=terraform_outputs(),
391
+ headers={"X-Multicloud-Connect-Token": token})
392
+ _finish(_run(ctx, post))
393
+
394
+
395
+ def _finish(data: Dict[str, Any]) -> None:
396
+ status = data.pop("_status", 200)
397
+ click.echo(describe_verify(data))
398
+ if status == 422:
399
+ sys.exit(1)
400
+ if status == 202:
401
+ sys.exit(exitcodes.PENDING)
@@ -0,0 +1,114 @@
1
+ """Settings: the config directory, ``cli.json`` (org and API URL) and their precedence."""
2
+ import json
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+ from urllib.parse import urlparse
8
+
9
+ import click
10
+
11
+ DEFAULT_API_URL = "https://api.multicloud.io"
12
+ DEFAULT_ISSUER = "https://auth.multicloud.io/realms/multicloud"
13
+ _LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
14
+
15
+
16
+ class ConfigError(Exception):
17
+ pass
18
+
19
+
20
+ def config_dir() -> Path:
21
+ base = os.environ.get("XDG_CONFIG_HOME")
22
+ root = Path(base) if base else Path.home() / ".config"
23
+ return root / "multicloud"
24
+
25
+
26
+ _warned = set()
27
+
28
+
29
+ def warn_once(text: str) -> None:
30
+ """A warning on stderr, once per process: the mode checks run on every refresh."""
31
+ if text not in _warned:
32
+ _warned.add(text)
33
+ print(f"Warning: {text}", file=sys.stderr)
34
+
35
+
36
+ def ensure_private_dir(strict: bool = False) -> Path:
37
+ """Create the config directory with access for the user only, and narrow an existing one to 0700.
38
+ ``strict`` (the credentials file) raises when the mode cannot be set; otherwise a warning goes to stderr,
39
+ because the directory may be a link or belong to another account, and the lock and settings hold no secret."""
40
+ directory = config_dir()
41
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
42
+ try:
43
+ os.chmod(directory, 0o700)
44
+ except OSError as ex:
45
+ if strict:
46
+ raise
47
+ warn_once(f"cannot set {directory} to 0700 ({type(ex).__name__}).")
48
+ return directory
49
+
50
+
51
+ def config_file() -> Path:
52
+ return config_dir() / "cli.json"
53
+
54
+
55
+ def load() -> Dict[str, Any]:
56
+ try:
57
+ data = json.loads(config_file().read_text())
58
+ except (OSError, ValueError):
59
+ return {}
60
+ return data if isinstance(data, dict) else {}
61
+
62
+
63
+ def save(org: Optional[str] = None, api_url: Optional[str] = None, issuer: Optional[str] = None) -> None:
64
+ data = load()
65
+ if org:
66
+ data["org"] = org
67
+ if api_url:
68
+ data["api_url"] = api_url
69
+ if issuer:
70
+ data["issuer"] = issuer
71
+ ensure_private_dir()
72
+ config_file().write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
73
+
74
+
75
+ def _root_option(key: str) -> Optional[str]:
76
+ """The value of a root option (``mctl --org acme ...``) in the running command, or None."""
77
+ ctx = click.get_current_context(silent=True)
78
+ obj = ctx.find_root().obj if ctx is not None else None
79
+ return obj.get(key) if isinstance(obj, dict) else None
80
+
81
+
82
+ def get_org(option: Optional[str] = None) -> Optional[str]:
83
+ """--org after the group or subcommand (the most specific wins), then --org before the command,
84
+ then MULTICLOUD_ORG, then the config file."""
85
+ return option or _root_option("root_org") or os.environ.get("MULTICLOUD_ORG") or load().get("org") or None
86
+
87
+
88
+ def org_is_explicit(option: Optional[str] = None) -> bool:
89
+ """Whether the organization came from --org or MULTICLOUD_ORG, not from the saved config."""
90
+ return bool(option or _root_option("root_org") or os.environ.get("MULTICLOUD_ORG"))
91
+
92
+
93
+ def get_api_url() -> str:
94
+ """--api-url before the command, then MULTICLOUD_API_URL, then the config file, then the public API."""
95
+ url = (_root_option("root_api_url") or os.environ.get("MULTICLOUD_API_URL") or load().get("api_url")
96
+ or DEFAULT_API_URL)
97
+ return check_api_url(url)
98
+
99
+
100
+ def get_issuer() -> str:
101
+ """MULTICLOUD_ISSUER_URL, then the config file, then the public Keycloak realm."""
102
+ url = os.environ.get("MULTICLOUD_ISSUER_URL") or load().get("issuer") or DEFAULT_ISSUER
103
+ return check_api_url(url, "issuer URL")
104
+
105
+
106
+ def check_api_url(url: str, what: str = "API URL") -> str:
107
+ """The key or token travels in a header or a form, so the URL must be https (plain http only to this machine)."""
108
+ url = url.rstrip("/")
109
+ parsed = urlparse(url)
110
+ if parsed.scheme == "https" and parsed.hostname:
111
+ return url
112
+ if parsed.scheme == "http" and parsed.hostname in _LOCAL_HOSTS:
113
+ return url
114
+ raise ConfigError(f"The {what} must start with https:// (got {url!r}).")
@@ -0,0 +1,30 @@
1
+ """Durations for ``--timeout`` and ``--interval``: ``90``, ``90s``, ``15m`` or ``1h``."""
2
+ import re
3
+
4
+ import click
5
+
6
+ _DURATION = re.compile(r"^\s*(\d+)\s*([smh]?)\s*$")
7
+ _UNITS = {"s": 1, "m": 60, "h": 3600}
8
+
9
+
10
+ def parse_duration(text: str) -> int:
11
+ """``90``, ``90s``, ``15m`` or ``1h`` in seconds."""
12
+ m = _DURATION.match(text or "")
13
+ if not m:
14
+ raise ValueError(f"Invalid duration '{text}'. Use a number with s, m or h, for example 15m.")
15
+ return int(m.group(1)) * _UNITS[m.group(2) or "s"]
16
+
17
+
18
+ def duration_option(ctx, param, value):
19
+ try:
20
+ return parse_duration(value)
21
+ except ValueError as ex:
22
+ raise click.BadParameter(str(ex))
23
+
24
+
25
+ def interval_option(ctx, param, value):
26
+ """Like ``duration_option``, and at least one second."""
27
+ seconds = duration_option(ctx, param, value)
28
+ if seconds < 1:
29
+ raise click.BadParameter("Must be at least 1 second.")
30
+ return seconds
@@ -0,0 +1,30 @@
1
+ """The exit codes of ``mctl``: one map, used by every command (see docs/reference.md).
2
+
3
+ 0 success. 1 error, refusal, or a declined prompt. 2 usage error (click's own). 3 not signed in, or the API
4
+ answered 401 or 403. 4 not found (API 404, unknown pool name). 5 not done yet: still pending, or a --wait
5
+ ran out of time. 6 gone: the pool is deleting or deleted. 130 Ctrl-C."""
6
+ from typing import Optional
7
+
8
+ OK, ERROR, USAGE, AUTH, NOT_FOUND, PENDING, GONE, INTERRUPTED = 0, 1, 2, 3, 4, 5, 6, 130
9
+ BROKEN_PIPE = 141 # 128 + SIGPIPE, as a shell shows a process that wrote into a closed pipe
10
+
11
+
12
+ def for_status(status: Optional[int]) -> int:
13
+ """The exit code for an HTTP status of the API (1 when there was none or it has no code of its own)."""
14
+ if status in (401, 403):
15
+ return AUTH
16
+ if status == 404:
17
+ return NOT_FOUND
18
+ return ERROR
19
+
20
+
21
+ class CliError(Exception):
22
+ """An error a command reports as ``Error: <text>`` and ends with ``exit_code``."""
23
+
24
+ def __init__(self, message: str, exit_code: int = ERROR):
25
+ super().__init__(message)
26
+ self.exit_code = exit_code
27
+
28
+
29
+ def of(ex: Exception) -> int:
30
+ return getattr(ex, "exit_code", ERROR)
multicloud_cli/main.py ADDED
@@ -0,0 +1,90 @@
1
+ """``mctl``: the customer command line."""
2
+ import os
3
+ import sys
4
+
5
+ import click
6
+
7
+ from . import __version__, exitcodes
8
+ from .auth import auth
9
+ from .catalog import catalog
10
+ from .cloud_account import cloud_account
11
+ from .pool import pool
12
+
13
+
14
+ def _broken_pipe() -> int:
15
+ """The reader of stdout left (``mctl pool list | head -1``). Point stdout at /dev/null, so the flush at exit
16
+ does not print a traceback, and give 141 (128 + SIGPIPE), the code a shell shows for this."""
17
+ try:
18
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
19
+ except (OSError, ValueError, AttributeError):
20
+ pass
21
+ return exitcodes.BROKEN_PIPE
22
+
23
+
24
+ class Root(click.Group):
25
+ def invoke(self, ctx):
26
+ # click itself turns EPIPE into exit 1, so this catches it first.
27
+ try:
28
+ return super().invoke(ctx)
29
+ except BrokenPipeError:
30
+ sys.exit(_broken_pipe())
31
+
32
+
33
+ @click.group(cls=Root, context_settings={"help_option_names": ["-h", "--help"]})
34
+ @click.version_option(__version__, prog_name="mctl")
35
+ @click.option("--org", default=None, help="Organization slug (env: MULTICLOUD_ORG)")
36
+ @click.option("--api-url", default=None, help="API URL (env: MULTICLOUD_API_URL)")
37
+ @click.pass_context
38
+ def mctl(ctx, org, api_url):
39
+ """Sign in to Multicloud, connect your cloud account and manage CI pools and find servers.
40
+
41
+ --org and --api-url work here and after a command. The one after the command wins, then the one
42
+ here, then the environment variable, then the saved setting."""
43
+ ctx.ensure_object(dict)
44
+ ctx.obj["root_org"] = org
45
+ ctx.obj["root_api_url"] = api_url
46
+
47
+
48
+ mctl.add_command(auth)
49
+ mctl.add_command(cloud_account)
50
+ mctl.add_command(pool)
51
+ mctl.add_command(catalog)
52
+
53
+
54
+ def _run() -> int:
55
+ # click's own error handling would make Ctrl-C "Aborted!" with exit 1, so it runs here.
56
+ try:
57
+ code = mctl(standalone_mode=False)
58
+ except click.exceptions.Abort as ex:
59
+ if isinstance(ex.__cause__, KeyboardInterrupt):
60
+ return exitcodes.INTERRUPTED
61
+ click.echo("Aborted!", err=True)
62
+ return exitcodes.ERROR
63
+ except click.ClickException as ex:
64
+ ex.show()
65
+ return ex.exit_code
66
+ except KeyboardInterrupt:
67
+ return exitcodes.INTERRUPTED
68
+ except BrokenPipeError:
69
+ return _broken_pipe()
70
+ except SystemExit as ex:
71
+ if ex.code is None:
72
+ return exitcodes.OK
73
+ if isinstance(ex.code, int):
74
+ return ex.code
75
+ click.echo(str(ex.code), err=True)
76
+ return exitcodes.ERROR
77
+ return code if isinstance(code, int) else exitcodes.OK
78
+
79
+
80
+ def main():
81
+ code = _run()
82
+ try:
83
+ sys.stdout.flush() # a closed pipe shows here, when the output was small and buffered
84
+ except BrokenPipeError:
85
+ code = _broken_pipe()
86
+ sys.exit(code)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()