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
multicloud_cli/auth.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""``mctl auth``: keep the API key or the SSO session in the OS keyring, or in a 0600 file when no keyring works.
|
|
2
|
+
|
|
3
|
+
No function here prints a key or a token. They leave this module only as return values
|
|
4
|
+
(get_key, fresh_access_token)."""
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Tuple
|
|
14
|
+
from urllib.parse import quote, urlparse
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from . import config, exitcodes, sso
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import fcntl
|
|
23
|
+
except ImportError: # Windows
|
|
24
|
+
fcntl = None
|
|
25
|
+
try:
|
|
26
|
+
import msvcrt
|
|
27
|
+
except ImportError:
|
|
28
|
+
msvcrt = None
|
|
29
|
+
|
|
30
|
+
SERVICE = "multicloud-api-key"
|
|
31
|
+
SSO_SERVICE = "multicloud-sso"
|
|
32
|
+
TIMEOUT_S = 30
|
|
33
|
+
REFRESH_SKEW_S = 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def account(org: str) -> str:
|
|
41
|
+
return f"{org}/infra"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def credentials_file() -> Path:
|
|
45
|
+
return config.config_dir() / "credentials"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _keyring():
|
|
49
|
+
"""The keyring module, or None when it has no usable backend."""
|
|
50
|
+
try:
|
|
51
|
+
import keyring
|
|
52
|
+
from keyring.backends import fail
|
|
53
|
+
backend = keyring.get_keyring()
|
|
54
|
+
# keyrings.alt holds plaintext or weakly encrypted files: the 0600 file is no worse and is visible.
|
|
55
|
+
if isinstance(backend, fail.Keyring) or type(backend).__module__.startswith("keyrings.alt"):
|
|
56
|
+
return None
|
|
57
|
+
return keyring
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_file(path: Path, acct: str) -> Optional[str]:
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return None
|
|
65
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
66
|
+
# Windows has no POSIX modes (chmod only sets read-only): the profile folder ACL protects the file there.
|
|
67
|
+
if os.name != "nt" and mode & 0o077:
|
|
68
|
+
raise AuthError(f"{path} can be read by other users (mode {mode:o}). Run: chmod 600 {path}")
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(path.read_text())
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
raise AuthError(f"Could not read {path}.")
|
|
73
|
+
key = data.get(acct) if isinstance(data, dict) else None
|
|
74
|
+
return key or None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_secret(service: str, acct: str) -> Tuple[Optional[str], Optional[str]]:
|
|
78
|
+
"""(value, source): the OS keyring first, then the credentials file."""
|
|
79
|
+
ring = _keyring()
|
|
80
|
+
if ring is not None:
|
|
81
|
+
try:
|
|
82
|
+
value = ring.get_password(service, acct)
|
|
83
|
+
except Exception:
|
|
84
|
+
value = None
|
|
85
|
+
if value:
|
|
86
|
+
return value, "OS keyring"
|
|
87
|
+
path = credentials_file()
|
|
88
|
+
value = _read_file(path, acct)
|
|
89
|
+
if value:
|
|
90
|
+
return value, f"file {path}"
|
|
91
|
+
return None, None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_key(org: str) -> Tuple[Optional[str], Optional[str]]:
|
|
95
|
+
"""(key, source). Order: environment, OS keyring, credentials file."""
|
|
96
|
+
key = os.environ.get("MULTICLOUD_API_KEY")
|
|
97
|
+
if key:
|
|
98
|
+
return key, "environment"
|
|
99
|
+
return _load_secret(SERVICE, account(org))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_file(acct: str, key: Optional[str]) -> bool:
|
|
103
|
+
"""Set (or remove, when key is None) one entry. Returns whether an entry was present or written."""
|
|
104
|
+
path = credentials_file()
|
|
105
|
+
data = {}
|
|
106
|
+
if path.exists():
|
|
107
|
+
_read_file(path, acct) # refuses a loose mode
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(path.read_text())
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
data = {}
|
|
112
|
+
had = acct in data
|
|
113
|
+
if key is None:
|
|
114
|
+
if not had:
|
|
115
|
+
return False
|
|
116
|
+
del data[acct]
|
|
117
|
+
else:
|
|
118
|
+
data[acct] = key
|
|
119
|
+
config.ensure_private_dir(strict=True)
|
|
120
|
+
# A temp file and a rename: a crash or a second login never leaves a cut-off file.
|
|
121
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
122
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
123
|
+
try:
|
|
124
|
+
with os.fdopen(fd, "w") as f:
|
|
125
|
+
f.write(json.dumps(data))
|
|
126
|
+
os.replace(tmp, path)
|
|
127
|
+
finally:
|
|
128
|
+
if tmp.exists():
|
|
129
|
+
tmp.unlink()
|
|
130
|
+
os.chmod(path, 0o600)
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _store_secret(service: str, acct: str, value: str) -> str:
|
|
135
|
+
"""Store one secret. Returns where: 'OS keyring' or 'file <path>'."""
|
|
136
|
+
ring = _keyring()
|
|
137
|
+
if ring is not None:
|
|
138
|
+
try:
|
|
139
|
+
ring.set_password(service, acct, value)
|
|
140
|
+
return "OS keyring"
|
|
141
|
+
except Exception:
|
|
142
|
+
# The keyring is read first: an old entry there would hide the value in the file.
|
|
143
|
+
try:
|
|
144
|
+
ring.delete_password(service, acct)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
_write_file(acct, value)
|
|
148
|
+
return f"file {credentials_file()}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _delete_secret(service: str, acct: str) -> list:
|
|
152
|
+
"""Remove one secret from both places. Returns where it was removed from."""
|
|
153
|
+
removed = []
|
|
154
|
+
ring = _keyring()
|
|
155
|
+
if ring is not None:
|
|
156
|
+
try:
|
|
157
|
+
if ring.get_password(service, acct):
|
|
158
|
+
ring.delete_password(service, acct)
|
|
159
|
+
removed.append("OS keyring")
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
if _write_file(acct, None):
|
|
163
|
+
removed.append(f"file {credentials_file()}")
|
|
164
|
+
return removed
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def store_key(org: str, key: str) -> str:
|
|
168
|
+
"""Store the key. Returns where: 'OS keyring' or 'file <path>'."""
|
|
169
|
+
return _store_secret(SERVICE, account(org), key)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def delete_key(org: str) -> list:
|
|
173
|
+
"""Remove the key from both places. Returns where it was removed from."""
|
|
174
|
+
return _delete_secret(SERVICE, account(org))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def sso_account(issuer: str, part: str) -> str:
|
|
178
|
+
return f"{issuer}#{part}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def store_sso(issuer: str, tokens: sso.Tokens) -> str:
|
|
182
|
+
"""Store the refresh token, then the access token. Two entries: one keyring entry is small on some systems."""
|
|
183
|
+
where = _store_secret(SSO_SERVICE, sso_account(issuer, "refresh"), tokens.refresh)
|
|
184
|
+
_store_secret(SSO_SERVICE, sso_account(issuer, "access"), tokens.access)
|
|
185
|
+
return where
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_sso(issuer: str) -> Optional[sso.Tokens]:
|
|
189
|
+
refresh, _ = _load_secret(SSO_SERVICE, sso_account(issuer, "refresh"))
|
|
190
|
+
if not refresh:
|
|
191
|
+
return None
|
|
192
|
+
access, _ = _load_secret(SSO_SERVICE, sso_account(issuer, "access"))
|
|
193
|
+
return sso.Tokens(access=access or "", refresh=refresh)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def sso_source(issuer: str) -> Optional[str]:
|
|
197
|
+
return _load_secret(SSO_SERVICE, sso_account(issuer, "refresh"))[1]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def delete_sso(issuer: str) -> list:
|
|
201
|
+
removed = _delete_secret(SSO_SERVICE, sso_account(issuer, "refresh"))
|
|
202
|
+
for where in _delete_secret(SSO_SERVICE, sso_account(issuer, "access")):
|
|
203
|
+
if where not in removed:
|
|
204
|
+
removed.append(where)
|
|
205
|
+
return removed
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class SessionEnded(AuthError):
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@contextlib.contextmanager
|
|
213
|
+
def _refresh_lock():
|
|
214
|
+
"""One refresh at a time across mctl processes. Keycloak may rotate the refresh token, and a refresh token
|
|
215
|
+
used twice ends the whole session, so two processes must not refresh from the same stored token."""
|
|
216
|
+
directory = config.ensure_private_dir()
|
|
217
|
+
fd = os.open(directory / ".refresh.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o600)
|
|
218
|
+
with os.fdopen(fd, "a+") as f:
|
|
219
|
+
if hasattr(os, "fchmod"): # not on Windows
|
|
220
|
+
try:
|
|
221
|
+
os.fchmod(f.fileno(), 0o600)
|
|
222
|
+
except OSError as ex: # another account's lock file: it holds no secret, so lock anyway
|
|
223
|
+
config.warn_once(f"cannot set {directory / '.refresh.lock'} to 0600 ({type(ex).__name__}).")
|
|
224
|
+
if fcntl is not None:
|
|
225
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
226
|
+
elif msvcrt is not None: # pragma: no cover (Windows)
|
|
227
|
+
f.seek(0)
|
|
228
|
+
for attempt in range(6): # LK_LOCK itself gives up after about 10 s
|
|
229
|
+
try:
|
|
230
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
|
231
|
+
break
|
|
232
|
+
except OSError:
|
|
233
|
+
if attempt == 5:
|
|
234
|
+
raise AuthError("Another mctl is refreshing the sign-in. Try again in a minute.")
|
|
235
|
+
try:
|
|
236
|
+
yield
|
|
237
|
+
finally:
|
|
238
|
+
if fcntl is not None:
|
|
239
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
240
|
+
elif msvcrt is not None: # pragma: no cover (Windows)
|
|
241
|
+
f.seek(0)
|
|
242
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _valid(access: str) -> bool:
|
|
246
|
+
return bool(access) and sso.expires_at(access) - time.time() > REFRESH_SKEW_S
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def fresh_access_token(issuer: str, rejected: Optional[str] = None) -> str:
|
|
250
|
+
"""The access token of the stored SSO session; refreshed when it expires within a minute, or when it is
|
|
251
|
+
the ``rejected`` one (the API answered 401). The refresh response may hold a new refresh token
|
|
252
|
+
(rotation): it replaces the old one, and is written before the access token."""
|
|
253
|
+
tokens = load_sso(issuer)
|
|
254
|
+
if tokens is None:
|
|
255
|
+
raise SessionEnded("You are not signed in. Run: mctl auth login")
|
|
256
|
+
if _valid(tokens.access) and tokens.access != rejected:
|
|
257
|
+
return tokens.access
|
|
258
|
+
with _refresh_lock():
|
|
259
|
+
# Another process may have refreshed while this one waited for the lock.
|
|
260
|
+
tokens = load_sso(issuer)
|
|
261
|
+
if tokens is None:
|
|
262
|
+
raise SessionEnded("You are not signed in. Run: mctl auth login")
|
|
263
|
+
if _valid(tokens.access) and tokens.access != rejected:
|
|
264
|
+
return tokens.access
|
|
265
|
+
try:
|
|
266
|
+
new = sso.refresh(issuer, tokens.refresh)
|
|
267
|
+
except sso.SsoError as ex:
|
|
268
|
+
if ex.code == "invalid_grant":
|
|
269
|
+
raise SessionEnded("Your session ended. Run: mctl auth login (or mctl auth logout --sso to use a stored API key)")
|
|
270
|
+
raise AuthError(str(ex))
|
|
271
|
+
try:
|
|
272
|
+
store_sso(issuer, new)
|
|
273
|
+
except AuthError as ex:
|
|
274
|
+
# The old refresh token may be dead already (rotation): say that the session was not saved.
|
|
275
|
+
raise AuthError(f"{ex} The refreshed sign-in could not be saved. Run: mctl auth login")
|
|
276
|
+
return new.access
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_key(org: str, api_url: str, key: str) -> Tuple[bool, str]:
|
|
280
|
+
"""GET the list of cloud accounts with this key. Returns (ok, error text)."""
|
|
281
|
+
url = f"{api_url}/api/v1/orgs/{quote(org, safe='')}/cloud-accounts"
|
|
282
|
+
try:
|
|
283
|
+
r = requests.request("GET", url, timeout=TIMEOUT_S, headers={"X-API-Key": key},
|
|
284
|
+
allow_redirects=False)
|
|
285
|
+
except requests.RequestException as ex:
|
|
286
|
+
return False, f"Could not reach the API: {type(ex).__name__}"
|
|
287
|
+
if r.status_code == 200:
|
|
288
|
+
return True, ""
|
|
289
|
+
return False, f"The API refused the key (HTTP {r.status_code})."
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def need_org(option: Optional[str]) -> str:
|
|
293
|
+
org = config.get_org(option)
|
|
294
|
+
if not org:
|
|
295
|
+
click.echo("Error: name the organization with --org or MULTICLOUD_ORG.", err=True)
|
|
296
|
+
sys.exit(1)
|
|
297
|
+
return org
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _check_hosts(url: str, issuer: str) -> None:
|
|
301
|
+
for what, u in (("API", url), ("sign-in", issuer)):
|
|
302
|
+
host = urlparse(u).hostname or ""
|
|
303
|
+
if host not in ("localhost", "127.0.0.1", "::1") and host != "multicloud.io" \
|
|
304
|
+
and not host.endswith(".multicloud.io"):
|
|
305
|
+
click.echo(f"Warning: {u} is not a multicloud.io address. Your credentials go to that {what} host.", err=True)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@click.group()
|
|
309
|
+
def auth():
|
|
310
|
+
"""Sign in with your browser, or store an API key, and check the result."""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@auth.command()
|
|
314
|
+
@click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG, saved, or the one in your sign-in)")
|
|
315
|
+
@click.option("--api-url", default=None, help="API URL (default: MULTICLOUD_API_URL or saved)")
|
|
316
|
+
@click.option("--issuer-url", default=None, help="Keycloak realm URL (default: MULTICLOUD_ISSUER_URL, saved, or auth.multicloud.io)")
|
|
317
|
+
@click.option("--no-browser", is_flag=True, help="Sign in with a code on another device (for SSH and containers)")
|
|
318
|
+
@click.option("--api-key", "use_key", is_flag=True, help="Store an API key instead of signing in with SSO")
|
|
319
|
+
@click.option("--key-stdin", is_flag=True, help="Read the API key from one line of standard input (implies --api-key)")
|
|
320
|
+
def login(org, api_url, issuer_url, no_browser, use_key, key_stdin):
|
|
321
|
+
"""Sign in with SSO (browser), or with --api-key store a checked API key. Nothing secret is shown."""
|
|
322
|
+
try:
|
|
323
|
+
url = config.check_api_url(api_url) if api_url else config.get_api_url()
|
|
324
|
+
issuer = config.check_api_url(issuer_url, "issuer URL") if issuer_url else config.get_issuer()
|
|
325
|
+
except config.ConfigError as ex:
|
|
326
|
+
click.echo(f"Error: {ex} Nothing stored.", err=True)
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
if use_key or key_stdin:
|
|
329
|
+
_login_key(org, url, key_stdin)
|
|
330
|
+
return
|
|
331
|
+
_check_hosts(url, issuer)
|
|
332
|
+
try:
|
|
333
|
+
tokens = (sso.login_device if no_browser else sso.login_browser)(
|
|
334
|
+
issuer, lambda text: click.echo(text, err=True)) # the URL and code are for the user, not for a pipe
|
|
335
|
+
except sso.SsoError as ex:
|
|
336
|
+
click.echo(f"Error: {ex}", err=True)
|
|
337
|
+
sys.exit(1)
|
|
338
|
+
orgs = sso.orgs_in_token(tokens.access)
|
|
339
|
+
org = config.get_org(org) if org else None
|
|
340
|
+
if org and org not in orgs:
|
|
341
|
+
listed = ", ".join(orgs) or "none"
|
|
342
|
+
click.echo(f"Error: you are not a member of '{org}' (your organizations: {listed}). Nothing stored.", err=True)
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
probe = _probe(url, tokens.access, org or (orgs[0] if orgs else None))
|
|
345
|
+
if probe == 401:
|
|
346
|
+
click.echo("Error: the API did not accept this sign-in (HTTP 401). Nothing stored. "
|
|
347
|
+
"Ask Multicloud support whether the API accepts the 'multicloud-cli' client.", err=True)
|
|
348
|
+
sys.exit(exitcodes.AUTH)
|
|
349
|
+
if probe is not None and probe != 200:
|
|
350
|
+
click.echo(f"Warning: the API answered HTTP {probe} to a test call. Check with: mctl auth status", err=True)
|
|
351
|
+
try:
|
|
352
|
+
where = store_sso(issuer, tokens)
|
|
353
|
+
except AuthError as ex:
|
|
354
|
+
click.echo(f"Error: {ex} Nothing stored.", err=True)
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
config.save(org=org, api_url=api_url and url, issuer=issuer_url and issuer)
|
|
357
|
+
claims = sso.decode_claims(tokens.access)
|
|
358
|
+
who = claims.get("email") or claims.get("preferred_username") or "your account"
|
|
359
|
+
click.echo(f"Signed in as {who}. Session stored in {where}.")
|
|
360
|
+
if not orgs:
|
|
361
|
+
click.echo("Warning: your sign-in holds no organization. Ask an owner to add you.", err=True)
|
|
362
|
+
else:
|
|
363
|
+
click.echo(f"Organizations: {', '.join(orgs)}." + ("" if len(orgs) == 1 else " Name one with --org."))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _probe(url: str, access: str, org: Optional[str]) -> Optional[int]:
|
|
367
|
+
"""One read with the new token, before it is stored. The HTTP status, or None when there is no answer or no
|
|
368
|
+
organization to ask about (a network problem must not block a sign-in)."""
|
|
369
|
+
if not org:
|
|
370
|
+
return None
|
|
371
|
+
try:
|
|
372
|
+
r = requests.request("GET", f"{url}/api/v1/orgs/{quote(org, safe='')}/pool-classes", timeout=TIMEOUT_S,
|
|
373
|
+
headers={"Authorization": f"Bearer {access}"}, allow_redirects=False)
|
|
374
|
+
except requests.RequestException:
|
|
375
|
+
return None
|
|
376
|
+
return r.status_code
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _key_exit_code(error: str) -> int:
|
|
380
|
+
"""3 when the API refused the key (HTTP 401 or 403), else 1."""
|
|
381
|
+
m = re.search(r"refused the key \(HTTP (\d+)\)", error)
|
|
382
|
+
return exitcodes.for_status(int(m.group(1))) if m else exitcodes.ERROR
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _login_key(org, url, key_stdin):
|
|
386
|
+
org = need_org(org)
|
|
387
|
+
host = urlparse(url).hostname or ""
|
|
388
|
+
if host not in ("localhost", "127.0.0.1", "::1") and host != "multicloud.io" \
|
|
389
|
+
and not host.endswith(".multicloud.io"):
|
|
390
|
+
click.echo(f"Warning: {url} is not a multicloud.io address. The key goes to that host.", err=True)
|
|
391
|
+
if key_stdin:
|
|
392
|
+
key = sys.stdin.readline()
|
|
393
|
+
else:
|
|
394
|
+
key = click.prompt("API key", hide_input=True)
|
|
395
|
+
key = key.strip()
|
|
396
|
+
if not key:
|
|
397
|
+
click.echo("Error: no API key given. Nothing stored.", err=True)
|
|
398
|
+
sys.exit(1)
|
|
399
|
+
ok, error = check_key(org, url, key)
|
|
400
|
+
if not ok:
|
|
401
|
+
click.echo(f"{error} Nothing stored.", err=True)
|
|
402
|
+
sys.exit(_key_exit_code(error))
|
|
403
|
+
try:
|
|
404
|
+
where = store_key(org, key)
|
|
405
|
+
except AuthError as ex:
|
|
406
|
+
click.echo(f"Error: {ex} Nothing stored.", err=True)
|
|
407
|
+
sys.exit(1)
|
|
408
|
+
config.save(org=org, api_url=url)
|
|
409
|
+
click.echo(f"Stored the API key for {org} in {where}. It can read the cloud accounts of {org}.")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _expiry_text(access: str) -> str:
|
|
413
|
+
exp = sso.expires_at(access)
|
|
414
|
+
if not exp:
|
|
415
|
+
return "unknown"
|
|
416
|
+
left = int(exp - time.time())
|
|
417
|
+
stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(exp))
|
|
418
|
+
return f"{stamp} (in {left // 60} min)" if left > 0 else f"{stamp} (expired; refreshed on the next call)"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@auth.command()
|
|
422
|
+
@click.option("--org", default=None)
|
|
423
|
+
@click.option("--json", "as_json", is_flag=True, help="Print method, api_url, org, orgs, expires and works as JSON")
|
|
424
|
+
def status(org, as_json):
|
|
425
|
+
"""Show how you are signed in and check that it works."""
|
|
426
|
+
say = lambda text: click.echo(text, err=as_json) # noqa: E731
|
|
427
|
+
info = {"method": None, "api_url": None, "org": None, "orgs": [], "expires": None, "works": None}
|
|
428
|
+
|
|
429
|
+
def emit(works=None):
|
|
430
|
+
info["works"] = works
|
|
431
|
+
if as_json:
|
|
432
|
+
click.echo(json.dumps(info, indent=2, sort_keys=True))
|
|
433
|
+
try:
|
|
434
|
+
url = config.get_api_url()
|
|
435
|
+
issuer = config.get_issuer()
|
|
436
|
+
except config.ConfigError as ex:
|
|
437
|
+
click.echo(f"Error: {ex}", err=True)
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
say(f"API URL: {url}")
|
|
440
|
+
info["api_url"] = url
|
|
441
|
+
slug = config.get_org(org)
|
|
442
|
+
info["org"] = slug
|
|
443
|
+
env_key = bool(os.environ.get("MULTICLOUD_API_KEY"))
|
|
444
|
+
try:
|
|
445
|
+
tokens = load_sso(issuer)
|
|
446
|
+
stored, source = get_key(slug) if slug and not env_key else (None, None)
|
|
447
|
+
except AuthError as ex:
|
|
448
|
+
click.echo(f"Error: {ex}", err=True)
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
if env_key:
|
|
451
|
+
say("Method: API key from MULTICLOUD_API_KEY (wins over a stored sign-in)")
|
|
452
|
+
info["method"] = "api-key-env"
|
|
453
|
+
elif tokens:
|
|
454
|
+
say("Method: SSO")
|
|
455
|
+
info["method"] = "sso"
|
|
456
|
+
elif stored:
|
|
457
|
+
say(f"Method: API key from {source}")
|
|
458
|
+
info["method"] = "api-key"
|
|
459
|
+
else:
|
|
460
|
+
say("Method: none. Run: mctl auth login")
|
|
461
|
+
emit(False)
|
|
462
|
+
sys.exit(exitcodes.AUTH)
|
|
463
|
+
if tokens:
|
|
464
|
+
claims = sso.decode_claims(tokens.access)
|
|
465
|
+
say(f"SSO user: {claims.get('email') or claims.get('preferred_username') or 'unknown'}"
|
|
466
|
+
f" (stored in {sso_source(issuer)}; the refresh token is kept)")
|
|
467
|
+
say(f"Access token expires: {_expiry_text(tokens.access)}")
|
|
468
|
+
say(f"Organizations: {', '.join(sso.orgs_in_token(tokens.access)) or 'none'}")
|
|
469
|
+
info["orgs"] = sso.orgs_in_token(tokens.access)
|
|
470
|
+
exp = sso.expires_at(tokens.access)
|
|
471
|
+
info["expires"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(exp)) if exp else None
|
|
472
|
+
if tokens and not env_key:
|
|
473
|
+
if stored:
|
|
474
|
+
say(f"Stored API key for {slug}: {source} (not used while an SSO sign-in exists)")
|
|
475
|
+
orgs = sso.orgs_in_token(tokens.access)
|
|
476
|
+
explicit = config.org_is_explicit(org)
|
|
477
|
+
if len(orgs) > 1 and not explicit and slug not in orgs:
|
|
478
|
+
say("You are signed in, but no organization is chosen. To check an API call, name one: "
|
|
479
|
+
"mctl auth status --org <team-id> (save one with: mctl auth login --org <team-id>)")
|
|
480
|
+
info["org"] = None
|
|
481
|
+
emit()
|
|
482
|
+
return
|
|
483
|
+
from . import api # api imports this module
|
|
484
|
+
try:
|
|
485
|
+
api.call("GET", org, "/pool-classes")
|
|
486
|
+
except api.ApiError as ex:
|
|
487
|
+
click.echo(f"The sign-in does not work: {ex}", err=True)
|
|
488
|
+
emit(False)
|
|
489
|
+
sys.exit(ex.exit_code)
|
|
490
|
+
say("The sign-in works.")
|
|
491
|
+
emit(True)
|
|
492
|
+
return
|
|
493
|
+
key = os.environ.get("MULTICLOUD_API_KEY") or stored
|
|
494
|
+
if not slug:
|
|
495
|
+
click.echo("Name the organization with --org or MULTICLOUD_ORG to check the key.", err=True)
|
|
496
|
+
emit()
|
|
497
|
+
sys.exit(1)
|
|
498
|
+
ok, error = check_key(slug, url, key)
|
|
499
|
+
if not ok:
|
|
500
|
+
click.echo(error, err=True)
|
|
501
|
+
emit(False)
|
|
502
|
+
sys.exit(_key_exit_code(error))
|
|
503
|
+
say("The key works.")
|
|
504
|
+
emit(True)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@auth.command()
|
|
508
|
+
@click.option("--org", default=None)
|
|
509
|
+
@click.option("--sso", "only_sso", is_flag=True, help="Remove only the SSO sign-in")
|
|
510
|
+
@click.option("--api-key", "only_key", is_flag=True, help="Remove only the stored API key")
|
|
511
|
+
def logout(org, only_sso, only_key):
|
|
512
|
+
"""End the SSO session at Keycloak and remove it, and remove the stored key.
|
|
513
|
+
|
|
514
|
+
The CLI shares the Keycloak browser session, so this signs you out of Keycloak in your browser too."""
|
|
515
|
+
do_sso, do_key = (only_sso or not only_key), (only_key or not only_sso)
|
|
516
|
+
try:
|
|
517
|
+
if do_sso:
|
|
518
|
+
try:
|
|
519
|
+
issuer = config.get_issuer()
|
|
520
|
+
except config.ConfigError as ex:
|
|
521
|
+
click.echo(f"Error: {ex}", err=True)
|
|
522
|
+
sys.exit(1)
|
|
523
|
+
tokens = load_sso(issuer)
|
|
524
|
+
if tokens:
|
|
525
|
+
try:
|
|
526
|
+
sso.revoke(issuer, tokens.refresh)
|
|
527
|
+
click.echo("Ended the session at the sign-in server.")
|
|
528
|
+
except sso.SsoError as ex:
|
|
529
|
+
click.echo(f"Warning: could not end the session at the sign-in server ({ex}). "
|
|
530
|
+
"It stays valid until it expires.", err=True)
|
|
531
|
+
click.echo(f"Removed the SSO sign-in from {', '.join(delete_sso(issuer)) or 'nowhere'}.")
|
|
532
|
+
elif only_sso:
|
|
533
|
+
click.echo("No SSO sign-in stored.")
|
|
534
|
+
if do_key:
|
|
535
|
+
slug = config.get_org(org)
|
|
536
|
+
if slug:
|
|
537
|
+
removed = delete_key(slug)
|
|
538
|
+
if removed:
|
|
539
|
+
click.echo(f"Removed the API key for {slug} from {', '.join(removed)}.")
|
|
540
|
+
elif only_key:
|
|
541
|
+
click.echo(f"No stored API key for {slug}.")
|
|
542
|
+
elif only_key:
|
|
543
|
+
click.echo("Error: name the organization with --org or MULTICLOUD_ORG.", err=True)
|
|
544
|
+
sys.exit(1)
|
|
545
|
+
except AuthError as ex:
|
|
546
|
+
click.echo(f"Error: {ex}", err=True)
|
|
547
|
+
sys.exit(1)
|