ssm-cli 1.1.0.dev5__tar.gz → 1.1.0.dev6__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 (35) hide show
  1. {ssm_cli-1.1.0.dev5/ssm_cli.egg-info → ssm_cli-1.1.0.dev6}/PKG-INFO +6 -3
  2. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/README.md +2 -2
  3. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/pyproject.toml +7 -0
  4. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/app.py +26 -13
  5. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/aws.py +24 -10
  6. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/click.py +7 -3
  7. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/forwarding.py +8 -6
  8. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/server.py +24 -15
  9. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6/ssm_cli.egg-info}/PKG-INFO +6 -3
  10. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/SOURCES.txt +5 -1
  11. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/requires.txt +4 -0
  12. ssm_cli-1.1.0.dev6/tests/test_aws.py +172 -0
  13. ssm_cli-1.1.0.dev6/tests/test_cli.py +62 -0
  14. ssm_cli-1.1.0.dev6/tests/test_config.py +39 -0
  15. ssm_cli-1.1.0.dev6/tests/test_helpers.py +101 -0
  16. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/LICENCE +0 -0
  17. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/setup.cfg +0 -0
  18. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/__init__.py +0 -0
  19. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/__main__.py +0 -0
  20. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/cli.py +0 -0
  21. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/config.py +0 -0
  22. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/instances.py +0 -0
  23. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/logging.py +0 -0
  24. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/selectors/__init__.py +0 -0
  25. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/selectors/first.py +0 -0
  26. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/selectors/tui.py +0 -0
  27. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/__init__.py +0 -0
  28. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/channels.py +0 -0
  29. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/shell.py +0 -0
  30. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/socket.py +0 -0
  31. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ui.py +0 -0
  32. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/xdg.py +0 -0
  33. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/dependency_links.txt +0 -0
  34. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/entry_points.txt +0 -0
  35. {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssm-cli
3
- Version: 1.1.0.dev5
3
+ Version: 1.1.0.dev6
4
4
  Summary: CLI tool to help with SSM functionality, aimed at adminstrators
5
5
  Author-email: Simon Fletcher <simon.fletcher@lexisnexisrisk.com>
6
6
  License: MIT License
@@ -36,10 +36,13 @@ License-File: LICENCE
36
36
  Requires-Dist: boto3
37
37
  Requires-Dist: paramiko
38
38
  Requires-Dist: rich
39
+ Requires-Dist: readchar
39
40
  Requires-Dist: confclasses>=0.3.2
40
41
  Requires-Dist: xdg_base_dirs
41
42
  Requires-Dist: rich-click
42
43
  Requires-Dist: psutil
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest; extra == "test"
43
46
  Dynamic: license-file
44
47
 
45
48
  # SSM CLI
@@ -97,7 +100,7 @@ I recommend using conditions in some way to control fine grained access.
97
100
  "Sid": "FirstStatement",
98
101
  "Effect": "Allow",
99
102
  "Action": [
100
- "tag:GetResources",
103
+ "resourcegroupstaggingapi:GetResources",
101
104
  "ssm:DescribeInstanceInformation",
102
105
  "ssm:StartSession"
103
106
  ],
@@ -131,7 +134,7 @@ Adding to the ssh config (typically `~/.ssh/config`) and using ssh client as an
131
134
  ```bash
132
135
  cat >> ~/.ssh/config << EOL
133
136
  Host bastion
134
- ProxyCommand ssm proxycommand bastion_group
137
+ ProxyCommand ssm sshproxy bastion_group
135
138
  EOL
136
139
 
137
140
  ssh bastion -L 3306:database.host:3306
@@ -53,7 +53,7 @@ I recommend using conditions in some way to control fine grained access.
53
53
  "Sid": "FirstStatement",
54
54
  "Effect": "Allow",
55
55
  "Action": [
56
- "tag:GetResources",
56
+ "resourcegroupstaggingapi:GetResources",
57
57
  "ssm:DescribeInstanceInformation",
58
58
  "ssm:StartSession"
59
59
  ],
@@ -87,7 +87,7 @@ Adding to the ssh config (typically `~/.ssh/config`) and using ssh client as an
87
87
  ```bash
88
88
  cat >> ~/.ssh/config << EOL
89
89
  Host bastion
90
- ProxyCommand ssm proxycommand bastion_group
90
+ ProxyCommand ssm sshproxy bastion_group
91
91
  EOL
92
92
 
93
93
  ssh bastion -L 3306:database.host:3306
@@ -20,6 +20,7 @@ dependencies = [
20
20
  "boto3",
21
21
  "paramiko",
22
22
  "rich",
23
+ "readchar",
23
24
  "confclasses>=0.3.2",
24
25
  "xdg_base_dirs",
25
26
  "rich-click",
@@ -27,6 +28,12 @@ dependencies = [
27
28
  ]
28
29
  dynamic = ["version"]
29
30
 
31
+ [project.optional-dependencies]
32
+ test = ["pytest"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+
30
37
 
31
38
  [project.scripts]
32
39
  ssm = "ssm_cli.cli:cli"
@@ -17,28 +17,38 @@ logger = logging.getLogger(__name__)
17
17
  @click.option('--profile', type=str, required=False, help="Which AWS profile to use")
18
18
  @click.option('--log-level', type=str, required=False, help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
19
19
  @version_option('--version')
20
- def app(profile, log_level):
20
+ @click.pass_context
21
+ def app(ctx, profile, log_level):
21
22
  # Allow setup stuff to log at the right level if passed in
22
23
  if log_level:
23
24
  set_log_level(log_level)
24
25
 
26
+ # Only setup command can run without a config file
27
+ config_loaded = False
25
28
  try:
26
29
  with open(get_conf_file(), 'r') as file:
27
30
  confclasses.load(CONFIG, file)
28
31
  logger.debug(f"Config: {CONFIG}")
32
+ config_loaded = True
29
33
  except EnvironmentError as e:
30
- console.print(f"Invalid config: {e}", style="red")
34
+ # If running setup command, we don't need the config yet
35
+ if ctx.invoked_subcommand == 'setup':
36
+ logger.debug(f"Config not loaded, but running setup command, so continuing")
37
+ else:
38
+ console.print(f"Invalid config: {e}", style="red")
39
+ raise SystemExit(1)
31
40
 
32
- if log_level:
33
- CONFIG.log.level = log_level
34
- if profile:
35
- CONFIG.aws_profile = profile
41
+ if config_loaded:
42
+ if log_level:
43
+ CONFIG.log.level = log_level
44
+ if profile:
45
+ CONFIG.aws_profile = profile
36
46
 
37
- if not log_level:
38
- set_log_level(CONFIG.log.level)
39
- elif log_level != logging.DEBUG:
40
- for logger_name, level in CONFIG.log.loggers.items():
41
- set_log_level(level, name=logger_name)
47
+ if not log_level:
48
+ set_log_level(CONFIG.log.level)
49
+ elif log_level != logging.DEBUG:
50
+ for logger_name, level in CONFIG.log.loggers.items():
51
+ set_log_level(level, name=logger_name)
42
52
 
43
53
 
44
54
  @app.command(help='list all instances in a group, if no group provided, will list all available groups')
@@ -109,12 +119,15 @@ def sshproxy(group):
109
119
 
110
120
  logger.info(f"connecting to {repr(instance)}")
111
121
 
122
+ server = None
112
123
  try:
113
124
  server = ssm_cli.ssh_proxy.server.SshServer(instance)
114
125
  server.start()
115
126
  except Exception as e:
116
- logger.info(f"hiding exceptions in sshproxy {e}")
117
- server.event.set()
127
+ logger.exception(f"sshproxy failed: {e}")
128
+ if server is not None:
129
+ server.event.set()
130
+ raise
118
131
 
119
132
  @app.command(help="setups up ssm-cli, can be rerun safely")
120
133
  @click.option("--replace-config", is_flag=True, help="if we should replace existing config file")
@@ -1,13 +1,19 @@
1
1
  import boto3
2
2
  import botocore
3
3
  import contextlib
4
+ from datetime import datetime, timezone
4
5
  from threading import local
5
6
 
6
7
  from ssm_cli.config import CONFIG
7
8
 
8
9
  _cache = local()
9
- _cache.client_cache = {}
10
- _cache.session_cache = None
10
+
11
+ def _get_cache():
12
+ if not hasattr(_cache, 'client_cache'):
13
+ _cache.client_cache = {}
14
+ if not hasattr(_cache, 'session_cache'):
15
+ _cache.session_cache = None
16
+ return _cache
11
17
 
12
18
  class AWSError(Exception):
13
19
  """ A generic exception for any AWS errors """
@@ -24,17 +30,24 @@ class AWSAccessDeniedError(AWSError):
24
30
  @contextlib.contextmanager
25
31
  def aws_session(use_cache=True):
26
32
  """ A context manager for creating a boto3 session with caching built in """
33
+ cache = _get_cache()
27
34
  try:
28
- # TODO, check its still valid
29
- if _cache.session_cache is not None and use_cache:
30
- yield _cache.session_cache
31
- return
35
+ if cache.session_cache is not None and use_cache:
36
+ credentials = cache.session_cache.get_credentials()
37
+ expiry_time = getattr(credentials, '_expiry_time', None)
38
+ if expiry_time is not None and expiry_time.tzinfo is None:
39
+ expiry_time = expiry_time.replace(tzinfo=timezone.utc)
40
+ if credentials is not None and (expiry_time is None or expiry_time > datetime.now(timezone.utc)):
41
+ yield cache.session_cache
42
+ return
43
+ cache.session_cache = None
44
+ cache.client_cache = {}
32
45
 
33
46
  session = boto3.Session(profile_name=CONFIG.aws_profile)
34
47
  if session.region_name is None:
35
48
  raise AWSAuthError(f"AWS config missing region for profile {session.profile_name}")
36
49
 
37
- _cache.session_cache = session
50
+ cache.session_cache = session
38
51
  yield session
39
52
  except botocore.exceptions.ProfileNotFound as e:
40
53
  raise AWSAuthError(f"profile invalid") from e
@@ -48,11 +61,12 @@ def aws_session(use_cache=True):
48
61
  @contextlib.contextmanager
49
62
  def aws_client(service_name, use_cache=True):
50
63
  """ A context manager for creating a boto3 client with caching built in """
64
+ cache = _get_cache()
51
65
  with aws_session(use_cache) as session:
52
- if service_name in _cache.client_cache and use_cache:
53
- yield _cache.client_cache[service_name]
66
+ if service_name in cache.client_cache and use_cache:
67
+ yield cache.client_cache[service_name]
54
68
  return
55
69
 
56
70
  client = session.client(service_name)
57
- _cache.client_cache[service_name] = client
71
+ cache.client_cache[service_name] = client
58
72
  yield client
@@ -16,8 +16,12 @@ def version_callback(ctx: Context, param: Parameter, value: bool) -> None:
16
16
  import importlib.metadata
17
17
  import os
18
18
 
19
+ plugin_version = "not installed"
19
20
  try:
20
21
  results = subprocess.run(["session-manager-plugin", "--version"], capture_output=True, text=True)
22
+ plugin_version = results.stdout.strip()
23
+ if not plugin_version:
24
+ plugin_version = f"unavailable (exit={results.returncode})"
21
25
  except FileNotFoundError:
22
26
  print("session-manager-plugin not found", file=sys.stderr)
23
27
 
@@ -26,7 +30,7 @@ def version_callback(ctx: Context, param: Parameter, value: bool) -> None:
26
30
  print(f"ssm-cli {importlib.metadata.version('ssm-cli')} from {pkg_dir}")
27
31
  v = sys.version_info
28
32
  print(f"python {v.major}.{v.minor}.{v.micro}")
29
- print(f"session-manager-plugin {results.stdout.strip()}")
33
+ print(f"session-manager-plugin {plugin_version}")
30
34
  ctx.exit()
31
35
 
32
36
  def version_option(*param_decls):
@@ -53,7 +57,7 @@ class PluginGroup(RichGroup):
53
57
  # When developing the allow_interspersed_args, I kept finding these other settings in examples.
54
58
  # I haven't found them necessary, so leaving commented if they become useful:
55
59
  # "allow_extra_args": True,
56
- # "ignore_unknown_options": True
60
+ "ignore_unknown_options": True
57
61
  }, **kwargs)
58
62
 
59
63
  def parse_args(self, ctx, args):
@@ -74,4 +78,4 @@ class PluginGroup(RichGroup):
74
78
  args.remove(cmd.name)
75
79
  ctx = cmd.make_context(cmd.name, args, parent=parent_ctx)
76
80
 
77
- return super().parse_args(ctx, args)
81
+ return super().parse_args(ctx, args)
@@ -42,8 +42,10 @@ class PortForwardingSession():
42
42
  """
43
43
  This is the object that the user will interact with
44
44
  """
45
- def __init__(self, manager: "PortForwarding", session_id: str, internal_port: int):
45
+ def __init__(self, manager: "PortForwarding", host: str, remote_port: int, session_id: str, internal_port: int):
46
46
  self.manager = manager
47
+ self.host = host
48
+ self.remote_port = remote_port
47
49
  self.session_id = session_id
48
50
  self.internal_port = internal_port
49
51
 
@@ -93,13 +95,13 @@ class PortForwarding():
93
95
  del self.session_cache[(host, remote_port)]
94
96
 
95
97
  session_id, internal_port = self.send_recv(("open_session", (host, remote_port)))
96
- session = PortForwardingSession(self, session_id, internal_port)
98
+ session = PortForwardingSession(self, host, remote_port, session_id, internal_port)
97
99
  self.session_cache[(host, remote_port)] = session
98
100
  return session
99
101
 
100
102
  def close_session(self, session: PortForwardingSession):
101
- self.proxy_pipe.send(("close_session", session.session_id))
102
- del self.session_cache[(session.host, session.remote_port)]
103
+ self.send(("close_session", session.session_id))
104
+ self.session_cache.pop((session.host, session.remote_port), None)
103
105
 
104
106
  def is_open(self, session: PortForwardingSession):
105
107
  return self.send_recv(("is_open", session.session_id))
@@ -304,7 +306,7 @@ class PortForwardingManagerProcess(multiprocessing.Process):
304
306
  logger.debug(f"{s.session_id} in cache for {host}:{remote_port}")
305
307
  if s.is_open():
306
308
  logger.debug(f"{s.session_id} still running, reusing")
307
- return s
309
+ return s.session_id, s.internal_port
308
310
  else:
309
311
  logger.debug(f"{s.session_id} closed, removing from cache")
310
312
  self.sessions.remove(s)
@@ -375,4 +377,4 @@ def get_free_port(bind_host="127.0.0.1"):
375
377
  s.bind((bind_host, 0))
376
378
  port = s.getsockname()[1]
377
379
  s.close()
378
- return port
380
+ return port
@@ -117,22 +117,31 @@ class SshServer(paramiko.ServerInterface):
117
117
  logger.info(f"starting forward thread chan={chanid}")
118
118
 
119
119
  chan = self.channels.get_channel(chanid)
120
- while True:
121
- r, _, _ = select.select([chan, sock], [], [])
122
- if sock in r:
123
- data = sock.recv(chunk_size)
124
- if len(data) == 0:
125
- break
126
- chan.send(data)
120
+ if chan is None:
121
+ logger.error(f"failed to acquire channel for chanid={chanid}")
122
+ session.close()
123
+ sock.close()
124
+ return
127
125
 
128
- if chan in r:
129
- data = chan.recv(chunk_size)
130
- if len(data) == 0:
131
- break
132
- sock.send(data)
133
-
134
- session.close()
135
- logger.info(f"forward thread chan={chanid} exiting")
126
+ try:
127
+ while True:
128
+ r, _, _ = select.select([chan, sock], [], [])
129
+ if sock in r:
130
+ data = sock.recv(chunk_size)
131
+ if len(data) == 0:
132
+ break
133
+ chan.send(data)
134
+
135
+ if chan in r:
136
+ data = chan.recv(chunk_size)
137
+ if len(data) == 0:
138
+ break
139
+ sock.send(data)
140
+ finally:
141
+ chan.close()
142
+ sock.close()
143
+ session.close()
144
+ logger.info(f"forward thread chan={chanid} exiting")
136
145
 
137
146
  t = threading.Thread(target=forwarding_thread)
138
147
  t.start()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssm-cli
3
- Version: 1.1.0.dev5
3
+ Version: 1.1.0.dev6
4
4
  Summary: CLI tool to help with SSM functionality, aimed at adminstrators
5
5
  Author-email: Simon Fletcher <simon.fletcher@lexisnexisrisk.com>
6
6
  License: MIT License
@@ -36,10 +36,13 @@ License-File: LICENCE
36
36
  Requires-Dist: boto3
37
37
  Requires-Dist: paramiko
38
38
  Requires-Dist: rich
39
+ Requires-Dist: readchar
39
40
  Requires-Dist: confclasses>=0.3.2
40
41
  Requires-Dist: xdg_base_dirs
41
42
  Requires-Dist: rich-click
42
43
  Requires-Dist: psutil
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest; extra == "test"
43
46
  Dynamic: license-file
44
47
 
45
48
  # SSM CLI
@@ -97,7 +100,7 @@ I recommend using conditions in some way to control fine grained access.
97
100
  "Sid": "FirstStatement",
98
101
  "Effect": "Allow",
99
102
  "Action": [
100
- "tag:GetResources",
103
+ "resourcegroupstaggingapi:GetResources",
101
104
  "ssm:DescribeInstanceInformation",
102
105
  "ssm:StartSession"
103
106
  ],
@@ -131,7 +134,7 @@ Adding to the ssh config (typically `~/.ssh/config`) and using ssh client as an
131
134
  ```bash
132
135
  cat >> ~/.ssh/config << EOL
133
136
  Host bastion
134
- ProxyCommand ssm proxycommand bastion_group
137
+ ProxyCommand ssm sshproxy bastion_group
135
138
  EOL
136
139
 
137
140
  ssh bastion -L 3306:database.host:3306
@@ -26,4 +26,8 @@ ssm_cli/ssh_proxy/channels.py
26
26
  ssm_cli/ssh_proxy/forwarding.py
27
27
  ssm_cli/ssh_proxy/server.py
28
28
  ssm_cli/ssh_proxy/shell.py
29
- ssm_cli/ssh_proxy/socket.py
29
+ ssm_cli/ssh_proxy/socket.py
30
+ tests/test_aws.py
31
+ tests/test_cli.py
32
+ tests/test_config.py
33
+ tests/test_helpers.py
@@ -1,7 +1,11 @@
1
1
  boto3
2
2
  paramiko
3
3
  rich
4
+ readchar
4
5
  confclasses>=0.3.2
5
6
  xdg_base_dirs
6
7
  rich-click
7
8
  psutil
9
+
10
+ [test]
11
+ pytest
@@ -0,0 +1,172 @@
1
+ """
2
+ Tests for ssm_cli/aws.py - session and client caching.
3
+
4
+ All boto3 calls are mocked so nothing hits real AWS.
5
+
6
+ We use unittest.mock.patch to swap out boto3.Session with a fake - inside
7
+ the patch block our fake is used, outside everything goes back to normal.
8
+
9
+ The CONFIG object uses confclasses which needs from_dict() before you can
10
+ read it. Can't use patch() on it directly so we just load it with defaults
11
+ and set the profile manually.
12
+ """
13
+ import pytest
14
+ from unittest.mock import patch, MagicMock
15
+ from datetime import datetime, timezone, timedelta
16
+ from threading import local
17
+ from confclasses import from_dict
18
+
19
+ from ssm_cli.aws import (
20
+ aws_session, aws_client,
21
+ _get_cache,
22
+ AWSAuthError, AWSAccessDeniedError,
23
+ )
24
+ from ssm_cli.config import CONFIG
25
+ import ssm_cli.aws
26
+ import botocore.exceptions
27
+
28
+
29
+ def _setup_config(profile="test-profile"):
30
+ """Load CONFIG with defaults and set the profile for testing."""
31
+ from_dict(CONFIG, {})
32
+ CONFIG.aws_profile = profile
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # _get_cache - thread-local storage init
37
+ # ---------------------------------------------------------------------------
38
+
39
+ # check a fresh cache starts with empty client_cache and no session
40
+ def test_get_cache_initialises_empty():
41
+ ssm_cli.aws._cache = local()
42
+ cache = _get_cache()
43
+
44
+ assert cache.client_cache == {}
45
+ assert cache.session_cache is None
46
+
47
+
48
+ # check calling _get_cache() again doesn't wipe data that's already there
49
+ def test_get_cache_preserves_existing_data():
50
+ ssm_cli.aws._cache = local()
51
+ cache = _get_cache()
52
+ cache.client_cache["ssm"] = "fake-client"
53
+
54
+ cache2 = _get_cache()
55
+ assert cache2.client_cache["ssm"] == "fake-client"
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # aws_session - creates and caches boto3 sessions
60
+ # ---------------------------------------------------------------------------
61
+
62
+ # check we get a boto3 session back when we call aws_session()
63
+ def test_aws_session_creates_session():
64
+ ssm_cli.aws._cache = local()
65
+ _setup_config()
66
+
67
+ fake_session = MagicMock()
68
+ fake_session.region_name = "eu-west-1"
69
+
70
+ with patch("ssm_cli.aws.boto3.Session", return_value=fake_session):
71
+ with aws_session() as session:
72
+ assert session == fake_session
73
+
74
+
75
+ # check the second call reuses the cached session instead of making a new one
76
+ def test_aws_session_caches_on_second_call():
77
+ ssm_cli.aws._cache = local()
78
+ _setup_config()
79
+
80
+ fake_session = MagicMock()
81
+ fake_session.region_name = "eu-west-1"
82
+
83
+ fake_creds = MagicMock()
84
+ fake_creds._expiry_time = None
85
+ fake_session.get_credentials.return_value = fake_creds
86
+
87
+ with patch("ssm_cli.aws.boto3.Session", return_value=fake_session) as mock_session:
88
+ with aws_session() as s1:
89
+ pass
90
+ with aws_session() as s2:
91
+ pass
92
+
93
+ assert mock_session.call_count == 1
94
+ assert s1 == s2
95
+
96
+
97
+ # check that expired creds get thrown away and a new session is created
98
+ def test_aws_session_clears_cache_when_credentials_expired():
99
+ ssm_cli.aws._cache = local()
100
+ _setup_config()
101
+
102
+ expired_session = MagicMock()
103
+ expired_session.region_name = "eu-west-1"
104
+ expired_creds = MagicMock()
105
+ expired_creds._expiry_time = datetime.now(timezone.utc) - timedelta(hours=1)
106
+ expired_session.get_credentials.return_value = expired_creds
107
+
108
+ fresh_session = MagicMock()
109
+ fresh_session.region_name = "eu-west-1"
110
+
111
+ with patch("ssm_cli.aws.boto3.Session", side_effect=[expired_session, fresh_session]) as mock_session:
112
+ with aws_session() as s1:
113
+ pass
114
+ with aws_session() as s2:
115
+ pass
116
+
117
+ assert mock_session.call_count == 2
118
+ assert s2 == fresh_session
119
+
120
+
121
+ # check we get AWSAuthError when the profile has no region set
122
+ def test_aws_session_raises_on_missing_region():
123
+ ssm_cli.aws._cache = local()
124
+ _setup_config("broken-profile")
125
+
126
+ fake_session = MagicMock()
127
+ fake_session.region_name = None
128
+ fake_session.profile_name = "broken-profile"
129
+
130
+ with patch("ssm_cli.aws.boto3.Session", return_value=fake_session):
131
+ with pytest.raises(AWSAuthError, match="missing region"):
132
+ with aws_session() as session:
133
+ pass
134
+
135
+
136
+ # check we wrap boto3's ProfileNotFound into our own AWSAuthError
137
+ def test_aws_session_raises_on_invalid_profile():
138
+ ssm_cli.aws._cache = local()
139
+ _setup_config("nope")
140
+
141
+ with patch("ssm_cli.aws.boto3.Session",
142
+ side_effect=botocore.exceptions.ProfileNotFound(profile="nope")):
143
+ with pytest.raises(AWSAuthError, match="profile invalid"):
144
+ with aws_session() as session:
145
+ pass
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # aws_client - creates and caches boto3 service clients
150
+ # ---------------------------------------------------------------------------
151
+
152
+ # check the client is created once and reused on the second call
153
+ def test_aws_client_creates_and_caches_client():
154
+ ssm_cli.aws._cache = local()
155
+ _setup_config()
156
+
157
+ fake_session = MagicMock()
158
+ fake_session.region_name = "eu-west-1"
159
+ fake_client = MagicMock()
160
+ fake_session.client.return_value = fake_client
161
+
162
+ fake_creds = MagicMock()
163
+ fake_creds._expiry_time = None
164
+ fake_session.get_credentials.return_value = fake_creds
165
+
166
+ with patch("ssm_cli.aws.boto3.Session", return_value=fake_session):
167
+ with aws_client("ssm") as client1:
168
+ assert client1 == fake_client
169
+ with aws_client("ssm") as client2:
170
+ assert client2 == fake_client
171
+
172
+ assert fake_session.client.call_count == 1
@@ -0,0 +1,62 @@
1
+ """
2
+ Tests for the CLI commands - smoke tests using click's CliRunner.
3
+
4
+ CliRunner lets us call CLI commands without a real terminal.
5
+ Output is captured as a string and we can check exit codes.
6
+
7
+ These just check the CLI loads and shows the right help text,
8
+ they don't test any actual AWS functionality.
9
+ """
10
+ from unittest.mock import patch, MagicMock
11
+ from click.testing import CliRunner
12
+ from ssm_cli.app import app
13
+
14
+
15
+ # check ssm --help lists all four commands
16
+ def test_help_shows_commands():
17
+ runner = CliRunner()
18
+ result = runner.invoke(app, ["--help"])
19
+
20
+ assert result.exit_code == 0, f"--help failed with: {result.output}"
21
+ assert "list" in result.output
22
+ assert "shell" in result.output
23
+ assert "sshproxy" in result.output
24
+ assert "setup" in result.output
25
+
26
+
27
+ # check ssm --version shows versions of ssm-cli, python, and the plugin
28
+ #
29
+ # we mock subprocess.run because session-manager-plugin might not be
30
+ # installed, and importlib.metadata.version because the package might
31
+ # not be installed in editable mode
32
+ def test_version_flag():
33
+ runner = CliRunner()
34
+
35
+ fake_result = MagicMock()
36
+ fake_result.stdout = "1.2.3.0"
37
+ fake_result.returncode = 0
38
+
39
+ with patch("subprocess.run", return_value=fake_result), \
40
+ patch("importlib.metadata.version", return_value="0.0.0-test"):
41
+ result = runner.invoke(app, ["--version"])
42
+
43
+ assert result.exit_code == 0, f"--version failed with: {result.output}"
44
+ assert "ssm-cli" in result.output
45
+ assert "python" in result.output
46
+
47
+
48
+ # check ssm list --help doesn't crash
49
+ def test_list_help():
50
+ runner = CliRunner()
51
+ result = runner.invoke(app, ["list", "--help"])
52
+ assert result.exit_code == 0, f"list --help failed with: {result.output}"
53
+
54
+
55
+ # check ssm setup --help shows the --replace-config and --replace-hostkey flags
56
+ def test_setup_help():
57
+ runner = CliRunner()
58
+ result = runner.invoke(app, ["setup", "--help"])
59
+
60
+ assert result.exit_code == 0, f"setup --help failed with: {result.output}"
61
+ assert "replace-config" in result.output
62
+ assert "replace-hostkey" in result.output
@@ -0,0 +1,39 @@
1
+ """
2
+ Tests for ssm_cli/config.py - checks the CONFIG defaults are what we expect.
3
+
4
+ If someone changes a default by accident, these will catch it.
5
+
6
+ Note: confclasses needs from_dict() called before you can read attributes,
7
+ so each test creates a fresh Config and loads it with an empty dict to get defaults.
8
+ """
9
+ from confclasses import from_dict
10
+ from ssm_cli.config import Config
11
+
12
+
13
+ # check the default EC2 tag key for grouping instances is "group"
14
+ def test_default_group_tag_key():
15
+ config = Config()
16
+ from_dict(config, {})
17
+ assert config.group_tag_key == "group"
18
+
19
+
20
+ # check the default AWS profile is "default"
21
+ def test_default_aws_profile():
22
+ config = Config()
23
+ from_dict(config, {})
24
+ assert config.aws_profile == "default"
25
+
26
+
27
+ # check the default log level is "info"
28
+ def test_default_log_level():
29
+ config = Config()
30
+ from_dict(config, {})
31
+ assert config.log.level == "info"
32
+
33
+
34
+ # check botocore and paramiko default to "warn" (they're noisy at info)
35
+ def test_default_logger_overrides():
36
+ config = Config()
37
+ from_dict(config, {})
38
+ assert config.log.loggers["botocore"] == "warn"
39
+ assert config.log.loggers["paramiko"] == "warn"
@@ -0,0 +1,101 @@
1
+ """
2
+ Tests for the helper functions in ssm_cli/instances.py.
3
+
4
+ These are pure functions with no AWS dependencies so no mocking is needed.
5
+ """
6
+ import pytest
7
+ from ssm_cli.instances import get_tag, arn_to_instance_id, ip_as_int
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # get_tag - looks up a tag value from the list of tag dicts that AWS returns
12
+ #
13
+ # AWS tags come back as: [{"Key": "Name", "Value": "my-server"}, ...]
14
+ # ---------------------------------------------------------------------------
15
+
16
+ # check it can find a tag that exists and return the right value
17
+ def test_get_tag_finds_existing_tag():
18
+ tags = [
19
+ {"Key": "Name", "Value": "my-server"},
20
+ {"Key": "env", "Value": "prod"},
21
+ ]
22
+ assert get_tag(tags, "Name") == "my-server"
23
+
24
+
25
+ # check it returns None when the tag key doesn't exist
26
+ def test_get_tag_returns_none_when_not_found():
27
+ tags = [
28
+ {"Key": "Name", "Value": "my-server"},
29
+ ]
30
+ assert get_tag(tags, "missing-key") is None
31
+
32
+
33
+ # check it handles an empty tag list without crashing
34
+ def test_get_tag_empty_list():
35
+ assert get_tag([], "anything") is None
36
+
37
+
38
+ # check that if there are duplicate keys, it returns the first one
39
+ def test_get_tag_returns_first_match():
40
+ tags = [
41
+ {"Key": "env", "Value": "first"},
42
+ {"Key": "env", "Value": "second"},
43
+ ]
44
+ assert get_tag(tags, "env") == "first"
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # arn_to_instance_id - pulls the instance ID out of an EC2 ARN
49
+ #
50
+ # e.g. "arn:aws:ec2:eu-west-1:123456789:instance/i-0abc123def456"
51
+ # -> "i-0abc123def456"
52
+ # ---------------------------------------------------------------------------
53
+
54
+ # check it extracts the instance ID from a valid ARN
55
+ def test_arn_to_instance_id_normal():
56
+ arn = "arn:aws:ec2:eu-west-1:123456789:instance/i-0abc123def456"
57
+ assert arn_to_instance_id(arn) == "i-0abc123def456"
58
+
59
+
60
+ # check it raises ValueError when there's no slash in the ARN
61
+ def test_arn_to_instance_id_invalid_raises():
62
+ with pytest.raises(ValueError):
63
+ arn_to_instance_id("no-slash-here")
64
+
65
+
66
+ # check it raises ValueError when there are too many slashes
67
+ def test_arn_to_instance_id_too_many_slashes_raises():
68
+ with pytest.raises(ValueError):
69
+ arn_to_instance_id("a/b/c")
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # ip_as_int - converts IP string to integer for sorting
74
+ #
75
+ # needed because string sorting puts "10.0.10.1" before "10.0.2.1"
76
+ # ---------------------------------------------------------------------------
77
+
78
+ # check a normal IP converts to the right integer
79
+ def test_ip_as_int_simple():
80
+ assert ip_as_int("10.0.0.1") == 167772161
81
+
82
+
83
+ # check 0.0.0.0 gives 0
84
+ def test_ip_as_int_all_zeros():
85
+ assert ip_as_int("0.0.0.0") == 0
86
+
87
+
88
+ # check 255.255.255.255 gives the max value
89
+ def test_ip_as_int_all_255():
90
+ assert ip_as_int("255.255.255.255") == 4294967295
91
+
92
+
93
+ # check that IPs sort numerically not alphabetically
94
+ def test_ip_as_int_ordering():
95
+ assert ip_as_int("10.0.2.1") < ip_as_int("10.0.10.1")
96
+
97
+
98
+ # check it raises ValueError for garbage input
99
+ def test_ip_as_int_invalid_raises():
100
+ with pytest.raises(ValueError):
101
+ ip_as_int("not-an-ip")
File without changes
File without changes
File without changes