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.
Files changed (36) hide show
  1. multicloud_cli-0.1.0/README.md → multicloud_cli-0.2.0/PKG-INFO +53 -4
  2. multicloud_cli-0.1.0/PKG-INFO → multicloud_cli-0.2.0/README.md +22 -19
  3. multicloud_cli-0.2.0/multicloud_cli/__init__.py +1 -0
  4. multicloud_cli-0.2.0/multicloud_cli/account.py +86 -0
  5. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/api.py +3 -3
  6. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/auth.py +5 -5
  7. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/catalog.py +3 -3
  8. multicloud_cli-0.2.0/multicloud_cli/extend.py +218 -0
  9. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/main.py +6 -0
  10. multicloud_cli-0.2.0/multicloud_cli/net.py +13 -0
  11. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/output.py +30 -1
  12. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/pool.py +5 -19
  13. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/sso.py +3 -1
  14. multicloud_cli-0.2.0/pyproject.toml +61 -0
  15. multicloud_cli-0.2.0/tests/test_account_extend.py +346 -0
  16. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_catalog.py +2 -1
  17. multicloud_cli-0.2.0/tests/test_net.py +24 -0
  18. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_sso_session.py +7 -6
  19. multicloud_cli-0.1.0/multicloud_cli/__init__.py +0 -1
  20. multicloud_cli-0.1.0/pyproject.toml +0 -37
  21. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/.gitignore +0 -0
  22. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/LICENSE +0 -0
  23. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/cloud_account.py +0 -0
  24. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/config.py +0 -0
  25. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/duration.py +0 -0
  26. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/exitcodes.py +0 -0
  27. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/multicloud_cli/orgopt.py +0 -0
  28. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/__init__.py +0 -0
  29. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/conftest.py +0 -0
  30. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/helpers.py +0 -0
  31. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_apply.py +0 -0
  32. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_auth.py +0 -0
  33. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_cloud_account.py +0 -0
  34. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_conventions.py +0 -0
  35. {multicloud_cli-0.1.0 → multicloud_cli-0.2.0}/tests/test_pool.py +0 -0
  36. {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
- 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
 
@@ -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`. See [docs/reference.md](docs/reference.md) for all options.
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
- 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.
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
- 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
 
@@ -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`. See [docs/reference.md](docs/reference.md) for all options.
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
- 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.
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 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
+ **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 = 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,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 typing import Any, Callable, Dict, List, Tuple
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: