multicloud-cli 0.2.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.2.0 → multicloud_cli-0.3.0}/PKG-INFO +3 -1
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/README.md +2 -0
- multicloud_cli-0.3.0/multicloud_cli/__init__.py +1 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/api.py +16 -8
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/main.py +4 -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.3.0/multicloud_cli/ssh.py +260 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_catalog.py +29 -1
- multicloud_cli-0.3.0/tests/test_ssh.py +353 -0
- multicloud_cli-0.2.0/multicloud_cli/__init__.py +0 -1
- multicloud_cli-0.2.0/multicloud_cli/net.py +0 -13
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/.gitignore +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/LICENSE +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/account.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/auth.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/catalog.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/cloud_account.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/config.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/duration.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/exitcodes.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/extend.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/orgopt.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/output.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/pool.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/sso.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/pyproject.toml +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/__init__.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/conftest.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/helpers.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_account_extend.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_apply.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_auth.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_cloud_account.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_conventions.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_net.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_pool.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_sso.py +0 -0
- {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_sso_session.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: multicloud-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: mctl: the Multicloud command line: sign in, connect your cloud account, run pools
|
|
5
5
|
Project-URL: Homepage, https://multicloud.io
|
|
6
6
|
Author-email: Multicloud Team <info@multicloud.io>
|
|
@@ -54,6 +54,7 @@ OpenTofu or Terraform 1.5 or later.
|
|
|
54
54
|
mctl auth login # opens your browser; sign in as you do on multicloud.io
|
|
55
55
|
mkdir multicloud-aws && cd multicloud-aws
|
|
56
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
|
|
57
58
|
mctl cloud-account apply 7 # init, plan, asks you, apply, then waits for "Ready"
|
|
58
59
|
```
|
|
59
60
|
|
|
@@ -81,6 +82,7 @@ mctl cloud-account verify 7 --wait
|
|
|
81
82
|
| `mctl overview` / `billing` | Setup checklist, alerts and pool counts; payment gate status. Read access or a member sign-in. |
|
|
82
83
|
| `mctl extend create NAME` | Make an Extend pool. Owner sign-in only. |
|
|
83
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`. |
|
|
84
86
|
|
|
85
87
|
The connect code reaches Terraform only through the environment of the `apply` process.
|
|
86
88
|
`mctl` never prints a key or a token, and only `mctl cloud-account token` prints a connect code.
|
|
@@ -23,6 +23,7 @@ OpenTofu or Terraform 1.5 or later.
|
|
|
23
23
|
mctl auth login # opens your browser; sign in as you do on multicloud.io
|
|
24
24
|
mkdir multicloud-aws && cd multicloud-aws
|
|
25
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
|
|
26
27
|
mctl cloud-account apply 7 # init, plan, asks you, apply, then waits for "Ready"
|
|
27
28
|
```
|
|
28
29
|
|
|
@@ -50,6 +51,7 @@ mctl cloud-account verify 7 --wait
|
|
|
50
51
|
| `mctl overview` / `billing` | Setup checklist, alerts and pool counts; payment gate status. Read access or a member sign-in. |
|
|
51
52
|
| `mctl extend create NAME` | Make an Extend pool. Owner sign-in only. |
|
|
52
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`. |
|
|
53
55
|
|
|
54
56
|
The connect code reaches Terraform only through the environment of the `apply` process.
|
|
55
57
|
`mctl` never prints a key or a token, and only `mctl cloud-account token` prints a connect code.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -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
59
|
return net.request(method, url, json=body, timeout=TIMEOUT_S, allow_redirects=False, headers=headers,
|
|
60
|
-
**extra)
|
|
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:
|
|
@@ -10,7 +10,9 @@ from .auth import auth
|
|
|
10
10
|
from .catalog import catalog
|
|
11
11
|
from .cloud_account import cloud_account
|
|
12
12
|
from .extend import extend, peering
|
|
13
|
+
from .node import node
|
|
13
14
|
from .pool import pool
|
|
15
|
+
from .ssh import ssh
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
def _broken_pipe() -> int:
|
|
@@ -55,6 +57,8 @@ mctl.add_command(overview)
|
|
|
55
57
|
mctl.add_command(billing)
|
|
56
58
|
mctl.add_command(extend)
|
|
57
59
|
mctl.add_command(peering)
|
|
60
|
+
mctl.add_command(ssh)
|
|
61
|
+
mctl.add_command(node)
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
def _run() -> int:
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
from requests.adapters import HTTPAdapter
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
USER_AGENT = f"mctl/{__version__}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IPv4Adapter(HTTPAdapter):
|
|
13
|
+
"""Connects over IPv4 only. Binding the local end to ``0.0.0.0`` makes urllib3 skip every IPv6 (AAAA) address:
|
|
14
|
+
its socket cannot bind there, so the next address is tried."""
|
|
15
|
+
|
|
16
|
+
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
|
17
|
+
pool_kwargs["source_address"] = ("0.0.0.0", 0)
|
|
18
|
+
super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def request(method: str, url: str, ipv4_only: bool = False, **kw: Any) -> requests.Response:
|
|
22
|
+
"""One request. ``ipv4_only`` connects over IPv4 only: the SSH grant routes read the address of the caller as
|
|
23
|
+
the API sees it, and ``api.multicloud.io`` has AAAA records."""
|
|
24
|
+
kw["headers"] = {"User-Agent": USER_AGENT, **(kw.get("headers") or {})}
|
|
25
|
+
if ipv4_only:
|
|
26
|
+
with requests.Session() as session:
|
|
27
|
+
session.mount("https://", IPv4Adapter())
|
|
28
|
+
session.mount("http://", IPv4Adapter())
|
|
29
|
+
return session.request(method, url, **kw)
|
|
30
|
+
return requests.request(method, url, **kw)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""``mctl node``: the nodes of the platform clusters, for operators.
|
|
2
|
+
|
|
3
|
+
These commands call ``GET /api/v1/operator/nodes`` (not scoped to an organization). They need the SSO sign-in of
|
|
4
|
+
an operator (realm role ``multicloud-admins``). ``mctl ssh NODE`` uses the same list.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import api, exitcodes
|
|
11
|
+
from .orgopt import org_group
|
|
12
|
+
from .output import dump as _dump, echo as _echo, json_option, no_headers_option, quiet_option, run, table as _table
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NodeError(exitcodes.CliError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fetch_nodes(org, cluster: Optional[str] = None) -> Dict[str, Any]:
|
|
20
|
+
"""The answer of ``GET /operator/nodes``; ``cluster`` is a cluster id or slug."""
|
|
21
|
+
try:
|
|
22
|
+
return api.call("GET", org, "/operator/nodes", org_scoped=False, prefer_sso=True,
|
|
23
|
+
params={"cluster": cluster} if cluster else None)
|
|
24
|
+
except api.ApiError as ex:
|
|
25
|
+
text = str(ex)
|
|
26
|
+
if ex.status == 403:
|
|
27
|
+
text += " (These commands need the multicloud-admins role.)"
|
|
28
|
+
raise NodeError(text, ex.exit_code)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_nodes(nodes: List[Dict[str, Any]], headers: bool = True) -> str:
|
|
32
|
+
return _table(("NODE", "CLUSTER", "PROVIDER", "REGION", "IP", "STATUS"), [
|
|
33
|
+
(str(n.get("id")), str(n.get("cluster_slug") or n.get("cluster_id")), str(n.get("provider")),
|
|
34
|
+
str(n.get("region")), str(n.get("public_ip") or "-"), str(n.get("status"))) for n in nodes], headers)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group(cls=org_group("node_org"))
|
|
38
|
+
@click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG)")
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def node(ctx, org):
|
|
41
|
+
"""List the nodes of the platform clusters (operators only; sign in first: mctl auth login)."""
|
|
42
|
+
ctx.ensure_object(dict)
|
|
43
|
+
ctx.obj["node_org"] = org
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@node.command("list")
|
|
47
|
+
@click.option("--cluster", default=None, help="Only the nodes of this cluster (id or slug)")
|
|
48
|
+
@json_option
|
|
49
|
+
@quiet_option
|
|
50
|
+
@no_headers_option
|
|
51
|
+
@click.pass_context
|
|
52
|
+
def list_nodes(ctx, cluster, as_json, quiet, no_headers):
|
|
53
|
+
"""List nodes: node id, cluster, provider, region, public IP and status."""
|
|
54
|
+
data = run(ctx.obj.get("node_org"), lambda org: fetch_nodes(org, cluster), (NodeError,))
|
|
55
|
+
if as_json:
|
|
56
|
+
return _dump(data)
|
|
57
|
+
nodes = data.get("nodes", [])
|
|
58
|
+
if quiet:
|
|
59
|
+
return _echo("\n".join(str(n.get("id")) for n in nodes))
|
|
60
|
+
_echo(format_nodes(nodes, not no_headers))
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""``mctl ssh``: open tcp/22 on the platform nodes for your own IP, and connect.
|
|
2
|
+
|
|
3
|
+
These commands call ``/api/v1/ssh-access`` and ``/api/v1/operator/nodes`` (not scoped to an organization). They
|
|
4
|
+
need the SSO sign-in of an operator (realm role ``multicloud-admins``). Every ssh-access call goes over IPv4
|
|
5
|
+
only: the API takes the address of the caller from the connection, and ``api.multicloud.io`` has AAAA records,
|
|
6
|
+
so an IPv6 connection would open a rule for an address that SSH never uses.
|
|
7
|
+
``mctl ssh NODE`` finds your grant, opens or extends one when needed, waits for the firewall rule and runs ``ssh``.
|
|
8
|
+
A ``NODE`` is a node id or a public IP (see ``mctl node list``).
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from . import api, exitcodes
|
|
20
|
+
from .duration import duration_option
|
|
21
|
+
from .node import NodeError, fetch_nodes
|
|
22
|
+
from .orgopt import org_group
|
|
23
|
+
from .output import dump as _dump, echo as _echo, json_option, no_headers_option, plain as _plain, quiet_option, run, table as _table
|
|
24
|
+
|
|
25
|
+
PORT = 22
|
|
26
|
+
MIN_TTL_S, MAX_TTL_S = 300, 28800 # the API accepts 5 minutes to 8 hours
|
|
27
|
+
REFRESH_BELOW_S = 30 * 60 # mctl ssh extends a grant that has this much time left or less
|
|
28
|
+
WAIT_S, WAIT_EVERY_S, PROBE_S = 150, 5, 3.0
|
|
29
|
+
DEFAULT_USER = "multicloud"
|
|
30
|
+
_sleep = time.sleep # tests patch these names
|
|
31
|
+
_clock = time.monotonic
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SshError(exitcodes.CliError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stdin_is_tty() -> bool:
|
|
39
|
+
return sys.stdin.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _port_open(ip: str, port: int = PORT, timeout_s: float = PROBE_S) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
with socket.create_connection((ip, port), timeout=timeout_s):
|
|
45
|
+
return True
|
|
46
|
+
except OSError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wait_for_port(ip: str, port: int = PORT, timeout_s: int = WAIT_S, every_s: int = WAIT_EVERY_S,
|
|
51
|
+
note: Optional[str] = None) -> bool:
|
|
52
|
+
"""Try a TCP connect (3 s timeout) every ``every_s`` seconds until it works or ``timeout_s`` passed.
|
|
53
|
+
``note`` is printed to stderr once, after the first failed try."""
|
|
54
|
+
deadline = _clock() + timeout_s
|
|
55
|
+
noted = False
|
|
56
|
+
while True:
|
|
57
|
+
if _port_open(ip, port):
|
|
58
|
+
return True
|
|
59
|
+
if note and not noted:
|
|
60
|
+
click.echo(note, err=True)
|
|
61
|
+
noted = True
|
|
62
|
+
if _clock() >= deadline:
|
|
63
|
+
return False
|
|
64
|
+
_sleep(every_s)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _call(org, method: str, path: str, **kw) -> Dict[str, Any]:
|
|
68
|
+
"""A grant call: always over IPv4."""
|
|
69
|
+
try:
|
|
70
|
+
return api.call(method, org, path, org_scoped=False, ipv4_only=True, prefer_sso=True, **kw)
|
|
71
|
+
except api.ApiError as ex:
|
|
72
|
+
text = str(ex)
|
|
73
|
+
if ex.status == 403:
|
|
74
|
+
text += " (These commands need the multicloud-admins role.)"
|
|
75
|
+
raise SshError(text, ex.exit_code)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _source(org) -> str:
|
|
79
|
+
data = _call(org, "GET", "/ssh-access/source")
|
|
80
|
+
source = str(data.get("source") or "")
|
|
81
|
+
if data.get("family", 4) != 4 or ":" in source or not source:
|
|
82
|
+
raise SshError(f"The API sees your address as '{source}', not as IPv4. SSH grants need an IPv4 source. "
|
|
83
|
+
"Check your network: curl -4 https://checkip.amazonaws.com")
|
|
84
|
+
return source
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _seconds_left(g: Dict[str, Any]) -> float:
|
|
88
|
+
try:
|
|
89
|
+
at = datetime.fromisoformat(str(g.get("expires_at")).replace("Z", "+00:00"))
|
|
90
|
+
except ValueError:
|
|
91
|
+
return 0
|
|
92
|
+
if at.tzinfo is None:
|
|
93
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
94
|
+
return (at - datetime.now(timezone.utc)).total_seconds()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _scope(g: Dict[str, Any]) -> str:
|
|
98
|
+
return f"cluster {g['cluster_id']}" if g.get("cluster_id") is not None else "all platform clusters"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cluster_id(org, ref: Optional[str]) -> Optional[int]:
|
|
102
|
+
"""The id for ``--cluster``: a number as it is, a slug through the nodes of that cluster."""
|
|
103
|
+
if ref is None:
|
|
104
|
+
return None
|
|
105
|
+
if ref.isascii() and ref.isdigit():
|
|
106
|
+
return int(ref)
|
|
107
|
+
nodes = fetch_nodes(org, ref).get("nodes", [])
|
|
108
|
+
if not nodes:
|
|
109
|
+
raise SshError(f"No nodes found for cluster '{ref}', so its id is unknown. "
|
|
110
|
+
"If the cluster exists, use its numeric id.", exitcodes.NOT_FOUND)
|
|
111
|
+
return int(nodes[0]["cluster_id"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SshGroup(org_group("ssh_org")):
|
|
115
|
+
"""A group whose first argument may be a node: ``mctl ssh 12`` runs the hidden ``connect`` command."""
|
|
116
|
+
|
|
117
|
+
def resolve_command(self, ctx, args):
|
|
118
|
+
if args and not args[0].startswith("-") and self.get_command(ctx, args[0]) is None:
|
|
119
|
+
return "connect", self.commands["connect"], args
|
|
120
|
+
return super().resolve_command(ctx, args)
|
|
121
|
+
|
|
122
|
+
def collect_usage_pieces(self, ctx):
|
|
123
|
+
return ["[OPTIONS] COMMAND [ARGS]...", "|", "NODE [-y] [-- SSH_ARGS]..."]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@click.group(cls=SshGroup)
|
|
127
|
+
@click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG)")
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def ssh(ctx, org):
|
|
130
|
+
"""Open SSH access to platform nodes and connect (operators only; sign in first: mctl auth login).
|
|
131
|
+
|
|
132
|
+
mctl ssh NODE [-y|--yes] [-- SSH_ARGS]... connects to a node (id or public IP from mctl node list).
|
|
133
|
+
It opens tcp/22 for your IP when needed, waits for the firewall rule, then runs ssh. Everything after --
|
|
134
|
+
goes to ssh. The grant calls use IPv4 only."""
|
|
135
|
+
ctx.ensure_object(dict)
|
|
136
|
+
ctx.obj["ssh_org"] = org
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _run(ctx, fn):
|
|
140
|
+
return run(ctx.obj.get("ssh_org"), fn, (SshError, NodeError, api.ApiError))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _grant_text(row: Dict[str, Any]) -> str:
|
|
144
|
+
return f"grant {row.get('id')}: tcp/22 open for {row.get('cidr')} until {row.get('expires_at')} ({_scope(row)})"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@ssh.command()
|
|
148
|
+
@click.option("--for", "ttl", default="1h", show_default=True, callback=duration_option,
|
|
149
|
+
help="How long the rule lasts: 5m to 8h (30m, 1h, 90s)")
|
|
150
|
+
@click.option("--cluster", default=None, help="Only this cluster (id or slug). Default: all platform clusters")
|
|
151
|
+
@json_option
|
|
152
|
+
@click.pass_context
|
|
153
|
+
def allow(ctx, ttl, cluster, as_json):
|
|
154
|
+
"""Open tcp/22 for your IP, or extend the grant you have."""
|
|
155
|
+
def go(org):
|
|
156
|
+
if not MIN_TTL_S <= ttl <= MAX_TTL_S:
|
|
157
|
+
raise SshError(f"--for must be between 5m and 8h, not {ttl} seconds.", exitcodes.USAGE)
|
|
158
|
+
return _call(org, "POST", "/ssh-access", body={"cluster_id": _cluster_id(org, cluster), "ttl_seconds": ttl})
|
|
159
|
+
data = _run(ctx, go)
|
|
160
|
+
if as_json:
|
|
161
|
+
return _dump(data)
|
|
162
|
+
click.echo(f"{'Extended' if data.get('_status') == 200 else 'Created'} {_grant_text(_plain(data))}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@ssh.command("list")
|
|
166
|
+
@click.option("--include-expired", is_flag=True, help="Also the grants that ended in the last 7 days")
|
|
167
|
+
@json_option
|
|
168
|
+
@quiet_option
|
|
169
|
+
@no_headers_option
|
|
170
|
+
@click.pass_context
|
|
171
|
+
def list_(ctx, include_expired, as_json, quiet, no_headers):
|
|
172
|
+
"""List the active grants (all people, all kinds)."""
|
|
173
|
+
params = {"include_expired": "true"} if include_expired else None
|
|
174
|
+
data = _run(ctx, lambda org: _call(org, "GET", "/ssh-access", params=params))
|
|
175
|
+
if as_json:
|
|
176
|
+
return _dump(data)
|
|
177
|
+
grants = data.get("grants", [])
|
|
178
|
+
if quiet:
|
|
179
|
+
return _echo("\n".join(str(g.get("id")) for g in grants))
|
|
180
|
+
_echo(_table(("ID", "KIND", "CIDR", "CLUSTER", "CREATED_BY", "EXPIRES_AT"), [
|
|
181
|
+
(str(g.get("id")), str(g.get("kind")), str(g.get("cidr")),
|
|
182
|
+
str(g["cluster_id"]) if g.get("cluster_id") is not None else "-", str(g.get("created_by") or "-"),
|
|
183
|
+
str(g.get("expires_at"))) for g in grants], not no_headers))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@ssh.command()
|
|
187
|
+
@click.argument("grant_id", metavar="ID", type=int)
|
|
188
|
+
@json_option
|
|
189
|
+
@click.pass_context
|
|
190
|
+
def revoke(ctx, grant_id, as_json):
|
|
191
|
+
"""Revoke a grant. The firewall rule ends within about 2 minutes."""
|
|
192
|
+
data = _run(ctx, lambda org: _call(org, "DELETE", f"/ssh-access/{grant_id}"))
|
|
193
|
+
if as_json:
|
|
194
|
+
return _dump(data)
|
|
195
|
+
click.echo(f"Revoked grant {grant_id}.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _find_node(org, ref: str) -> Dict[str, Any]:
|
|
199
|
+
nodes: List[Dict[str, Any]] = fetch_nodes(org).get("nodes", [])
|
|
200
|
+
found = [n for n in nodes if str(n.get("id")) == ref] or [n for n in nodes if n.get("public_ip") == ref]
|
|
201
|
+
if not found:
|
|
202
|
+
raise SshError(f"No node '{ref}'. Run: mctl node list", exitcodes.NOT_FOUND)
|
|
203
|
+
return found[0]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _exec(node: Dict[str, Any], extra: Tuple[str, ...]) -> None:
|
|
207
|
+
sys.stdout.flush()
|
|
208
|
+
sys.stderr.flush()
|
|
209
|
+
try:
|
|
210
|
+
os.execvp("ssh", ["ssh", f"{node.get('ssh_user') or DEFAULT_USER}@{node['public_ip']}", *extra])
|
|
211
|
+
except OSError as ex:
|
|
212
|
+
raise SshError(f"Could not run ssh: {ex}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _connect(org, ref: str, yes: bool, extra: Tuple[str, ...]) -> None:
|
|
216
|
+
node = _find_node(org, ref)
|
|
217
|
+
ip = node.get("public_ip")
|
|
218
|
+
if not ip:
|
|
219
|
+
raise SshError(f"Node {node.get('id')} has no public IP yet (status: {node.get('status')}). "
|
|
220
|
+
"Try again when the node is ready.")
|
|
221
|
+
cluster_id = node["cluster_id"]
|
|
222
|
+
source = _source(org)
|
|
223
|
+
grants = _call(org, "GET", "/ssh-access").get("grants", [])
|
|
224
|
+
mine = [g for g in grants if str(g.get("kind")).upper() == "OPERATOR" and g.get("cidr") == f"{source}/32"
|
|
225
|
+
and g.get("cluster_id") in (cluster_id, None)]
|
|
226
|
+
exact = [g for g in mine if g.get("cluster_id") == cluster_id]
|
|
227
|
+
if exact:
|
|
228
|
+
best = max(exact, key=_seconds_left)
|
|
229
|
+
grant_id = best["id"]
|
|
230
|
+
if _seconds_left(best) <= REFRESH_BELOW_S:
|
|
231
|
+
_call(org, "POST", "/ssh-access", body={"cluster_id": cluster_id}) # extends the same grant
|
|
232
|
+
elif mine and _port_open(ip, PORT):
|
|
233
|
+
# A grant for all platform clusters, and the port answers: the node is on a platform cluster.
|
|
234
|
+
return _exec(node, extra)
|
|
235
|
+
else:
|
|
236
|
+
# No grant, or only a grant for all platform clusters and the port is closed (a customer pool node).
|
|
237
|
+
slug = node.get("cluster_slug") or cluster_id
|
|
238
|
+
if not yes:
|
|
239
|
+
if not _stdin_is_tty():
|
|
240
|
+
raise SshError(f"Your IP {source} has no SSH access to {slug}. Run: "
|
|
241
|
+
f"mctl ssh allow --cluster {cluster_id} (then run this command again, "
|
|
242
|
+
"or add --yes)", exitcodes.PENDING)
|
|
243
|
+
if not click.confirm(f"Your IP {source} has no SSH access to {slug}. "
|
|
244
|
+
f"Open tcp/22 for {source} for 1h?", default=True, err=True):
|
|
245
|
+
raise SshError("You answered no. No SSH access was opened.")
|
|
246
|
+
grant_id = _call(org, "POST", "/ssh-access", body={"cluster_id": cluster_id})["id"]
|
|
247
|
+
if not wait_for_port(ip, PORT, WAIT_S, WAIT_EVERY_S, note=f"waiting for the firewall rule (grant {grant_id})"):
|
|
248
|
+
raise SshError(f"tcp/22 on {ip} did not open within {WAIT_S} s (grant {grant_id}). The scheduler applies "
|
|
249
|
+
f"grants about every minute. Run the command again in a minute.", exitcodes.PENDING)
|
|
250
|
+
_exec(node, extra)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@ssh.command("connect", hidden=True)
|
|
254
|
+
@click.argument("node_ref", metavar="NODE")
|
|
255
|
+
@click.argument("ssh_args", nargs=-1, type=click.UNPROCESSED, metavar="[SSH_ARGS]...")
|
|
256
|
+
@click.option("-y", "--yes", is_flag=True, help="Open tcp/22 without asking")
|
|
257
|
+
@click.pass_context
|
|
258
|
+
def connect(ctx, node_ref, ssh_args, yes):
|
|
259
|
+
"""Connect to a node (id or public IP); put the ssh options after --."""
|
|
260
|
+
_run(ctx, lambda org: _connect(org, node_ref, yes, ssh_args))
|
|
@@ -5,7 +5,7 @@ import pytest
|
|
|
5
5
|
import requests
|
|
6
6
|
from click.testing import CliRunner
|
|
7
7
|
|
|
8
|
-
from multicloud_cli import api, auth
|
|
8
|
+
from multicloud_cli import api, auth, exitcodes
|
|
9
9
|
from multicloud_cli.catalog import FILTERS
|
|
10
10
|
from multicloud_cli.main import mctl
|
|
11
11
|
|
|
@@ -324,3 +324,31 @@ def test_a_stored_key_of_the_named_org_wins_over_sso_for_the_catalog(net, ring,
|
|
|
324
324
|
monkeypatch.setattr(auth, "fresh_access_token", lambda issuer, **kw: "tok")
|
|
325
325
|
assert invoke("--org", "acme", "providers").exit_code == 0
|
|
326
326
|
assert net["calls"][-1][2]["headers"].get("X-API-Key") == "stored-key"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_stored_key_wins_on_catalog_but_prefer_sso_keeps_the_token(net, ring, monkeypatch):
|
|
330
|
+
monkeypatch.delenv("MULTICLOUD_API_KEY")
|
|
331
|
+
token = sso_login(ring, monkeypatch)
|
|
332
|
+
auth.store_key("acme", "mca_stored")
|
|
333
|
+
api.call("GET", "acme", "/catalog/providers", org_scoped=False)
|
|
334
|
+
assert net["calls"][0][2]["headers"]["X-API-Key"] == "mca_stored"
|
|
335
|
+
api.call("GET", "acme", "/ssh-access", org_scoped=False, prefer_sso=True)
|
|
336
|
+
headers = net["calls"][1][2]["headers"]
|
|
337
|
+
assert headers["Authorization"] == f"Bearer {token}" and "X-API-Key" not in headers
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_prefer_sso_beats_the_env_key(net, ring, monkeypatch):
|
|
341
|
+
token = sso_login(ring, monkeypatch) # MULTICLOUD_API_KEY stays set by the net fixture
|
|
342
|
+
api.call("GET", None, "/ssh-access", org_scoped=False, prefer_sso=True)
|
|
343
|
+
headers = net["calls"][0][2]["headers"]
|
|
344
|
+
assert headers["Authorization"] == f"Bearer {token}" and "X-API-Key" not in headers
|
|
345
|
+
api.call("GET", None, "/catalog/providers", org_scoped=False)
|
|
346
|
+
assert net["calls"][1][2]["headers"]["X-API-Key"] == "fake-key-0000"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_prefer_sso_without_sso_login_asks_for_it(net, ring):
|
|
350
|
+
# MULTICLOUD_API_KEY stays set by the net fixture; an operator route never accepts it.
|
|
351
|
+
with pytest.raises(api.ApiError) as ex:
|
|
352
|
+
api.call("GET", None, "/ssh-access", org_scoped=False, prefer_sso=True)
|
|
353
|
+
assert "mctl auth login" in str(ex.value) and ex.value.exit_code == exitcodes.AUTH
|
|
354
|
+
assert net["calls"] == []
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""mctl ssh and mctl node: SSH access grants for operators. The API is faked at the ``api.call`` seam."""
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import requests
|
|
7
|
+
from click.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from multicloud_cli import api, net
|
|
10
|
+
from multicloud_cli import ssh as ssh_mod
|
|
11
|
+
from multicloud_cli.main import mctl
|
|
12
|
+
|
|
13
|
+
SOURCE = "46.116.212.65"
|
|
14
|
+
NODE = {"id": 12, "cluster_id": 5, "cluster_slug": "platform-a", "provider": "aws", "region": "us-east-1",
|
|
15
|
+
"public_ip": "203.0.113.7", "status": "ready", "ssh_user": "ubuntu"}
|
|
16
|
+
NODE_BARE = {"id": 13, "cluster_id": 5, "cluster_slug": "platform-a", "provider": "gce", "region": "europe-west4",
|
|
17
|
+
"public_ip": "203.0.113.8", "status": "ready", "ssh_user": None}
|
|
18
|
+
NODE_NO_IP = {"id": 14, "cluster_id": 5, "cluster_slug": "platform-a", "provider": "aws", "region": "us-east-1",
|
|
19
|
+
"public_ip": None, "status": "provisioning", "ssh_user": None}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def runner():
|
|
23
|
+
try:
|
|
24
|
+
return CliRunner(mix_stderr=False)
|
|
25
|
+
except TypeError:
|
|
26
|
+
return CliRunner()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def iso(seconds_from_now):
|
|
30
|
+
return (datetime.now(timezone.utc) + timedelta(seconds=seconds_from_now)).isoformat()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def grant(gid=7, cluster_id=5, left_s=7200, cidr=SOURCE + "/32", kind="OPERATOR"):
|
|
34
|
+
return {"id": gid, "kind": kind, "cidr": cidr, "cluster_id": cluster_id, "org_slug": None,
|
|
35
|
+
"created_by": "ada@example.test", "created_at": iso(-60), "expires_at": iso(left_s), "revoked_at": None}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FakeApi:
|
|
39
|
+
"""Answers the routes of the ssh-access API in memory and records every call."""
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self.calls = []
|
|
43
|
+
self.source = {"source": SOURCE, "family": 4}
|
|
44
|
+
self.grants = []
|
|
45
|
+
self.nodes = [NODE, NODE_BARE, NODE_NO_IP]
|
|
46
|
+
self.created = grant(gid=99, cluster_id=5, left_s=3600)
|
|
47
|
+
|
|
48
|
+
def posts(self):
|
|
49
|
+
return [c for c in self.calls if c[0] == "POST"]
|
|
50
|
+
|
|
51
|
+
def __call__(self, method, org, path, **kw):
|
|
52
|
+
self.calls.append((method, path, kw))
|
|
53
|
+
if path == "/ssh-access/source":
|
|
54
|
+
return {**self.source, "_status": 200}
|
|
55
|
+
if path == "/ssh-access" and method == "GET":
|
|
56
|
+
return {"grants": self.grants, "_status": 200}
|
|
57
|
+
if path == "/ssh-access" and method == "POST":
|
|
58
|
+
body = kw["body"]
|
|
59
|
+
return {**self.created, "cluster_id": body.get("cluster_id"), "_status": 201}
|
|
60
|
+
if path.startswith("/ssh-access/") and method == "DELETE":
|
|
61
|
+
return {**grant(gid=int(path.rsplit("/", 1)[1])), "revoked_at": iso(0), "_status": 200}
|
|
62
|
+
if path == "/operator/nodes":
|
|
63
|
+
wanted = (kw.get("params") or {}).get("cluster")
|
|
64
|
+
nodes = [n for n in self.nodes if wanted is None or wanted in (str(n["cluster_id"]), n["cluster_slug"])]
|
|
65
|
+
return {"nodes": nodes, "_status": 200}
|
|
66
|
+
raise AssertionError(f"unexpected call {method} {path}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture
|
|
70
|
+
def fake(monkeypatch):
|
|
71
|
+
f = FakeApi()
|
|
72
|
+
monkeypatch.setattr(api, "call", f)
|
|
73
|
+
state = {"probes": [True], "clock": 0.0, "slept": [], "exec": [], "tty": False}
|
|
74
|
+
|
|
75
|
+
def probe(ip, port=22, timeout_s=3.0):
|
|
76
|
+
state.setdefault("probed", []).append((ip, port))
|
|
77
|
+
return state["probes"].pop(0) if len(state["probes"]) > 1 else state["probes"][0]
|
|
78
|
+
|
|
79
|
+
def sleep(s):
|
|
80
|
+
state["slept"].append(s)
|
|
81
|
+
state["clock"] += s
|
|
82
|
+
monkeypatch.setattr(ssh_mod, "_port_open", probe)
|
|
83
|
+
monkeypatch.setattr(ssh_mod, "_sleep", sleep)
|
|
84
|
+
monkeypatch.setattr(ssh_mod, "_clock", lambda: state["clock"])
|
|
85
|
+
monkeypatch.setattr(ssh_mod.os, "execvp", lambda file, args: state["exec"].append((file, list(args))))
|
|
86
|
+
monkeypatch.setattr(ssh_mod, "_stdin_is_tty", lambda: state["tty"])
|
|
87
|
+
f.state = state
|
|
88
|
+
return f
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def invoke(*args, **kw):
|
|
92
|
+
return runner().invoke(mctl, list(args), **kw)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- allow, list, revoke -----------------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def test_allow_default_one_hour(fake):
|
|
98
|
+
r = invoke("ssh", "allow")
|
|
99
|
+
assert r.exit_code == 0, r.stderr
|
|
100
|
+
method, path, kw = fake.calls[0]
|
|
101
|
+
assert (method, path, kw["body"]) == ("POST", "/ssh-access", {"cluster_id": None, "ttl_seconds": 3600})
|
|
102
|
+
assert "99" in r.stdout and SOURCE + "/32" in r.stdout and fake.created["expires_at"] in r.stdout
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_allow_for_and_cluster_id(fake):
|
|
106
|
+
r = invoke("ssh", "allow", "--for", "2h", "--cluster", "5")
|
|
107
|
+
assert r.exit_code == 0, r.stderr
|
|
108
|
+
assert fake.calls[0][2]["body"] == {"cluster_id": 5, "ttl_seconds": 7200}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_allow_cluster_slug_resolves_to_id(fake):
|
|
112
|
+
r = invoke("ssh", "allow", "--cluster", "platform-a")
|
|
113
|
+
assert r.exit_code == 0, r.stderr
|
|
114
|
+
assert fake.calls[0][:2] == ("GET", "/operator/nodes") and fake.calls[0][2]["params"] == {"cluster": "platform-a"}
|
|
115
|
+
assert fake.calls[1][2]["body"]["cluster_id"] == 5
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_allow_unknown_cluster_slug_is_not_found(fake):
|
|
119
|
+
r = invoke("ssh", "allow", "--cluster", "nope")
|
|
120
|
+
assert r.exit_code == 4 and "numeric id" in r.stderr and not fake.posts()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize("value", ["9h", "29000", "1m"])
|
|
124
|
+
def test_for_over_eight_hours_is_usage_error(fake, value):
|
|
125
|
+
r = invoke("ssh", "allow", "--for", value)
|
|
126
|
+
assert r.exit_code == 2 and not fake.calls
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_allow_eight_hours_is_accepted(fake):
|
|
130
|
+
assert invoke("ssh", "allow", "--for", "8h").exit_code == 0
|
|
131
|
+
assert fake.calls[0][2]["body"]["ttl_seconds"] == 28800
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_allow_uses_ipv4_transport(fake):
|
|
135
|
+
fake.grants = [grant()]
|
|
136
|
+
assert invoke("ssh", "allow").exit_code == 0
|
|
137
|
+
assert invoke("ssh", "list").exit_code == 0
|
|
138
|
+
assert invoke("ssh", "revoke", "7").exit_code == 0
|
|
139
|
+
fake.state["tty"] = False
|
|
140
|
+
assert invoke("ssh", "12", "--yes").exit_code == 0
|
|
141
|
+
grant_calls = [c for c in fake.calls if c[1].startswith("/ssh-access")]
|
|
142
|
+
assert len(grant_calls) == 5
|
|
143
|
+
assert all(c[2].get("ipv4_only") is True for c in grant_calls), grant_calls
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_ipv4_adapter_binds_ipv4():
|
|
147
|
+
adapter = net.IPv4Adapter()
|
|
148
|
+
assert adapter.poolmanager.connection_pool_kw["source_address"] == ("0.0.0.0", 0)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_api_call_passes_ipv4_only_to_the_transport(monkeypatch):
|
|
152
|
+
from .helpers import reply
|
|
153
|
+
monkeypatch.setenv("MULTICLOUD_API_KEY", "fake-key-0000")
|
|
154
|
+
seen = []
|
|
155
|
+
monkeypatch.setattr(net, "request", lambda m, u, **kw: seen.append(kw) or reply(200, {"source": SOURCE}))
|
|
156
|
+
api.call("GET", None, "/ssh-access/source", org_scoped=False, ipv4_only=True)
|
|
157
|
+
api.call("GET", None, "/operator/nodes", org_scoped=False)
|
|
158
|
+
assert [kw["ipv4_only"] for kw in seen] == [True, False]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_net_request_ipv4_only_uses_the_adapter(monkeypatch):
|
|
162
|
+
seen = {}
|
|
163
|
+
|
|
164
|
+
def session_request(self, method, url, **kw):
|
|
165
|
+
seen["adapter"] = self.get_adapter(url)
|
|
166
|
+
seen["kw"] = kw
|
|
167
|
+
return "r"
|
|
168
|
+
monkeypatch.setattr(requests.Session, "request", session_request)
|
|
169
|
+
monkeypatch.setattr(requests, "request", lambda *a, **kw: pytest.fail("plain path used"))
|
|
170
|
+
assert net.request("GET", "https://api.multicloud.io/x", ipv4_only=True, timeout=1) == "r"
|
|
171
|
+
assert isinstance(seen["adapter"], net.IPv4Adapter) and "ipv4_only" not in seen["kw"]
|
|
172
|
+
assert seen["kw"]["headers"]["User-Agent"] == net.USER_AGENT
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_list_table(fake):
|
|
176
|
+
fake.grants = [grant(gid=7, cluster_id=5), grant(gid=8, cluster_id=None, kind="OPERATOR")]
|
|
177
|
+
r = invoke("ssh", "list")
|
|
178
|
+
assert r.exit_code == 0, r.stderr
|
|
179
|
+
lines = r.stdout.splitlines()
|
|
180
|
+
assert lines[0].split() == ["ID", "KIND", "CIDR", "CLUSTER", "CREATED_BY", "EXPIRES_AT"]
|
|
181
|
+
assert lines[1].split()[:5] == ["7", "OPERATOR", SOURCE + "/32", "5", "ada@example.test"]
|
|
182
|
+
assert lines[2].split()[3] == "-"
|
|
183
|
+
assert fake.calls[0][2].get("params") in (None, {})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_list_include_expired_and_json(fake):
|
|
187
|
+
fake.grants = [grant()]
|
|
188
|
+
r = invoke("ssh", "list", "--include-expired", "--json")
|
|
189
|
+
assert fake.calls[0][2]["params"] == {"include_expired": "true"}
|
|
190
|
+
assert json.loads(r.stdout) == {"grants": fake.grants}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_revoke(fake):
|
|
194
|
+
r = invoke("ssh", "revoke", "7")
|
|
195
|
+
assert r.exit_code == 0, r.stderr
|
|
196
|
+
assert fake.calls[0][:2] == ("DELETE", "/ssh-access/7") and "7" in r.stdout
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_revoke_forbidden_is_exit_3(fake, monkeypatch):
|
|
200
|
+
def forbidden(method, org, path, **kw):
|
|
201
|
+
raise api.ApiError("multicloud-admins role needed", 403)
|
|
202
|
+
monkeypatch.setattr(api, "call", forbidden)
|
|
203
|
+
r = invoke("ssh", "revoke", "7")
|
|
204
|
+
assert r.exit_code == 3 and "multicloud-admins" in r.stderr
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --- mctl ssh NODE -----------------------------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def test_ssh_node_refreshes_when_less_than_30_min_left(fake):
|
|
210
|
+
fake.grants = [grant(gid=7, cluster_id=5, left_s=600)]
|
|
211
|
+
r = invoke("ssh", "12")
|
|
212
|
+
assert r.exit_code == 0, r.stderr
|
|
213
|
+
assert [c[2]["body"] for c in fake.posts()] == [{"cluster_id": 5}]
|
|
214
|
+
assert fake.state["exec"] == [("ssh", ["ssh", "ubuntu@203.0.113.7"])]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_ssh_node_skips_refresh_when_fresh(fake):
|
|
218
|
+
fake.grants = [grant(gid=7, cluster_id=5, left_s=7200)]
|
|
219
|
+
r = invoke("ssh", "12")
|
|
220
|
+
assert r.exit_code == 0, r.stderr
|
|
221
|
+
assert not fake.posts() and fake.state["exec"]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_ssh_node_no_grant_prompts_then_creates(fake):
|
|
225
|
+
fake.state["tty"] = True
|
|
226
|
+
fake.state["probes"] = [True]
|
|
227
|
+
r = invoke("ssh", "12", input="y\n")
|
|
228
|
+
assert r.exit_code == 0, r.stderr
|
|
229
|
+
assert f"Your IP {SOURCE} has no SSH access to platform-a. Open tcp/22 for {SOURCE} for 1h?" in r.stderr
|
|
230
|
+
assert [c[2]["body"] for c in fake.posts()] == [{"cluster_id": 5}]
|
|
231
|
+
assert fake.state["probed"] == [("203.0.113.7", 22)] and fake.state["exec"]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_ssh_node_no_grant_declined_exits_without_post(fake):
|
|
235
|
+
fake.state["tty"] = True
|
|
236
|
+
r = invoke("ssh", "12", input="n\n")
|
|
237
|
+
assert r.exit_code == 1 and not fake.posts() and not fake.state["exec"]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_ssh_node_no_tty_no_yes_exits_pending_with_hint(fake):
|
|
241
|
+
r = invoke("ssh", "12")
|
|
242
|
+
assert r.exit_code == 5 and "mctl ssh allow --cluster 5" in r.stderr
|
|
243
|
+
assert not fake.posts() and not fake.state["exec"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_ssh_node_yes_flag_skips_prompt(fake):
|
|
247
|
+
r = invoke("ssh", "12", "-y")
|
|
248
|
+
assert r.exit_code == 0, r.stderr
|
|
249
|
+
assert "Open tcp/22" not in r.stderr + r.stdout
|
|
250
|
+
assert [c[2]["body"] for c in fake.posts()] == [{"cluster_id": 5}] and fake.state["exec"]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_ssh_node_by_public_ip(fake):
|
|
254
|
+
fake.grants = [grant(cluster_id=5)]
|
|
255
|
+
r = invoke("ssh", "203.0.113.8")
|
|
256
|
+
assert r.exit_code == 0, r.stderr
|
|
257
|
+
assert fake.state["exec"][0][1] == ["ssh", "multicloud@203.0.113.8"]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_grant_for_other_ip_does_not_count(fake):
|
|
261
|
+
fake.grants = [grant(cluster_id=5, cidr="198.51.100.9/32"), grant(gid=8, cluster_id=None, cidr="198.51.100.9/32")]
|
|
262
|
+
r = invoke("ssh", "12", "--yes")
|
|
263
|
+
assert r.exit_code == 0, r.stderr
|
|
264
|
+
assert len(fake.posts()) == 1 and fake.posts()[0][2]["body"] == {"cluster_id": 5}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_grant_of_another_cluster_or_kind_does_not_count(fake):
|
|
268
|
+
fake.grants = [grant(cluster_id=6), grant(gid=8, cluster_id=5, kind="CI")]
|
|
269
|
+
r = invoke("ssh", "12", "--yes")
|
|
270
|
+
assert r.exit_code == 0, r.stderr
|
|
271
|
+
assert len(fake.posts()) == 1
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_null_cluster_grant_used_when_port_answers(fake):
|
|
275
|
+
fake.grants = [grant(cluster_id=None)]
|
|
276
|
+
fake.state["probes"] = [True]
|
|
277
|
+
r = invoke("ssh", "12")
|
|
278
|
+
assert r.exit_code == 0, r.stderr
|
|
279
|
+
assert not fake.posts() and fake.state["exec"] and fake.state["probed"] == [("203.0.113.7", 22)]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_null_cluster_grant_unreachable_opens_cluster_grant(fake):
|
|
283
|
+
fake.grants = [grant(cluster_id=None)]
|
|
284
|
+
fake.state["probes"] = [False, True] # the probe fails, then the port opens after the new grant
|
|
285
|
+
r = invoke("ssh", "12", "--yes")
|
|
286
|
+
assert r.exit_code == 0, r.stderr
|
|
287
|
+
assert [c[2]["body"] for c in fake.posts()] == [{"cluster_id": 5}] and fake.state["exec"]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_ssh_node_execs_ssh_with_ssh_user(fake):
|
|
291
|
+
fake.grants = [grant(cluster_id=5)]
|
|
292
|
+
r = invoke("ssh", "12", "--", "-v", "-L", "8080:localhost:80")
|
|
293
|
+
assert r.exit_code == 0, r.stderr
|
|
294
|
+
assert fake.state["exec"] == [("ssh", ["ssh", "ubuntu@203.0.113.7", "-v", "-L", "8080:localhost:80"])]
|
|
295
|
+
fake.state["exec"].clear()
|
|
296
|
+
invoke("ssh", "13")
|
|
297
|
+
assert fake.state["exec"] == [("ssh", ["ssh", "multicloud@203.0.113.8"])]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_ssh_node_timeout_exit_pending(fake):
|
|
301
|
+
fake.grants = [grant(cluster_id=5)]
|
|
302
|
+
fake.state["probes"] = [False]
|
|
303
|
+
r = invoke("ssh", "12")
|
|
304
|
+
assert r.exit_code == 5 and not fake.state["exec"]
|
|
305
|
+
assert "waiting for the firewall rule (grant 7)" in r.stderr and r.stderr.count("waiting for the firewall") == 1
|
|
306
|
+
assert fake.state["clock"] >= 150 and set(fake.state["slept"]) == {5}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_unknown_node_exit_not_found(fake):
|
|
310
|
+
r = invoke("ssh", "999")
|
|
311
|
+
assert r.exit_code == 4 and not [c for c in fake.calls if c[1].startswith("/ssh-access")]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_node_without_public_ip_is_an_error(fake):
|
|
315
|
+
r = invoke("ssh", "14")
|
|
316
|
+
assert r.exit_code == 1 and "public IP" in r.stderr and not fake.posts()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_non_ipv4_source_is_refused(fake):
|
|
320
|
+
fake.source = {"source": "2001:db8::1", "family": 6}
|
|
321
|
+
r = invoke("ssh", "12", "--yes")
|
|
322
|
+
assert r.exit_code == 1 and "IPv4" in r.stderr and not fake.posts()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_wait_for_port_returns_true_when_it_opens(fake):
|
|
326
|
+
fake.state["probes"] = [False, False, True]
|
|
327
|
+
assert ssh_mod.wait_for_port("203.0.113.7", note="waiting") is True
|
|
328
|
+
assert fake.state["slept"] == [5, 5]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_ssh_group_help_lists_subcommands_and_node_usage():
|
|
332
|
+
r = invoke("ssh", "--help")
|
|
333
|
+
assert r.exit_code == 0
|
|
334
|
+
for word in ("allow", "list", "revoke", "NODE"):
|
|
335
|
+
assert word in r.stdout
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --- mctl node ---------------------------------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def test_node_list_table(fake):
|
|
341
|
+
r = invoke("node", "list")
|
|
342
|
+
assert r.exit_code == 0, r.stderr
|
|
343
|
+
lines = r.stdout.splitlines()
|
|
344
|
+
assert lines[0].split() == ["NODE", "CLUSTER", "PROVIDER", "REGION", "IP", "STATUS"]
|
|
345
|
+
assert lines[1].split() == ["12", "platform-a", "aws", "us-east-1", "203.0.113.7", "ready"]
|
|
346
|
+
assert lines[3].split()[4] == "-"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_node_list_cluster_filter_and_json(fake):
|
|
350
|
+
r = invoke("node", "list", "--cluster", "platform-a", "--json")
|
|
351
|
+
assert fake.calls[0][2]["params"] == {"cluster": "platform-a"}
|
|
352
|
+
assert len(json.loads(r.stdout)["nodes"]) == 3
|
|
353
|
+
assert invoke("node", "list", "-q").stdout == "12\n13\n14\n"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
@@ -1,13 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|