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.
Files changed (39) hide show
  1. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/PKG-INFO +3 -1
  2. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/README.md +2 -0
  3. multicloud_cli-0.3.0/multicloud_cli/__init__.py +1 -0
  4. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/api.py +16 -8
  5. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/main.py +4 -0
  6. multicloud_cli-0.3.0/multicloud_cli/net.py +30 -0
  7. multicloud_cli-0.3.0/multicloud_cli/node.py +60 -0
  8. multicloud_cli-0.3.0/multicloud_cli/ssh.py +260 -0
  9. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_catalog.py +29 -1
  10. multicloud_cli-0.3.0/tests/test_ssh.py +353 -0
  11. multicloud_cli-0.2.0/multicloud_cli/__init__.py +0 -1
  12. multicloud_cli-0.2.0/multicloud_cli/net.py +0 -13
  13. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/.gitignore +0 -0
  14. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/LICENSE +0 -0
  15. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/account.py +0 -0
  16. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/auth.py +0 -0
  17. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/catalog.py +0 -0
  18. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/cloud_account.py +0 -0
  19. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/config.py +0 -0
  20. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/duration.py +0 -0
  21. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/exitcodes.py +0 -0
  22. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/extend.py +0 -0
  23. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/orgopt.py +0 -0
  24. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/output.py +0 -0
  25. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/pool.py +0 -0
  26. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/multicloud_cli/sso.py +0 -0
  27. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/pyproject.toml +0 -0
  28. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/__init__.py +0 -0
  29. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/conftest.py +0 -0
  30. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/helpers.py +0 -0
  31. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_account_extend.py +0 -0
  32. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_apply.py +0 -0
  33. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_auth.py +0 -0
  34. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_cloud_account.py +0 -0
  35. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_conventions.py +0 -0
  36. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_net.py +0 -0
  37. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_pool.py +0 -0
  38. {multicloud_cli-0.2.0 → multicloud_cli-0.3.0}/tests/test_sso.py +0 -0
  39. {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.2.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) -> 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:
@@ -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