multicloud-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0/README.md → multicloud_cli-0.2.0/PKG-INFO +53 -4
- multicloud_cli-0.1.0/PKG-INFO → multicloud_cli-0.2.0/README.md +22 -19
- multicloud_cli-0.2.0/multicloud_cli/__init__.py +1 -0
- multicloud_cli-0.2.0/multicloud_cli/account.py +86 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/api.py +3 -3
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/auth.py +5 -5
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/catalog.py +3 -3
- multicloud_cli-0.2.0/multicloud_cli/extend.py +218 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/main.py +6 -0
- multicloud_cli-0.2.0/multicloud_cli/net.py +13 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/output.py +30 -1
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/pool.py +5 -19
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/sso.py +3 -1
- multicloud_cli-0.2.0/pyproject.toml +61 -0
- multicloud_cli-0.2.0/tests/test_account_extend.py +346 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_catalog.py +2 -1
- multicloud_cli-0.2.0/tests/test_net.py +24 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_sso_session.py +7 -6
- multicloud_cli-0.1.0/multicloud_cli/__init__.py +0 -1
- multicloud_cli-0.1.0/pyproject.toml +0 -37
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/.gitignore +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/LICENSE +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/cloud_account.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/config.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/duration.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/exitcodes.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/orgopt.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/__init__.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/conftest.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/helpers.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_apply.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_auth.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_cloud_account.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_conventions.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_pool.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_sso.py +0 -0
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: multicloud-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: mctl: the Multicloud command line: sign in, connect your cloud account, run pools
|
|
5
|
+
Project-URL: Homepage, https://multicloud.io
|
|
6
|
+
Author-email: Multicloud Team <info@multicloud.io>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: aws,cli,cloud,kubernetes,multicloud
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: click>=8.1
|
|
26
|
+
Requires-Dist: keyring>=24
|
|
27
|
+
Requires-Dist: requests>=2.31
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
1
32
|
# multicloud-cli
|
|
2
33
|
|
|
3
34
|
`mctl` connects your AWS account to [Multicloud](https://multicloud.io).
|
|
@@ -5,13 +36,14 @@ Multicloud then starts machines in your account, and AWS bills you directly.
|
|
|
5
36
|
You give Multicloud no password and no access key: you create one IAM role with the public
|
|
6
37
|
Terraform module [multicloud/multicloud/aws](https://registry.terraform.io/modules/multicloud/multicloud/aws).
|
|
7
38
|
|
|
8
|
-
|
|
39
|
+
Install it from PyPI (Python 3.10 or later):
|
|
9
40
|
|
|
10
41
|
```bash
|
|
11
42
|
pipx install multicloud-cli # or: uv tool install multicloud-cli
|
|
12
43
|
```
|
|
13
44
|
|
|
14
45
|
If `mctl` is not found after the install, run `uv tool update-shell` (or `pipx ensurepath`) and open a new shell.
|
|
46
|
+
Upgrade with `uv tool upgrade multicloud-cli` (or `pipx upgrade multicloud-cli`). Check the version with `mctl --version`.
|
|
15
47
|
|
|
16
48
|
## Connect an AWS account
|
|
17
49
|
|
|
@@ -46,10 +78,26 @@ mctl cloud-account verify 7 --wait
|
|
|
46
78
|
| `mctl cloud-account token <id>` | A new one-time connect code, for a manual `terraform apply`. |
|
|
47
79
|
| `mctl cloud-account verify <id> [--wait]` | Check the role again: `Ready` (exit 0), the missing permissions (exit 1) or pending (exit 5). |
|
|
48
80
|
| `mctl cloud-account list` / `show <id> [--json]` | The connections of your team. |
|
|
81
|
+
| `mctl overview` / `billing` | Setup checklist, alerts and pool counts; payment gate status. Read access or a member sign-in. |
|
|
82
|
+
| `mctl extend create NAME` | Make an Extend pool. Owner sign-in only. |
|
|
83
|
+
| `mctl peering create\|list\|show\|revoke POOL ...` | Manage the peerings of an Extend pool. Owner sign-in only. |
|
|
49
84
|
|
|
50
85
|
The connect code reaches Terraform only through the environment of the `apply` process.
|
|
51
86
|
`mctl` never prints a key or a token, and only `mctl cloud-account token` prints a connect code.
|
|
52
87
|
|
|
88
|
+
## Overview, Extend pools and peerings
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
mctl overview # checklist, alerts, pools by status
|
|
92
|
+
mctl billing # payment gate
|
|
93
|
+
mctl extend create edge --max-servers 5
|
|
94
|
+
mctl peering create edge --pods 20 # prints the install command for your cluster
|
|
95
|
+
mctl peering show edge 4 # a READY peering also gives its bundle, once: save it
|
|
96
|
+
mctl peering revoke edge 4
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`extend` and `peering` need an SSO sign-in as an owner (`mctl auth login`); an API key gets 403.
|
|
100
|
+
|
|
53
101
|
## Sign in
|
|
54
102
|
|
|
55
103
|
`mctl auth login` opens your browser on `auth.multicloud.io` (authorization code with PKCE, on a temporary
|
|
@@ -117,7 +165,7 @@ mctl catalog providers
|
|
|
117
165
|
mctl catalog field-values # the values the filters accept
|
|
118
166
|
```
|
|
119
167
|
|
|
120
|
-
Every command takes `--json`.
|
|
168
|
+
Every command takes `--json`. `mctl catalog search -h` lists all filters.
|
|
121
169
|
|
|
122
170
|
## Use it from an AI agent
|
|
123
171
|
|
|
@@ -127,7 +175,8 @@ such as Claude Code to run these commands. The agent asks you before each apply
|
|
|
127
175
|
## More
|
|
128
176
|
|
|
129
177
|
Every command shows its options with `--help` or `-h`, for example `mctl pool create -h`.
|
|
130
|
-
|
|
131
|
-
|
|
178
|
+
Exit codes: 0 ok, 1 error, 2 usage, 3 sign-in or access, 4 not found, 5 still pending or wait timeout, 6 pool gone.
|
|
179
|
+
|
|
180
|
+
Shell completion: `eval "$(_MCTL_COMPLETE=zsh_source mctl)"` in `~/.zshrc` (`bash_source` for bash).
|
|
132
181
|
|
|
133
182
|
License: Apache-2.0.
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: multicloud-cli
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: mctl: sign in to Multicloud and connect your cloud account
|
|
5
|
-
Author-email: Multicloud Team <info@multicloud.io>
|
|
6
|
-
License: Apache-2.0
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.10
|
|
9
|
-
Requires-Dist: click>=8.1
|
|
10
|
-
Requires-Dist: keyring>=24
|
|
11
|
-
Requires-Dist: requests>=2.31
|
|
12
|
-
Provides-Extra: test
|
|
13
|
-
Requires-Dist: pytest>=8; extra == 'test'
|
|
14
|
-
Description-Content-Type: text/markdown
|
|
15
|
-
|
|
16
1
|
# multicloud-cli
|
|
17
2
|
|
|
18
3
|
`mctl` connects your AWS account to [Multicloud](https://multicloud.io).
|
|
@@ -20,13 +5,14 @@ Multicloud then starts machines in your account, and AWS bills you directly.
|
|
|
20
5
|
You give Multicloud no password and no access key: you create one IAM role with the public
|
|
21
6
|
Terraform module [multicloud/multicloud/aws](https://registry.terraform.io/modules/multicloud/multicloud/aws).
|
|
22
7
|
|
|
23
|
-
|
|
8
|
+
Install it from PyPI (Python 3.10 or later):
|
|
24
9
|
|
|
25
10
|
```bash
|
|
26
11
|
pipx install multicloud-cli # or: uv tool install multicloud-cli
|
|
27
12
|
```
|
|
28
13
|
|
|
29
14
|
If `mctl` is not found after the install, run `uv tool update-shell` (or `pipx ensurepath`) and open a new shell.
|
|
15
|
+
Upgrade with `uv tool upgrade multicloud-cli` (or `pipx upgrade multicloud-cli`). Check the version with `mctl --version`.
|
|
30
16
|
|
|
31
17
|
## Connect an AWS account
|
|
32
18
|
|
|
@@ -61,10 +47,26 @@ mctl cloud-account verify 7 --wait
|
|
|
61
47
|
| `mctl cloud-account token <id>` | A new one-time connect code, for a manual `terraform apply`. |
|
|
62
48
|
| `mctl cloud-account verify <id> [--wait]` | Check the role again: `Ready` (exit 0), the missing permissions (exit 1) or pending (exit 5). |
|
|
63
49
|
| `mctl cloud-account list` / `show <id> [--json]` | The connections of your team. |
|
|
50
|
+
| `mctl overview` / `billing` | Setup checklist, alerts and pool counts; payment gate status. Read access or a member sign-in. |
|
|
51
|
+
| `mctl extend create NAME` | Make an Extend pool. Owner sign-in only. |
|
|
52
|
+
| `mctl peering create\|list\|show\|revoke POOL ...` | Manage the peerings of an Extend pool. Owner sign-in only. |
|
|
64
53
|
|
|
65
54
|
The connect code reaches Terraform only through the environment of the `apply` process.
|
|
66
55
|
`mctl` never prints a key or a token, and only `mctl cloud-account token` prints a connect code.
|
|
67
56
|
|
|
57
|
+
## Overview, Extend pools and peerings
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
mctl overview # checklist, alerts, pools by status
|
|
61
|
+
mctl billing # payment gate
|
|
62
|
+
mctl extend create edge --max-servers 5
|
|
63
|
+
mctl peering create edge --pods 20 # prints the install command for your cluster
|
|
64
|
+
mctl peering show edge 4 # a READY peering also gives its bundle, once: save it
|
|
65
|
+
mctl peering revoke edge 4
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`extend` and `peering` need an SSO sign-in as an owner (`mctl auth login`); an API key gets 403.
|
|
69
|
+
|
|
68
70
|
## Sign in
|
|
69
71
|
|
|
70
72
|
`mctl auth login` opens your browser on `auth.multicloud.io` (authorization code with PKCE, on a temporary
|
|
@@ -132,7 +134,7 @@ mctl catalog providers
|
|
|
132
134
|
mctl catalog field-values # the values the filters accept
|
|
133
135
|
```
|
|
134
136
|
|
|
135
|
-
Every command takes `--json`.
|
|
137
|
+
Every command takes `--json`. `mctl catalog search -h` lists all filters.
|
|
136
138
|
|
|
137
139
|
## Use it from an AI agent
|
|
138
140
|
|
|
@@ -142,7 +144,8 @@ such as Claude Code to run these commands. The agent asks you before each apply
|
|
|
142
144
|
## More
|
|
143
145
|
|
|
144
146
|
Every command shows its options with `--help` or `-h`, for example `mctl pool create -h`.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
Exit codes: 0 ok, 1 error, 2 usage, 3 sign-in or access, 4 not found, 5 still pending or wait timeout, 6 pool gone.
|
|
148
|
+
|
|
149
|
+
Shell completion: `eval "$(_MCTL_COMPLETE=zsh_source mctl)"` in `~/.zshrc` (`bash_source` for bash).
|
|
147
150
|
|
|
148
151
|
License: Apache-2.0.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""``mctl overview`` and ``mctl billing``: the state of the organization.
|
|
2
|
+
|
|
3
|
+
``overview`` calls ``GET /api/v1/orgs/{slug}/overview``, ``billing`` calls ``GET /api/v1/orgs/{slug}/billing``.
|
|
4
|
+
Both need the ``org:read`` scope (``org:write`` and ``org:admin`` include it) or the SSO sign-in of any member.
|
|
5
|
+
The API leaves out fields that have no value, so every key is optional here.
|
|
6
|
+
"""
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from . import api, exitcodes
|
|
12
|
+
from .output import dump as _dump, json_option, run
|
|
13
|
+
|
|
14
|
+
READ = "org:read"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AccountError(exitcodes.CliError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get(org, path: str) -> Dict[str, Any]:
|
|
22
|
+
try:
|
|
23
|
+
return api.call("GET", org, path, scope=READ)
|
|
24
|
+
except api.ApiError as ex:
|
|
25
|
+
raise AccountError(str(ex), ex.exit_code)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _org_option(f):
|
|
29
|
+
return click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG)")(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _checklist_line(item: Dict[str, Any]) -> str:
|
|
33
|
+
state = item.get("state")
|
|
34
|
+
notes = [n for n in ((state if state not in ("done", "open", None) else None),
|
|
35
|
+
"optional" if item.get("optional") else None) if n]
|
|
36
|
+
return f"[{'x' if state == 'done' else ' '}] {item.get('step')}" + (f" ({', '.join(notes)})" if notes else "")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _alert_line(a: Dict[str, Any]) -> str:
|
|
40
|
+
line = str(a.get("kind"))
|
|
41
|
+
if a.get("pool_id") is not None or a.get("name"):
|
|
42
|
+
line += f": pool {a.get('pool_id') if a.get('pool_id') is not None else '?'}" + (f" ({a['name']})" if a.get("name") else "")
|
|
43
|
+
return line
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def readable_overview(data: Dict[str, Any]) -> str:
|
|
47
|
+
lines = ["Checklist:"] + [_checklist_line(i) for i in data.get("checklist") or []]
|
|
48
|
+
alerts = data.get("alerts") or []
|
|
49
|
+
lines.append("Alerts:" + ("" if alerts else " none"))
|
|
50
|
+
lines += [f" {_alert_line(a)}" for a in alerts]
|
|
51
|
+
pools = data.get("pools") or {}
|
|
52
|
+
lines.append(f"Pools: {pools.get('total', 0)}")
|
|
53
|
+
lines += [f" {status}: {n}" for status, n in sorted((pools.get("by_status") or {}).items())]
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def readable_billing(data: Dict[str, Any]) -> str:
|
|
58
|
+
gate = data.get("payment_gate") or {}
|
|
59
|
+
lines = [f"Payment gate: {gate.get('status', 'unknown')}"]
|
|
60
|
+
for key, label in (("passed_at", "Passed"), ("waived_at", "Waived"),
|
|
61
|
+
("waiver_expires_at", "Waiver expires"), ("suspended_at", "Suspended")):
|
|
62
|
+
if gate.get(key):
|
|
63
|
+
lines.append(f"{label}: {gate[key]}")
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@click.command()
|
|
68
|
+
@_org_option
|
|
69
|
+
@json_option
|
|
70
|
+
def overview(org, as_json):
|
|
71
|
+
"""Show the setup checklist, alerts and pool counts of the organization."""
|
|
72
|
+
data = run(org, lambda o: _get(o, "/overview"), (AccountError, api.ApiError))
|
|
73
|
+
if as_json:
|
|
74
|
+
return _dump(data)
|
|
75
|
+
click.echo(readable_overview(data))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@click.command()
|
|
79
|
+
@_org_option
|
|
80
|
+
@json_option
|
|
81
|
+
def billing(org, as_json):
|
|
82
|
+
"""Show the payment gate of the organization and its dates."""
|
|
83
|
+
data = run(org, lambda o: _get(o, "/billing"), (AccountError, api.ApiError))
|
|
84
|
+
if as_json:
|
|
85
|
+
return _dump(data)
|
|
86
|
+
click.echo(readable_billing(data))
|
|
@@ -11,7 +11,7 @@ from urllib.parse import quote
|
|
|
11
11
|
|
|
12
12
|
import requests
|
|
13
13
|
|
|
14
|
-
from . import auth, config, exitcodes, sso
|
|
14
|
+
from . import auth, config, exitcodes, net, sso
|
|
15
15
|
|
|
16
16
|
TIMEOUT_S = 30
|
|
17
17
|
|
|
@@ -56,8 +56,8 @@ def _send(method: str, url: str, body: Optional[Dict[str, Any]], headers: Dict[s
|
|
|
56
56
|
extra = {"params": params} if params else {} # a list value becomes one repeated key
|
|
57
57
|
try:
|
|
58
58
|
# No redirects: requests would carry the credential header to another host.
|
|
59
|
-
return
|
|
60
|
-
|
|
59
|
+
return net.request(method, url, json=body, timeout=TIMEOUT_S, allow_redirects=False, headers=headers,
|
|
60
|
+
**extra)
|
|
61
61
|
except requests.RequestException as ex:
|
|
62
62
|
raise ApiError(f"Could not reach the API: {ex}")
|
|
63
63
|
|
|
@@ -16,7 +16,7 @@ from urllib.parse import quote, urlparse
|
|
|
16
16
|
import click
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
|
-
from . import config, exitcodes, sso
|
|
19
|
+
from . import config, exitcodes, net, sso
|
|
20
20
|
|
|
21
21
|
try:
|
|
22
22
|
import fcntl
|
|
@@ -280,8 +280,8 @@ def check_key(org: str, api_url: str, key: str) -> Tuple[bool, str]:
|
|
|
280
280
|
"""GET the list of cloud accounts with this key. Returns (ok, error text)."""
|
|
281
281
|
url = f"{api_url}/api/v1/orgs/{quote(org, safe='')}/cloud-accounts"
|
|
282
282
|
try:
|
|
283
|
-
r =
|
|
284
|
-
|
|
283
|
+
r = net.request("GET", url, timeout=TIMEOUT_S, headers={"X-API-Key": key},
|
|
284
|
+
allow_redirects=False)
|
|
285
285
|
except requests.RequestException as ex:
|
|
286
286
|
return False, f"Could not reach the API: {type(ex).__name__}"
|
|
287
287
|
if r.status_code == 200:
|
|
@@ -369,8 +369,8 @@ def _probe(url: str, access: str, org: Optional[str]) -> Optional[int]:
|
|
|
369
369
|
if not org:
|
|
370
370
|
return None
|
|
371
371
|
try:
|
|
372
|
-
r =
|
|
373
|
-
|
|
372
|
+
r = net.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
374
|
except requests.RequestException:
|
|
375
375
|
return None
|
|
376
376
|
return r.status_code
|
|
@@ -13,7 +13,7 @@ import click
|
|
|
13
13
|
|
|
14
14
|
from . import api, exitcodes
|
|
15
15
|
from .orgopt import org_group
|
|
16
|
-
from .output import dump as _dump, json_option, no_headers_option, plain as _plain, quiet_option, run, table as _table
|
|
16
|
+
from .output import dump as _dump, echo as _echo, json_option, no_headers_option, plain as _plain, quiet_option, run, table as _table
|
|
17
17
|
|
|
18
18
|
SCOPE = "catalog"
|
|
19
19
|
|
|
@@ -243,7 +243,7 @@ def compare(ctx, catalog_ids, as_json, no_headers):
|
|
|
243
243
|
cells.append(_number(found[0]) if found else "-")
|
|
244
244
|
rows.append((str(metric), *cells))
|
|
245
245
|
if servers:
|
|
246
|
-
|
|
246
|
+
_echo(_table(header, rows, not no_headers))
|
|
247
247
|
meta = data.get("metadata") or {}
|
|
248
248
|
for key, text in (("notFoundIds", "Not found"), ("invalidIds", "Invalid")):
|
|
249
249
|
if meta.get(key):
|
|
@@ -278,7 +278,7 @@ def locations(ctx, provider, region, zone, sort_by, sort_order, page, per_page,
|
|
|
278
278
|
if loc.get("id") is not None:
|
|
279
279
|
click.echo(loc["id"])
|
|
280
280
|
return
|
|
281
|
-
|
|
281
|
+
_echo(_table(("ID", "PROVIDER", "REGION", "ZONE", "NAME"), [
|
|
282
282
|
(str(loc.get("id")), str(loc.get("provider") or "-"), str(loc.get("region") or "-"),
|
|
283
283
|
str(loc.get("zone") or "-"), str(loc.get("display_name") or "-")) for loc in data.get("data", [])],
|
|
284
284
|
not no_headers))
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""``mctl extend`` and ``mctl peering``: Extend pools and their peerings.
|
|
2
|
+
|
|
3
|
+
These commands call ``/api/v1/orgs/{slug}/extend-pools`` and ``/api/v1/orgs/{slug}/pools/{name}/peerings``.
|
|
4
|
+
The owner routes accept only the SSO sign-in of an owner of the organization (``mctl auth login``): an API key of
|
|
5
|
+
any kind gets 403. Creating a pool or a peering goes through the payment gate and can answer 402 or 503.
|
|
6
|
+
Neither request body holds a secret, so every field has a flag. ``--from-file`` still gives the full body.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from . import api, exitcodes
|
|
17
|
+
from .orgopt import org_group
|
|
18
|
+
from .output import (collect as _collect, dump as _dump, echo as _echo, json_option, load_body, no_headers_option, quiet_option,
|
|
19
|
+
run, table as _table)
|
|
20
|
+
from .pool import _status
|
|
21
|
+
|
|
22
|
+
OWNER_HINT = "These commands need an SSO sign-in as an owner: run mctl auth login."
|
|
23
|
+
GROUPS = {"extend": "extend_org", "peering": "peering_org"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExtendError(exitcodes.CliError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _stdin_is_tty() -> bool:
|
|
31
|
+
return sys.stdin.isatty()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fail(ex: api.ApiError, text: Optional[str] = None) -> ExtendError:
|
|
35
|
+
text = text or str(ex)
|
|
36
|
+
if ex.status == 403:
|
|
37
|
+
text += " " + OWNER_HINT
|
|
38
|
+
return ExtendError(text, ex.exit_code)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _call(org, method: str, path: str, **kw) -> Dict[str, Any]:
|
|
42
|
+
# No scope: no API key can use these routes, so "the key may lack a scope" would mislead.
|
|
43
|
+
try:
|
|
44
|
+
return api.call(method, org, path, **kw)
|
|
45
|
+
except api.ApiError as ex:
|
|
46
|
+
raise _fail(ex)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pool_path(pool: str, rest: str = "") -> str:
|
|
50
|
+
return f"/pools/{quote(pool, safe='')}/peerings{rest}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _group(name: str, help_text: str):
|
|
54
|
+
@click.group(name=name, cls=org_group(GROUPS[name]), help=help_text)
|
|
55
|
+
@click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG)")
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def g(ctx, org):
|
|
58
|
+
ctx.ensure_object(dict)
|
|
59
|
+
ctx.obj[GROUPS[name]] = org
|
|
60
|
+
return g
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
extend = _group("extend", "Create Extend pools (owner sign-in: mctl auth login).")
|
|
64
|
+
peering = _group("peering", "Manage peerings of an Extend pool (owner sign-in: mctl auth login).")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(ctx, key, fn):
|
|
68
|
+
return run(ctx.obj.get(key), fn, (ExtendError, api.ApiError))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@extend.command(name="create")
|
|
72
|
+
@click.argument("name", required=False)
|
|
73
|
+
@click.option("--max-servers", type=int, default=None, help="Most servers the pool may hold (1-50)")
|
|
74
|
+
@click.option("--max-cost-per-hour", type=float, default=None, help="Cost ceiling in USD per hour (up to 50)")
|
|
75
|
+
@click.option("--from-file", "from_file", type=click.Path(dir_okay=False, allow_dash=True), default=None,
|
|
76
|
+
help="JSON file with the request body (- for standard input); flags override its fields")
|
|
77
|
+
@click.option("--idempotency-key", default=None,
|
|
78
|
+
help="Same key and body within 24 hours creates one pool (default: a new random key)")
|
|
79
|
+
@json_option
|
|
80
|
+
@click.pass_context
|
|
81
|
+
def create_pool(ctx, name, max_servers, max_cost_per_hour, from_file, idempotency_key, as_json):
|
|
82
|
+
"""Create an Extend pool."""
|
|
83
|
+
body = load_body(from_file)
|
|
84
|
+
body.update(_collect(name=name, max_servers=max_servers, max_cost_per_hour=max_cost_per_hour))
|
|
85
|
+
if "name" not in body:
|
|
86
|
+
raise click.UsageError("Missing: NAME")
|
|
87
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
88
|
+
|
|
89
|
+
def go(org):
|
|
90
|
+
try:
|
|
91
|
+
return api.call("POST", org, "/extend-pools", body=body, headers={"Idempotency-Key": key})
|
|
92
|
+
except api.ApiError as ex:
|
|
93
|
+
if ex.status is None or ex.status >= 500:
|
|
94
|
+
# The pool may exist already: the same key and body make the retry safe.
|
|
95
|
+
raise _fail(ex, f"{ex} To retry safely, run the same command with --idempotency-key {key}")
|
|
96
|
+
raise _fail(ex)
|
|
97
|
+
made = _run(ctx, "extend_org", go)
|
|
98
|
+
if as_json:
|
|
99
|
+
return _dump(made)
|
|
100
|
+
click.echo(f"Pool {made.get('pool_id')} ({made.get('name')}) created. Status: {_status(made)}")
|
|
101
|
+
click.echo(f"Next: mctl peering create {made.get('name')}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
GRANT_OPTIONS = (("cpu_millicores", "CPU the peering may use, in millicores (100-1000000)"),
|
|
105
|
+
("memory_mb", "Memory in MB (128-4000000)"),
|
|
106
|
+
("pods", "Number of pods (1-10000)"),
|
|
107
|
+
("ephemeral_storage_mb", "Ephemeral storage in MB (1024-10000000)"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _grant_options(f):
|
|
111
|
+
for field, text in reversed(GRANT_OPTIONS):
|
|
112
|
+
f = click.option("--" + field.replace("_", "-"), field, type=int, default=None, help=text)(f)
|
|
113
|
+
return f
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _peering_readable(p: Dict[str, Any]) -> str:
|
|
117
|
+
status = str(p.get("status"))
|
|
118
|
+
if p.get("reason"):
|
|
119
|
+
status += f" ({p['reason']})"
|
|
120
|
+
lines = [f"Peering {p.get('id')}", ("Consumer cluster", p.get("consumer_cluster_id")), f"Status: {status}",
|
|
121
|
+
("Message", p.get("message")), ("Install command", p.get("helm_command"))]
|
|
122
|
+
out = "\n".join(l if isinstance(l, str) else f"{l[0]}: {l[1]}"
|
|
123
|
+
for l in lines if isinstance(l, str) or l[1] not in (None, ""))
|
|
124
|
+
if p.get("bundle") is not None:
|
|
125
|
+
bundle = p["bundle"]
|
|
126
|
+
out += "\nBundle:\n" + (bundle if isinstance(bundle, str) else json.dumps(bundle, indent=2, sort_keys=True))
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@peering.command(name="create")
|
|
131
|
+
@click.argument("pool", metavar="POOL")
|
|
132
|
+
@_grant_options
|
|
133
|
+
@click.option("--from-file", "from_file", type=click.Path(dir_okay=False, allow_dash=True), default=None,
|
|
134
|
+
help="JSON file with the request body (- for standard input); flags override its fields")
|
|
135
|
+
@json_option
|
|
136
|
+
@click.pass_context
|
|
137
|
+
def create_peering(ctx, pool, from_file, as_json, **grant):
|
|
138
|
+
"""Create a peering for an Extend pool (name). Optional options ask for a resource grant."""
|
|
139
|
+
body = load_body(from_file)
|
|
140
|
+
body.update(_collect(**grant))
|
|
141
|
+
|
|
142
|
+
def go(org):
|
|
143
|
+
try:
|
|
144
|
+
return api.call("POST", org, _pool_path(pool), body=body)
|
|
145
|
+
except api.ApiError as ex:
|
|
146
|
+
if ex.status is None or ex.status >= 500:
|
|
147
|
+
# The route takes no Idempotency-Key: the peering may exist already.
|
|
148
|
+
raise _fail(ex, f"{ex} The peering may exist already. Check with: mctl peering list {pool}")
|
|
149
|
+
raise _fail(ex)
|
|
150
|
+
made = _run(ctx, "peering_org", go)
|
|
151
|
+
if as_json:
|
|
152
|
+
return _dump(made)
|
|
153
|
+
click.echo(f"Peering {made.get('id')} created. Status: {made.get('status')}")
|
|
154
|
+
if made.get("helm_command"):
|
|
155
|
+
click.echo(f"Install command: {made['helm_command']}")
|
|
156
|
+
click.echo(f"Next: mctl peering show {pool} {made.get('id')}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@peering.command(name="list")
|
|
160
|
+
@click.argument("pool", metavar="POOL")
|
|
161
|
+
@json_option
|
|
162
|
+
@quiet_option
|
|
163
|
+
@no_headers_option
|
|
164
|
+
@click.pass_context
|
|
165
|
+
def list_peerings(ctx, pool, as_json, quiet, no_headers):
|
|
166
|
+
"""List the peerings of an Extend pool (name)."""
|
|
167
|
+
data = _run(ctx, "peering_org", lambda org: _call(org, "GET", _pool_path(pool)))
|
|
168
|
+
if as_json:
|
|
169
|
+
return _dump(data)
|
|
170
|
+
peerings = data.get("peerings") or []
|
|
171
|
+
if quiet:
|
|
172
|
+
for p in peerings:
|
|
173
|
+
click.echo(p.get("id"))
|
|
174
|
+
return
|
|
175
|
+
_echo(_table(("ID", "CONSUMER", "STATUS", "MESSAGE"), [
|
|
176
|
+
(str(p.get("id")), str(p.get("consumer_cluster_id") or "-"), str(p.get("status")),
|
|
177
|
+
str(p.get("message") or p.get("reason") or "-")) for p in peerings], not no_headers))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@peering.command(name="show")
|
|
181
|
+
@click.argument("pool", metavar="POOL")
|
|
182
|
+
@click.argument("peering_id", metavar="ID", type=click.IntRange(min=1))
|
|
183
|
+
@json_option
|
|
184
|
+
@click.pass_context
|
|
185
|
+
def show(ctx, pool, peering_id, as_json):
|
|
186
|
+
"""Show one peering. The first read of a READY peering also gives its bundle, once: save it."""
|
|
187
|
+
data = _run(ctx, "peering_org", lambda org: _call(org, "GET", _pool_path(pool, f"/{peering_id}")))
|
|
188
|
+
if data.get("bundle") is not None:
|
|
189
|
+
click.echo("The bundle is shown only once. Save it now.", err=True)
|
|
190
|
+
if as_json:
|
|
191
|
+
return _dump(data)
|
|
192
|
+
click.echo(_peering_readable(data))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@peering.command(name="revoke")
|
|
196
|
+
@click.argument("pool", metavar="POOL")
|
|
197
|
+
@click.argument("peering_id", metavar="ID", type=click.IntRange(min=1))
|
|
198
|
+
@click.option("-y", "--yes", is_flag=True, help="Revoke without asking")
|
|
199
|
+
@json_option
|
|
200
|
+
@click.pass_context
|
|
201
|
+
def revoke(ctx, pool, peering_id, yes, as_json):
|
|
202
|
+
"""Revoke a peering. The consumer cluster loses access."""
|
|
203
|
+
if not yes and not _stdin_is_tty():
|
|
204
|
+
click.echo("Error: Refusing to revoke without confirmation. Run again with --yes.", err=True)
|
|
205
|
+
sys.exit(exitcodes.ERROR)
|
|
206
|
+
|
|
207
|
+
def go(org):
|
|
208
|
+
if not yes and not click.confirm(
|
|
209
|
+
f"This revokes peering {peering_id} of pool {pool}. The consumer cluster loses access. Revoke it?",
|
|
210
|
+
default=False):
|
|
211
|
+
click.echo("Nothing revoked.", err=True)
|
|
212
|
+
return exitcodes.ERROR
|
|
213
|
+
data = _call(org, "DELETE", _pool_path(pool, f"/{peering_id}"))
|
|
214
|
+
click.echo("Revoke started.", err=True)
|
|
215
|
+
if as_json:
|
|
216
|
+
_dump(data)
|
|
217
|
+
return exitcodes.OK
|
|
218
|
+
sys.exit(_run(ctx, "peering_org", go))
|
|
@@ -5,9 +5,11 @@ import sys
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
7
|
from . import __version__, exitcodes
|
|
8
|
+
from .account import billing, overview
|
|
8
9
|
from .auth import auth
|
|
9
10
|
from .catalog import catalog
|
|
10
11
|
from .cloud_account import cloud_account
|
|
12
|
+
from .extend import extend, peering
|
|
11
13
|
from .pool import pool
|
|
12
14
|
|
|
13
15
|
|
|
@@ -49,6 +51,10 @@ mctl.add_command(auth)
|
|
|
49
51
|
mctl.add_command(cloud_account)
|
|
50
52
|
mctl.add_command(pool)
|
|
51
53
|
mctl.add_command(catalog)
|
|
54
|
+
mctl.add_command(overview)
|
|
55
|
+
mctl.add_command(billing)
|
|
56
|
+
mctl.add_command(extend)
|
|
57
|
+
mctl.add_command(peering)
|
|
52
58
|
|
|
53
59
|
|
|
54
60
|
def _run() -> int:
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Every HTTP request of ``mctl`` goes through ``request``, which adds ``User-Agent: mctl/<version>``."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
|
|
8
|
+
USER_AGENT = f"mctl/{__version__}"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def request(method: str, url: str, **kw: Any) -> requests.Response:
|
|
12
|
+
kw["headers"] = {"User-Agent": USER_AGENT, **(kw.get("headers") or {})}
|
|
13
|
+
return requests.request(method, url, **kw)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Output helpers shared by the command groups: the plain table, the JSON dump and the common options."""
|
|
2
2
|
import json
|
|
3
3
|
import sys
|
|
4
|
-
from
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
5
6
|
|
|
6
7
|
import click
|
|
7
8
|
|
|
@@ -26,6 +27,34 @@ def table(header: Tuple[str, ...], rows: List[Tuple[str, ...]], headers: bool =
|
|
|
26
27
|
return "\n".join(" ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip() for r in rows)
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def echo(text: str) -> None:
|
|
31
|
+
"""Print ``text`` unless it is empty: an empty table prints no line, so ``wc -l`` counts 0."""
|
|
32
|
+
if text:
|
|
33
|
+
click.echo(text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_stdin() -> str:
|
|
37
|
+
return sys.stdin.read()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect(**given) -> Dict[str, Any]:
|
|
41
|
+
"""The given fields that are not None (a flag the user left out is not sent)."""
|
|
42
|
+
return {k: v for k, v in given.items() if v is not None}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_body(from_file: Optional[str]) -> Dict[str, Any]:
|
|
46
|
+
"""The JSON object of ``--from-file`` (``-``: standard input); ``{}`` when the option is not given."""
|
|
47
|
+
if not from_file:
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
body = json.loads(read_stdin() if from_file == "-" else Path(from_file).read_text(encoding="utf-8"))
|
|
51
|
+
except (OSError, ValueError) as ex:
|
|
52
|
+
raise click.BadParameter(f"Could not read {from_file} as JSON: {ex}", param_hint="--from-file")
|
|
53
|
+
if not isinstance(body, dict):
|
|
54
|
+
raise click.BadParameter("The file must hold a JSON object.", param_hint="--from-file")
|
|
55
|
+
return body
|
|
56
|
+
|
|
57
|
+
|
|
29
58
|
def run(org, fn: Callable[[Any], Any], errors: Tuple[type, ...]) -> Any:
|
|
30
59
|
"""Run ``fn(org)``; an error of ``errors`` prints ``Error: <text>`` and exits with its exit code."""
|
|
31
60
|
try:
|