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.
Files changed (39) hide show
  1. multicloud_cli-0.1.0/README.md → multicloud_cli-0.3.0/PKG-INFO +55 -4
  2. multicloud_cli-0.1.0/PKG-INFO → multicloud_cli-0.3.0/README.md +24 -19
  3. multicloud_cli-0.3.0/multicloud_cli/__init__.py +1 -0
  4. multicloud_cli-0.3.0/multicloud_cli/account.py +86 -0
  5. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/api.py +18 -10
  6. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/auth.py +5 -5
  7. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/catalog.py +3 -3
  8. multicloud_cli-0.3.0/multicloud_cli/extend.py +218 -0
  9. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/main.py +10 -0
  10. multicloud_cli-0.3.0/multicloud_cli/net.py +30 -0
  11. multicloud_cli-0.3.0/multicloud_cli/node.py +60 -0
  12. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/output.py +30 -1
  13. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/pool.py +5 -19
  14. multicloud_cli-0.3.0/multicloud_cli/ssh.py +260 -0
  15. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/sso.py +3 -1
  16. multicloud_cli-0.3.0/pyproject.toml +61 -0
  17. multicloud_cli-0.3.0/tests/test_account_extend.py +346 -0
  18. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_catalog.py +31 -2
  19. multicloud_cli-0.3.0/tests/test_net.py +24 -0
  20. multicloud_cli-0.3.0/tests/test_ssh.py +353 -0
  21. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_sso_session.py +7 -6
  22. multicloud_cli-0.1.0/multicloud_cli/__init__.py +0 -1
  23. multicloud_cli-0.1.0/pyproject.toml +0 -37
  24. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/.gitignore +0 -0
  25. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/LICENSE +0 -0
  26. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/cloud_account.py +0 -0
  27. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/config.py +0 -0
  28. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/duration.py +0 -0
  29. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/exitcodes.py +0 -0
  30. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/multicloud_cli/orgopt.py +0 -0
  31. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/__init__.py +0 -0
  32. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/conftest.py +0 -0
  33. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/helpers.py +0 -0
  34. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_apply.py +0 -0
  35. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_auth.py +0 -0
  36. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_cloud_account.py +0 -0
  37. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_conventions.py +0 -0
  38. {multicloud_cli-0.1.0 → multicloud_cli-0.3.0}/tests/test_pool.py +0 -0
  39. {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
- The package is not on PyPI yet, so these commands do not work today. When it is published, install it with:
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`. See [docs/reference.md](docs/reference.md) for all options.
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
- The exit codes, shell completion and all options are in the [command reference](docs/reference.md):
131
- 0 ok, 1 error, 2 usage, 3 sign-in or access, 4 not found, 5 still pending or wait timeout, 6 pool gone.
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
- The package is not on PyPI yet, so these commands do not work today. When it is published, install it with:
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`. See [docs/reference.md](docs/reference.md) for all options.
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
- The exit codes, shell completion and all options are in the [command reference](docs/reference.md):
146
- 0 ok, 1 error, 2 usage, 3 sign-in or access, 4 not found, 5 still pending or wait timeout, 6 pool gone.
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 requests.request(method, url, json=body, timeout=TIMEOUT_S, allow_redirects=False, headers=headers,
60
- **extra)
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) -> Dict[str, Any]:
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 = requests.request("GET", url, timeout=TIMEOUT_S, headers={"X-API-Key": key},
284
- allow_redirects=False)
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 = 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)
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
- click.echo(_table(header, rows, not no_headers))
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
- click.echo(_table(("ID", "PROVIDER", "REGION", "ZONE", "NAME"), [
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: