multicloud-cli 0.1.0__tar.gz → 0.3.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.3.0/PKG-INFO +55 -4
- multicloud_cli-0.1.0/PKG-INFO → multicloud_cli-0.3.0/README.md +24 -19
- multicloud_cli-0.3.0/multicloud_cli/__init__.py +1 -0
- multicloud_cli-0.3.0/multicloud_cli/account.py +86 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/api.py +18 -10
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/auth.py +5 -5
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/catalog.py +3 -3
- multicloud_cli-0.3.0/multicloud_cli/extend.py +218 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/main.py +10 -0
- multicloud_cli-0.3.0/multicloud_cli/net.py +30 -0
- multicloud_cli-0.3.0/multicloud_cli/node.py +60 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/output.py +30 -1
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/pool.py +5 -19
- multicloud_cli-0.3.0/multicloud_cli/ssh.py +260 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/sso.py +3 -1
- multicloud_cli-0.3.0/pyproject.toml +61 -0
- multicloud_cli-0.3.0/tests/test_account_extend.py +346 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_catalog.py +31 -2
- multicloud_cli-0.3.0/tests/test_net.py +24 -0
- multicloud_cli-0.3.0/tests/test_ssh.py +353 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.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.3.0}/.gitignore +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/LICENSE +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/cloud_account.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/config.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/duration.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/exitcodes.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/orgopt.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/__init__.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/conftest.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/helpers.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_apply.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_auth.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_cloud_account.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_conventions.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_pool.py +0 -0
- {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_sso.py +0 -0
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: multicloud-cli
|
|
3
|
+
Version: 0.3.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
|
|
|
@@ -22,6 +54,7 @@ OpenTofu or Terraform 1.5 or later.
|
|
|
22
54
|
mctl auth login # opens your browser; sign in as you do on multicloud.io
|
|
23
55
|
mkdir multicloud-aws && cd multicloud-aws
|
|
24
56
|
mctl cloud-account create --cloud AWS --account 123456789012 --region us-east-1
|
|
57
|
+
export AWS_REGION=us-east-1 # Terraform needs a region when your AWS profile has none
|
|
25
58
|
mctl cloud-account apply 7 # init, plan, asks you, apply, then waits for "Ready"
|
|
26
59
|
```
|
|
27
60
|
|
|
@@ -46,10 +79,27 @@ mctl cloud-account verify 7 --wait
|
|
|
46
79
|
| `mctl cloud-account token <id>` | A new one-time connect code, for a manual `terraform apply`. |
|
|
47
80
|
| `mctl cloud-account verify <id> [--wait]` | Check the role again: `Ready` (exit 0), the missing permissions (exit 1) or pending (exit 5). |
|
|
48
81
|
| `mctl cloud-account list` / `show <id> [--json]` | The connections of your team. |
|
|
82
|
+
| `mctl overview` / `billing` | Setup checklist, alerts and pool counts; payment gate status. Read access or a member sign-in. |
|
|
83
|
+
| `mctl extend create NAME` | Make an Extend pool. Owner sign-in only. |
|
|
84
|
+
| `mctl peering create\|list\|show\|revoke POOL ...` | Manage the peerings of an Extend pool. Owner sign-in only. |
|
|
85
|
+
| `mctl ssh allow\|list\|revoke`, `mctl node list`, `mctl ssh NODE [-y] [-- ssh args]` | Operators only (role `multicloud-admins`): open tcp/22 on a node for your own IPv4 address, list the nodes and the grants, connect. Details: `mctl ssh --help`. |
|
|
49
86
|
|
|
50
87
|
The connect code reaches Terraform only through the environment of the `apply` process.
|
|
51
88
|
`mctl` never prints a key or a token, and only `mctl cloud-account token` prints a connect code.
|
|
52
89
|
|
|
90
|
+
## Overview, Extend pools and peerings
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
mctl overview # checklist, alerts, pools by status
|
|
94
|
+
mctl billing # payment gate
|
|
95
|
+
mctl extend create edge --max-servers 5
|
|
96
|
+
mctl peering create edge --pods 20 # prints the install command for your cluster
|
|
97
|
+
mctl peering show edge 4 # a READY peering also gives its bundle, once: save it
|
|
98
|
+
mctl peering revoke edge 4
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`extend` and `peering` need an SSO sign-in as an owner (`mctl auth login`); an API key gets 403.
|
|
102
|
+
|
|
53
103
|
## Sign in
|
|
54
104
|
|
|
55
105
|
`mctl auth login` opens your browser on `auth.multicloud.io` (authorization code with PKCE, on a temporary
|
|
@@ -117,7 +167,7 @@ mctl catalog providers
|
|
|
117
167
|
mctl catalog field-values # the values the filters accept
|
|
118
168
|
```
|
|
119
169
|
|
|
120
|
-
Every command takes `--json`.
|
|
170
|
+
Every command takes `--json`. `mctl catalog search -h` lists all filters.
|
|
121
171
|
|
|
122
172
|
## Use it from an AI agent
|
|
123
173
|
|
|
@@ -127,7 +177,8 @@ such as Claude Code to run these commands. The agent asks you before each apply
|
|
|
127
177
|
## More
|
|
128
178
|
|
|
129
179
|
Every command shows its options with `--help` or `-h`, for example `mctl pool create -h`.
|
|
130
|
-
|
|
131
|
-
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
Shell completion: `eval "$(_MCTL_COMPLETE=zsh_source mctl)"` in `~/.zshrc` (`bash_source` for bash).
|
|
132
183
|
|
|
133
184
|
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
|
|
|
@@ -37,6 +23,7 @@ OpenTofu or Terraform 1.5 or later.
|
|
|
37
23
|
mctl auth login # opens your browser; sign in as you do on multicloud.io
|
|
38
24
|
mkdir multicloud-aws && cd multicloud-aws
|
|
39
25
|
mctl cloud-account create --cloud AWS --account 123456789012 --region us-east-1
|
|
26
|
+
export AWS_REGION=us-east-1 # Terraform needs a region when your AWS profile has none
|
|
40
27
|
mctl cloud-account apply 7 # init, plan, asks you, apply, then waits for "Ready"
|
|
41
28
|
```
|
|
42
29
|
|
|
@@ -61,10 +48,27 @@ mctl cloud-account verify 7 --wait
|
|
|
61
48
|
| `mctl cloud-account token <id>` | A new one-time connect code, for a manual `terraform apply`. |
|
|
62
49
|
| `mctl cloud-account verify <id> [--wait]` | Check the role again: `Ready` (exit 0), the missing permissions (exit 1) or pending (exit 5). |
|
|
63
50
|
| `mctl cloud-account list` / `show <id> [--json]` | The connections of your team. |
|
|
51
|
+
| `mctl overview` / `billing` | Setup checklist, alerts and pool counts; payment gate status. Read access or a member sign-in. |
|
|
52
|
+
| `mctl extend create NAME` | Make an Extend pool. Owner sign-in only. |
|
|
53
|
+
| `mctl peering create\|list\|show\|revoke POOL ...` | Manage the peerings of an Extend pool. Owner sign-in only. |
|
|
54
|
+
| `mctl ssh allow\|list\|revoke`, `mctl node list`, `mctl ssh NODE [-y] [-- ssh args]` | Operators only (role `multicloud-admins`): open tcp/22 on a node for your own IPv4 address, list the nodes and the grants, connect. Details: `mctl ssh --help`. |
|
|
64
55
|
|
|
65
56
|
The connect code reaches Terraform only through the environment of the `apply` process.
|
|
66
57
|
`mctl` never prints a key or a token, and only `mctl cloud-account token` prints a connect code.
|
|
67
58
|
|
|
59
|
+
## Overview, Extend pools and peerings
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
mctl overview # checklist, alerts, pools by status
|
|
63
|
+
mctl billing # payment gate
|
|
64
|
+
mctl extend create edge --max-servers 5
|
|
65
|
+
mctl peering create edge --pods 20 # prints the install command for your cluster
|
|
66
|
+
mctl peering show edge 4 # a READY peering also gives its bundle, once: save it
|
|
67
|
+
mctl peering revoke edge 4
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`extend` and `peering` need an SSO sign-in as an owner (`mctl auth login`); an API key gets 403.
|
|
71
|
+
|
|
68
72
|
## Sign in
|
|
69
73
|
|
|
70
74
|
`mctl auth login` opens your browser on `auth.multicloud.io` (authorization code with PKCE, on a temporary
|
|
@@ -132,7 +136,7 @@ mctl catalog providers
|
|
|
132
136
|
mctl catalog field-values # the values the filters accept
|
|
133
137
|
```
|
|
134
138
|
|
|
135
|
-
Every command takes `--json`.
|
|
139
|
+
Every command takes `--json`. `mctl catalog search -h` lists all filters.
|
|
136
140
|
|
|
137
141
|
## Use it from an AI agent
|
|
138
142
|
|
|
@@ -142,7 +146,8 @@ such as Claude Code to run these commands. The agent asks you before each apply
|
|
|
142
146
|
## More
|
|
143
147
|
|
|
144
148
|
Every command shows its options with `--help` or `-h`, for example `mctl pool create -h`.
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
Shell completion: `eval "$(_MCTL_COMPLETE=zsh_source mctl)"` in `~/.zshrc` (`bash_source` for bash).
|
|
147
152
|
|
|
148
153
|
License: Apache-2.0.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.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
|
|
|
@@ -52,12 +52,12 @@ def _detail_text(data: Any) -> Optional[str]:
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
def _send(method: str, url: str, body: Optional[Dict[str, Any]], headers: Dict[str, str],
|
|
55
|
-
params: Optional[Dict[str, Any]] = None) -> requests.Response:
|
|
55
|
+
params: Optional[Dict[str, Any]] = None, ipv4_only: bool = False) -> requests.Response:
|
|
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
|
+
ipv4_only=ipv4_only, **extra)
|
|
61
61
|
except requests.RequestException as ex:
|
|
62
62
|
raise ApiError(f"Could not reach the API: {ex}")
|
|
63
63
|
|
|
@@ -84,24 +84,31 @@ def _sso_org(slug: Optional[str], token: str, explicit: bool = True) -> str:
|
|
|
84
84
|
def call(method: str, org: Optional[str], path: str = "", *, body: Optional[Dict[str, Any]] = None,
|
|
85
85
|
headers: Optional[Dict[str, str]] = None, accept: Iterable[int] = (200, 201, 202),
|
|
86
86
|
scope: Optional[str] = None, pass_codes: Iterable[str] = (),
|
|
87
|
-
params: Optional[Dict[str, Any]] = None, org_scoped: bool = True
|
|
87
|
+
params: Optional[Dict[str, Any]] = None, org_scoped: bool = True,
|
|
88
|
+
ipv4_only: bool = False, prefer_sso: bool = False) -> Dict[str, Any]:
|
|
88
89
|
"""One API call. Returns the JSON body with ``_status`` added; raises ``ApiError`` on other statuses.
|
|
89
90
|
|
|
90
91
|
``scope`` names the API key scope this call needs; a 401 or 403 then says that it may be missing.
|
|
91
92
|
A failed answer whose error code is in ``pass_codes`` is returned like a success.
|
|
92
93
|
``params`` are query parameters. ``org_scoped=False`` calls ``/api/v1{path}`` with no organization in the URL:
|
|
93
94
|
an SSO call then needs no organization, and ``MULTICLOUD_API_KEY`` needs none either (a stored key still
|
|
94
|
-
needs one to be found).
|
|
95
|
+
needs one to be found). ``ipv4_only`` connects over IPv4 only (the SSH grant routes). ``prefer_sso`` keeps the
|
|
96
|
+
SSO token on a platform route even when the named org has a stored key or ``MULTICLOUD_API_KEY`` is set: the
|
|
97
|
+
operator routes check a realm role,
|
|
98
|
+
which an API key never holds."""
|
|
95
99
|
slug = config.get_org(org)
|
|
96
100
|
try:
|
|
97
101
|
use_sso = False
|
|
98
|
-
if not os.environ.get("MULTICLOUD_API_KEY"): # the key path needs no issuer
|
|
102
|
+
if prefer_sso or not os.environ.get("MULTICLOUD_API_KEY"): # the key path needs no issuer
|
|
99
103
|
issuer = config.get_issuer()
|
|
100
104
|
use_sso = auth.load_sso(issuer) is not None
|
|
101
|
-
if use_sso and not org_scoped and slug and auth.get_key(slug)[0]:
|
|
105
|
+
if use_sso and not org_scoped and not prefer_sso and slug and auth.get_key(slug)[0]:
|
|
102
106
|
# A stored key of the named org wins for the platform routes (the catalog): it holds its scope
|
|
103
107
|
# explicitly, while an SSO token may not reach them.
|
|
104
108
|
use_sso = False
|
|
109
|
+
if prefer_sso and not use_sso:
|
|
110
|
+
raise ApiError("This command needs an SSO sign-in; an API key never reaches it. Run: mctl auth login",
|
|
111
|
+
exit_code=exitcodes.AUTH)
|
|
105
112
|
base = config.get_api_url()
|
|
106
113
|
if use_sso:
|
|
107
114
|
token = auth.fresh_access_token(issuer)
|
|
@@ -124,14 +131,15 @@ def call(method: str, org: Optional[str], path: str = "", *, body: Optional[Dict
|
|
|
124
131
|
except config.ConfigError as ex:
|
|
125
132
|
raise ApiError(str(ex))
|
|
126
133
|
url = f"{base}/api/v1/orgs/{quote(slug, safe='')}{path}" if org_scoped else f"{base}/api/v1{path}"
|
|
127
|
-
r = _send(method, url, body, {**credential, **(headers or {})}, params)
|
|
134
|
+
r = _send(method, url, body, {**credential, **(headers or {})}, params, ipv4_only)
|
|
128
135
|
if r.status_code == 401 and use_sso:
|
|
129
136
|
# The access token was refused (the session may have ended on the server): refresh once and retry once.
|
|
130
137
|
try:
|
|
131
138
|
token = auth.fresh_access_token(issuer, rejected=token)
|
|
132
139
|
except auth.AuthError as ex:
|
|
133
140
|
raise ApiError(str(ex), 401)
|
|
134
|
-
r = _send(method, url, body, {"Authorization": f"Bearer {token}", **(headers or {})}, params
|
|
141
|
+
r = _send(method, url, body, {"Authorization": f"Bearer {token}", **(headers or {})}, params,
|
|
142
|
+
ipv4_only)
|
|
135
143
|
try:
|
|
136
144
|
data = r.json() if r.text else {}
|
|
137
145
|
except ValueError:
|
|
@@ -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,10 +5,14 @@ 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
|
|
13
|
+
from .node import node
|
|
11
14
|
from .pool import pool
|
|
15
|
+
from .ssh import ssh
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
def _broken_pipe() -> int:
|
|
@@ -49,6 +53,12 @@ mctl.add_command(auth)
|
|
|
49
53
|
mctl.add_command(cloud_account)
|
|
50
54
|
mctl.add_command(pool)
|
|
51
55
|
mctl.add_command(catalog)
|
|
56
|
+
mctl.add_command(overview)
|
|
57
|
+
mctl.add_command(billing)
|
|
58
|
+
mctl.add_command(extend)
|
|
59
|
+
mctl.add_command(peering)
|
|
60
|
+
mctl.add_command(ssh)
|
|
61
|
+
mctl.add_command(node)
|
|
52
62
|
|
|
53
63
|
|
|
54
64
|
def _run() -> int:
|