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.
- {ssm_cli-1.1.0.dev5/ssm_cli.egg-info → ssm_cli-1.1.0.dev6}/PKG-INFO +6 -3
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/README.md +2 -2
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/pyproject.toml +7 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/app.py +26 -13
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/aws.py +24 -10
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/click.py +7 -3
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/forwarding.py +8 -6
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/server.py +24 -15
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6/ssm_cli.egg-info}/PKG-INFO +6 -3
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/SOURCES.txt +5 -1
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/requires.txt +4 -0
- ssm_cli-1.1.0.dev6/tests/test_aws.py +172 -0
- ssm_cli-1.1.0.dev6/tests/test_cli.py +62 -0
- ssm_cli-1.1.0.dev6/tests/test_config.py +39 -0
- ssm_cli-1.1.0.dev6/tests/test_helpers.py +101 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/LICENCE +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/setup.cfg +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/__init__.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/__main__.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/cli.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/config.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/instances.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/logging.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/selectors/__init__.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/selectors/first.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/selectors/tui.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/__init__.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/channels.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/shell.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ssh_proxy/socket.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/ui.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli/xdg.py +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/dependency_links.txt +0 -0
- {ssm_cli-1.1.0.dev5 → ssm_cli-1.1.0.dev6}/ssm_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
117
|
-
server
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
yield
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
102
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|