ssm-cli 0.1.4.dev0__tar.gz → 0.1.4.dev3__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-0.1.4.dev0/ssm_cli.egg-info → ssm_cli-0.1.4.dev3}/PKG-INFO +1 -1
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/aws.py +5 -5
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/cli.py +13 -14
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/setup.py +1 -1
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/ssh_proxy/__init__.py +9 -3
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/ssh_proxy/server.py +5 -2
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/instances.py +45 -6
- ssm_cli-0.1.4.dev3/ssm_cli/logging.py +41 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/xdg.py +10 -2
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3/ssm_cli.egg-info}/PKG-INFO +1 -1
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli.egg-info/SOURCES.txt +1 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/LICENCE +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/README.md +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/pyproject.toml +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/setup.cfg +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/__init__.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/__main__.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/cli_args.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/__init__.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/base.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/list.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/shell.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/ssh_proxy/channels.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/ssh_proxy/forward.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/ssh_proxy/shell.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/commands/ssh_proxy/transport.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/config.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/console.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/selectors/__init__.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/selectors/first.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/selectors/tui/__init__.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/selectors/tui/posix.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli/selectors/tui/win.py +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli.egg-info/dependency_links.txt +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli.egg-info/entry_points.txt +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli.egg-info/requires.txt +0 -0
- {ssm_cli-0.1.4.dev0 → ssm_cli-0.1.4.dev3}/ssm_cli.egg-info/top_level.txt +0 -0
|
@@ -19,10 +19,10 @@ _session_cache = []
|
|
|
19
19
|
_client_cache = {}
|
|
20
20
|
|
|
21
21
|
@contextlib.contextmanager
|
|
22
|
-
def aws_session():
|
|
22
|
+
def aws_session(use_cache=True):
|
|
23
23
|
""" A context manager for creating a boto3 session with caching built in """
|
|
24
24
|
try:
|
|
25
|
-
if len(_session_cache) > 0:
|
|
25
|
+
if len(_session_cache) > 0 and use_cache:
|
|
26
26
|
yield _session_cache[0]
|
|
27
27
|
return
|
|
28
28
|
|
|
@@ -42,10 +42,10 @@ def aws_session():
|
|
|
42
42
|
raise e
|
|
43
43
|
|
|
44
44
|
@contextlib.contextmanager
|
|
45
|
-
def aws_client(service_name):
|
|
45
|
+
def aws_client(service_name, use_cache=True):
|
|
46
46
|
""" A context manager for creating a boto3 client with caching built in """
|
|
47
|
-
with aws_session() as session:
|
|
48
|
-
if service_name in _client_cache:
|
|
47
|
+
with aws_session(use_cache) as session:
|
|
48
|
+
if service_name in _client_cache and use_cache:
|
|
49
49
|
yield _client_cache[service_name]
|
|
50
50
|
return
|
|
51
51
|
|
|
@@ -1,37 +1,34 @@
|
|
|
1
1
|
import sys
|
|
2
|
+
import logging
|
|
2
3
|
|
|
3
4
|
from rich_argparse import ArgumentDefaultsRichHelpFormatter
|
|
4
5
|
|
|
5
6
|
import confclasses
|
|
6
7
|
from ssm_cli.config import CONFIG
|
|
7
|
-
from ssm_cli.xdg import
|
|
8
|
+
from ssm_cli.xdg import get_conf_file, get_log_file
|
|
8
9
|
from ssm_cli.commands import COMMANDS
|
|
9
10
|
from ssm_cli.cli_args import CliArgumentParser, ARGS
|
|
10
11
|
from ssm_cli.aws import AWSAuthError, AWSAccessDeniedError
|
|
11
12
|
from ssm_cli.console import console
|
|
13
|
+
from ssm_cli.logging import setup_logging, configure_log_level
|
|
14
|
+
from ssm_cli.instances import cleanup_sessions
|
|
12
15
|
from rich.markup import escape
|
|
13
16
|
|
|
14
|
-
# Setup logging
|
|
15
|
-
import logging
|
|
16
|
-
logging.basicConfig(
|
|
17
|
-
level=logging.WARNING,
|
|
18
|
-
filename=get_log_file(),
|
|
19
|
-
filemode='+wt',
|
|
20
|
-
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
21
|
-
datefmt='%Y-%m-%d %H:%M:%S'
|
|
22
|
-
)
|
|
23
17
|
logger = logging.getLogger(__name__)
|
|
24
18
|
|
|
25
19
|
def cli(argv: list = None) -> int:
|
|
26
20
|
if argv is None:
|
|
27
21
|
argv = sys.argv[1:]
|
|
28
22
|
|
|
23
|
+
# Initialize logging
|
|
24
|
+
setup_logging()
|
|
25
|
+
|
|
29
26
|
# Manually set the log level now, so we get accurate logging during argument parsing
|
|
30
27
|
for i, arg in enumerate(argv):
|
|
31
28
|
if arg == '--log-level':
|
|
32
|
-
|
|
29
|
+
configure_log_level(argv[i+1])
|
|
33
30
|
if arg.startswith('--log-level='):
|
|
34
|
-
|
|
31
|
+
configure_log_level(arg.split('=')[1])
|
|
35
32
|
|
|
36
33
|
logger.debug(f"CLI called with {argv}")
|
|
37
34
|
|
|
@@ -68,12 +65,12 @@ def cli(argv: list = None) -> int:
|
|
|
68
65
|
console.print(f"Invalid config: {e}", style="red")
|
|
69
66
|
return 1
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
configure_log_level(CONFIG.log.level)
|
|
72
69
|
|
|
73
70
|
|
|
74
71
|
for logger_name, level in CONFIG.log.loggers.items():
|
|
75
72
|
logger.debug(f"setting logger {logger_name} to {level}")
|
|
76
|
-
|
|
73
|
+
configure_log_level(level, name=logger_name)
|
|
77
74
|
|
|
78
75
|
|
|
79
76
|
if ARGS.command not in COMMANDS:
|
|
@@ -103,3 +100,5 @@ def _run():
|
|
|
103
100
|
console.print(f"Error: {e}", style="red bold")
|
|
104
101
|
logger.exception(e, stack_info=True, stacklevel=20)
|
|
105
102
|
return 1
|
|
103
|
+
finally:
|
|
104
|
+
cleanup_sessions()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import paramiko
|
|
3
3
|
from ssm_cli.commands.base import BaseCommand
|
|
4
|
-
from ssm_cli.xdg import get_conf_root, get_conf_file, get_ssh_hostkey
|
|
4
|
+
from ssm_cli.xdg import get_conf_root, get_conf_file, get_ssh_hostkey
|
|
5
5
|
from ssm_cli.cli_args import ARGS
|
|
6
6
|
from ssm_cli.config import CONFIG
|
|
7
7
|
from confclasses import from_dict, save
|
|
@@ -2,7 +2,7 @@ import socket
|
|
|
2
2
|
import time
|
|
3
3
|
|
|
4
4
|
from ssm_cli.commands.ssh_proxy.server import SshServer
|
|
5
|
-
from ssm_cli.instances import Instance, Instances
|
|
5
|
+
from ssm_cli.instances import Instance, Instances, SessionManagerPluginError, SessionManagerPluginPortError
|
|
6
6
|
from ssm_cli.config import CONFIG
|
|
7
7
|
from ssm_cli.commands.base import BaseCommand
|
|
8
8
|
from ssm_cli.cli_args import ARGS
|
|
@@ -50,9 +50,14 @@ def direct_tcpip_callback(instance: Instance, connections: dict) -> callable:
|
|
|
50
50
|
try:
|
|
51
51
|
internal_port = get_free_port()
|
|
52
52
|
proc = instance.start_port_forwarding_session_to_remote_host(host, remote_port, internal_port)
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
connections[(host, remote_port)] = (internal_port, proc)
|
|
54
|
+
break
|
|
55
|
+
except SessionManagerPluginPortError as e:
|
|
56
|
+
logger.warning(f"session-manager-plugin attempt {attempt} failed due to port clash, retrying: {e}")
|
|
55
57
|
time.sleep(0.1)
|
|
58
|
+
except SessionManagerPluginError as e:
|
|
59
|
+
logger.error(f"session-manager-plugin failed: {e}")
|
|
60
|
+
return None
|
|
56
61
|
|
|
57
62
|
logger.debug(f"connecting to session manager plugin on 127.0.0.1:{internal_port}")
|
|
58
63
|
# Even though we wait for the process to say its connected, we STILL need to wait for it
|
|
@@ -63,6 +68,7 @@ def direct_tcpip_callback(instance: Instance, connections: dict) -> callable:
|
|
|
63
68
|
raise RuntimeError("session-manager-plugin has exited")
|
|
64
69
|
sock = socket.create_connection(('127.0.0.1', internal_port))
|
|
65
70
|
logger.info(f"connected to 127.0.0.1:{internal_port}")
|
|
71
|
+
break
|
|
66
72
|
except Exception as e:
|
|
67
73
|
logger.warning(f"connection attempt {attempt} failed: {e}")
|
|
68
74
|
time.sleep(0.1)
|
|
@@ -81,8 +81,11 @@ class SshServer(paramiko.ServerInterface):
|
|
|
81
81
|
logger.info(f"direct TCP/IP request: chan={chanid} origin={origin} destination={destination}")
|
|
82
82
|
host = destination[0]
|
|
83
83
|
remote_port = destination[1]
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
try:
|
|
85
|
+
sock = self.direct_tcpip_callback(host, remote_port)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"failed to connect to session manager plugin: {e}")
|
|
88
|
+
return paramiko.OPEN_FAILED_CONNECT_FAILED
|
|
86
89
|
|
|
87
90
|
if not sock:
|
|
88
91
|
logger.error("failed to connect to session manager plugin")
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
2
1
|
from functools import cache
|
|
3
2
|
import json
|
|
4
3
|
import re
|
|
@@ -14,6 +13,38 @@ from ssm_cli.aws import aws_client, aws_session
|
|
|
14
13
|
import logging
|
|
15
14
|
logger = logging.getLogger(__name__)
|
|
16
15
|
|
|
16
|
+
class SessionManagerPluginError(Exception):
|
|
17
|
+
""" A generic exception for any AWS errors """
|
|
18
|
+
stdout = []
|
|
19
|
+
returncode = 0
|
|
20
|
+
def __init__(self, message, stdout, returncode):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.stdout = stdout
|
|
23
|
+
self.returncode = returncode
|
|
24
|
+
|
|
25
|
+
def __str__(self):
|
|
26
|
+
return f"{super().__str__()} (returncode={self.returncode}, stdout={self.stdout})"
|
|
27
|
+
|
|
28
|
+
class SessionManagerPluginPortError(SessionManagerPluginError):
|
|
29
|
+
""" A specific exception for a timeout error """
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
_sessions = {}
|
|
33
|
+
|
|
34
|
+
def cleanup_sessions():
|
|
35
|
+
""" Kill any active sessions """
|
|
36
|
+
with aws_client('ssm') as client:
|
|
37
|
+
for session_id, proc in _sessions.items():
|
|
38
|
+
logger.debug(f"terminating session {session_id}")
|
|
39
|
+
try:
|
|
40
|
+
proc.terminate()
|
|
41
|
+
proc.wait()
|
|
42
|
+
client.terminate_session(SessionId=session_id)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Failed to cleanup session {session_id} but continuing anyway: {e}")
|
|
45
|
+
|
|
46
|
+
_sessions.clear()
|
|
47
|
+
|
|
17
48
|
|
|
18
49
|
class Instance:
|
|
19
50
|
"""
|
|
@@ -56,7 +87,8 @@ class Instance:
|
|
|
56
87
|
|
|
57
88
|
def start_port_forwarding_session_to_remote_host(self, host: str, remote_port: int, internal_port: int):
|
|
58
89
|
logger.debug(f"start port forwarding between localhost:{internal_port} and {host}:{remote_port} via {self.id}")
|
|
59
|
-
with aws_session() as session
|
|
90
|
+
with aws_session(False) as session:
|
|
91
|
+
client = session.client('ssm') # we need a fresh connection where session is the same as the client
|
|
60
92
|
|
|
61
93
|
parameters = dict(
|
|
62
94
|
Target=self.id,
|
|
@@ -88,15 +120,22 @@ class Instance:
|
|
|
88
120
|
stdout=subprocess.PIPE,
|
|
89
121
|
stderr=subprocess.STDOUT
|
|
90
122
|
)
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
_sessions[response["SessionId"]] = proc
|
|
124
|
+
|
|
125
|
+
# changes needed here:
|
|
126
|
+
# SessionManagerPluginPortError needs to be raised correctly
|
|
127
|
+
# add a timeout for waiting on stdout
|
|
128
|
+
# maybe split up stdout/stderr?
|
|
129
|
+
output = b''
|
|
130
|
+
for line in proc.stdout:
|
|
93
131
|
line = line.strip()
|
|
132
|
+
output += line + b'\n'
|
|
94
133
|
if line == b'Waiting for connections...':
|
|
95
134
|
return proc
|
|
96
135
|
else:
|
|
97
136
|
logger.debug(f"Recieved from session-manager-plugin: {line}")
|
|
98
|
-
|
|
99
|
-
raise
|
|
137
|
+
|
|
138
|
+
raise SessionManagerPluginError("Failed to start port forwarding session", output, proc.returncode)
|
|
100
139
|
|
|
101
140
|
def _session_manager_plugin( command: list) -> int:
|
|
102
141
|
""" Call out to subprocess and ignore interrupts """
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from ssm_cli.xdg import get_log_file, get_all_log_files
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_logging():
|
|
9
|
+
"""Set up basic logging configuration with date-based rotation."""
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.WARNING,
|
|
12
|
+
filename=get_log_file(),
|
|
13
|
+
filemode='a',
|
|
14
|
+
format='%(asctime)s - %(process)d [%(threadName)s] - %(name)s - %(levelname)s - %(message)s',
|
|
15
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
16
|
+
)
|
|
17
|
+
start_log_cleanup()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def configure_log_level(level: str, name: Optional[str] = None):
|
|
21
|
+
"""Configure logger level."""
|
|
22
|
+
logging.getLogger(name).setLevel(level.upper())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cleanup_old_logs(days_to_keep: int = 7):
|
|
26
|
+
"""Clean up log files older than specified days. Runs with no error handling."""
|
|
27
|
+
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
|
|
28
|
+
|
|
29
|
+
for log_file in get_all_log_files():
|
|
30
|
+
# Extract date from filename
|
|
31
|
+
date_part = log_file.stem.split('.')[-1]
|
|
32
|
+
file_date = datetime.strptime(date_part, '%Y-%m-%d')
|
|
33
|
+
|
|
34
|
+
if file_date < cutoff_date:
|
|
35
|
+
log_file.unlink()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def start_log_cleanup(days_to_keep: int = 7):
|
|
39
|
+
"""Start log cleanup in background thread without error handling."""
|
|
40
|
+
thread = threading.Thread(target=cleanup_old_logs, args=(days_to_keep,), daemon=True)
|
|
41
|
+
thread.start()
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
from xdg_base_dirs import xdg_config_home, xdg_state_home
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
3
5
|
|
|
4
6
|
# Default locations for ssm-cli files
|
|
5
7
|
# ~/.config/ssm-cli/ssm.yaml
|
|
@@ -24,10 +26,16 @@ def get_conf_file(check=True) -> Path:
|
|
|
24
26
|
raise EnvironmentError(f"{path} missing, run `ssm setup` to create")
|
|
25
27
|
return path
|
|
26
28
|
|
|
27
|
-
def get_log_file() -> Path:
|
|
28
|
-
|
|
29
|
+
def get_log_file(date: Optional[datetime] = None) -> Path:
|
|
30
|
+
if date is None:
|
|
31
|
+
date = datetime.now()
|
|
32
|
+
date_str = date.strftime('%Y-%m-%d')
|
|
33
|
+
path = get_state_root() / f"run.{date_str}.log"
|
|
29
34
|
return path
|
|
30
35
|
|
|
36
|
+
def get_all_log_files() -> str:
|
|
37
|
+
return get_state_root().glob('run.*.log')
|
|
38
|
+
|
|
31
39
|
def get_ssh_hostkey(check=True) -> Path:
|
|
32
40
|
path = get_conf_root(check) / 'hostkey.pem'
|
|
33
41
|
if check and not path.exists():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|