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,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)
|
multicloud_cli/config.py
ADDED
|
@@ -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()
|