Skip to content

Commit b2b2ff0

Browse files
Refactor Some Command-Related Methods in aws_ssm.py (#2248) (#2261)
This is a backport of PR #2248 as merged into main (3111215). SUMMARY This work is part of the currently ongoing AWS SSM Connection Refactoring & Plugin Promotion effort and related to ansible-collections/amazon.aws#2394. Fixes ACA-2095 ISSUE TYPE Feature Pull Request TASK LIST The _exec_transport_commands method is refactored to accept a single structured object (a tuple containing lists of command dictionaries) that contains the necessary command data. Create a custom dataclass to hold command output. Create a custom dataclass TypedDict to hold command results. The _generate_commands function returns a list of typed dictionaries with clear metadata (command strings, method, headers). _exec_transport_commands accepts a list of typed dictionaries as an argument and returns the custom CommandResults object. Python type hints are added to the method signature and the structured object to improve readability, facilitate static analysis, and clearly define the expected structure and data types. Docstrings should be added to any refactored methods and the structured object (if custom), clarifying the purpose, structure, inputs, outputs, and any edge cases or special handling (if applicable). Unit tests have been created to ensure that the refactored _generate_commands method outputs the expected structured command object. Add a changelog file. Reviewed-by: Alina Buzachis
1 parent 440b786 commit b2b2ff0

File tree

3 files changed

+159
-37
lines changed

3 files changed

+159
-37
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
minor_changes:
2+
- aws_ssm - Refactor ``_exec_transport_commands``, ``_generate_commands``, and ``_exec_transport_commands`` methods for improved clarity (https://github.com/ansible-collections/community.aws/pull/2248).

plugins/connection/aws_ssm.py

+115-37
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,12 @@
332332
import string
333333
import subprocess
334334
import time
335+
from typing import Dict
336+
from typing import List
335337
from typing import NoReturn
336338
from typing import Optional
337339
from typing import Tuple
340+
from typing import TypedDict
338341

339342
try:
340343
import boto3
@@ -439,6 +442,16 @@ def filter_ansi(line: str, is_windows: bool) -> str:
439442
return line
440443

441444

445+
class CommandResult(TypedDict):
446+
"""
447+
A dictionary that contains the executed command results.
448+
"""
449+
450+
returncode: int
451+
stdout_combined: str
452+
stderr_combined: str
453+
454+
442455
class Connection(ConnectionBase):
443456
"""AWS SSM based connections"""
444457

@@ -974,15 +987,46 @@ def _generate_encryption_settings(self):
974987
put_headers["x-amz-server-side-encryption-aws-kms-key-id"] = self.get_option("bucket_sse_kms_key_id")
975988
return put_args, put_headers
976989

977-
def _generate_commands(self, bucket_name, s3_path, in_path, out_path):
990+
def _generate_commands(
991+
self,
992+
bucket_name: str,
993+
s3_path: str,
994+
in_path: str,
995+
out_path: str,
996+
) -> Tuple[List[Dict], dict]:
997+
"""
998+
Generate commands for the specified bucket, S3 path, input path, and output path.
999+
1000+
:param bucket_name: The name of the S3 bucket used for file transfers.
1001+
:param s3_path: The S3 path to the file to be sent.
1002+
:param in_path: Input path
1003+
:param out_path: Output path
1004+
:param method: The request method to use for the command (can be "get" or "put").
1005+
1006+
:returns: A tuple containing a list of command dictionaries along with any ``put_args`` dictionaries.
1007+
"""
1008+
9781009
put_args, put_headers = self._generate_encryption_settings()
1010+
commands = []
9791011

9801012
put_url = self._get_url("put_object", bucket_name, s3_path, "PUT", extra_args=put_args)
9811013
get_url = self._get_url("get_object", bucket_name, s3_path, "GET")
9821014

9831015
if self.is_windows:
9841016
put_command_headers = "; ".join([f"'{h}' = '{v}'" for h, v in put_headers.items()])
985-
put_commands = [
1017+
commands.append({
1018+
"command":
1019+
(
1020+
"Invoke-WebRequest "
1021+
f"'{get_url}' "
1022+
f"-OutFile '{out_path}'"
1023+
),
1024+
# The "method" key indicates to _file_transport_command which commands are get_commands
1025+
"method": "get",
1026+
"headers": {},
1027+
}) # fmt: skip
1028+
commands.append({
1029+
"command":
9861030
(
9871031
"Invoke-WebRequest -Method PUT "
9881032
# @{'key' = 'value'; 'key2' = 'value2'}
@@ -991,47 +1035,66 @@ def _generate_commands(self, bucket_name, s3_path, in_path, out_path):
9911035
f"-Uri '{put_url}' "
9921036
f"-UseBasicParsing"
9931037
),
994-
] # fmt: skip
995-
get_commands = [
996-
(
997-
"Invoke-WebRequest "
998-
f"'{get_url}' "
999-
f"-OutFile '{out_path}'"
1000-
),
1001-
] # fmt: skip
1038+
# The "method" key indicates to _file_transport_command which commands are put_commands
1039+
"method": "put",
1040+
"headers": put_headers,
1041+
}) # fmt: skip
10021042
else:
10031043
put_command_headers = " ".join([f"-H '{h}: {v}'" for h, v in put_headers.items()])
1004-
put_commands = [
1005-
(
1006-
"curl --request PUT "
1007-
f"{put_command_headers} "
1008-
f"--upload-file '{in_path}' "
1009-
f"'{put_url}'"
1010-
),
1011-
] # fmt: skip
1012-
get_commands = [
1044+
commands.append({
1045+
"command":
10131046
(
10141047
"curl "
10151048
f"-o '{out_path}' "
10161049
f"'{get_url}'"
10171050
),
1018-
# Due to https://github.com/curl/curl/issues/183 earlier
1019-
# versions of curl did not create the output file, when the
1020-
# response was empty. Although this issue was fixed in 2015,
1021-
# some actively maintained operating systems still use older
1022-
# versions of it (e.g. CentOS 7)
1051+
# The "method" key indicates to _file_transport_command which commands are get_commands
1052+
"method": "get",
1053+
"headers": {},
1054+
}) # fmt: skip
1055+
# Due to https://github.com/curl/curl/issues/183 earlier
1056+
# versions of curl did not create the output file, when the
1057+
# response was empty. Although this issue was fixed in 2015,
1058+
# some actively maintained operating systems still use older
1059+
# versions of it (e.g. CentOS 7)
1060+
commands.append({
1061+
"command":
10231062
(
10241063
"touch "
10251064
f"'{out_path}'"
1026-
)
1027-
] # fmt: skip
1065+
),
1066+
"method": "get",
1067+
"headers": {},
1068+
}) # fmt: skip
1069+
commands.append({
1070+
"command":
1071+
(
1072+
"curl --request PUT "
1073+
f"{put_command_headers} "
1074+
f"--upload-file '{in_path}' "
1075+
f"'{put_url}'"
1076+
),
1077+
# The "method" key indicates to _file_transport_command which commands are put_commands
1078+
"method": "put",
1079+
"headers": put_headers,
1080+
}) # fmt: skip
1081+
1082+
return commands, put_args
10281083

1029-
return get_commands, put_commands, put_args
1084+
def _exec_transport_commands(self, in_path: str, out_path: str, commands: List[dict]) -> CommandResult:
1085+
"""
1086+
Execute the provided transport commands.
1087+
1088+
:param in_path: The input path.
1089+
:param out_path: The output path.
1090+
:param commands: A list of command dictionaries containing the command string and metadata.
1091+
1092+
:returns: A tuple containing the return code, stdout, and stderr.
1093+
"""
10301094

1031-
def _exec_transport_commands(self, in_path, out_path, commands):
10321095
stdout_combined, stderr_combined = "", ""
10331096
for command in commands:
1034-
(returncode, stdout, stderr) = self.exec_command(command, in_data=None, sudoable=False)
1097+
(returncode, stdout, stderr) = self.exec_command(command["command"], in_data=None, sudoable=False)
10351098

10361099
# Check the return code
10371100
if returncode != 0:
@@ -1043,31 +1106,46 @@ def _exec_transport_commands(self, in_path, out_path, commands):
10431106
return (returncode, stdout_combined, stderr_combined)
10441107

10451108
@_ssm_retry
1046-
def _file_transport_command(self, in_path, out_path, ssm_action):
1047-
"""transfer a file to/from host using an intermediate S3 bucket"""
1109+
def _file_transport_command(
1110+
self,
1111+
in_path: str,
1112+
out_path: str,
1113+
ssm_action: str,
1114+
) -> CommandResult:
1115+
"""
1116+
Transfer file(s) to/from host using an intermediate S3 bucket and then delete the file(s).
1117+
1118+
:param in_path: The input path.
1119+
:param out_path: The output path.
1120+
:param ssm_action: The SSM action to perform ("get" or "put").
1121+
1122+
:returns: The command's return code, stdout, and stderr in a tuple.
1123+
"""
10481124

10491125
bucket_name = self.get_option("bucket_name")
10501126
s3_path = self._escape_path(f"{self.instance_id}/{out_path}")
10511127

1052-
get_commands, put_commands, put_args = self._generate_commands(
1128+
client = self._s3_client
1129+
1130+
commands, put_args = self._generate_commands(
10531131
bucket_name,
10541132
s3_path,
10551133
in_path,
10561134
out_path,
10571135
)
10581136

1059-
client = self._s3_client
1060-
10611137
try:
10621138
if ssm_action == "get":
1063-
(returncode, stdout, stderr) = self._exec_transport_commands(in_path, out_path, put_commands)
1139+
put_commands = [cmd for cmd in commands if cmd.get("method") == "put"]
1140+
result = self._exec_transport_commands(in_path, out_path, put_commands)
10641141
with open(to_bytes(out_path, errors="surrogate_or_strict"), "wb") as data:
10651142
client.download_fileobj(bucket_name, s3_path, data)
10661143
else:
1144+
get_commands = [cmd for cmd in commands if cmd.get("method") == "get"]
10671145
with open(to_bytes(in_path, errors="surrogate_or_strict"), "rb") as data:
10681146
client.upload_fileobj(data, bucket_name, s3_path, ExtraArgs=put_args)
1069-
(returncode, stdout, stderr) = self._exec_transport_commands(in_path, out_path, get_commands)
1070-
return (returncode, stdout, stderr)
1147+
result = self._exec_transport_commands(in_path, out_path, get_commands)
1148+
return result
10711149
finally:
10721150
# Remove the files from the bucket after they've been transferred
10731151
client.delete_object(Bucket=bucket_name, Key=s3_path)

tests/unit/plugins/connection/aws_ssm/test_aws_ssm.py

+42
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,45 @@ def test_generate_mark(self):
268268
assert test_a != test_b
269269
assert len(test_a) == Connection.MARK_LENGTH
270270
assert len(test_b) == Connection.MARK_LENGTH
271+
272+
@pytest.mark.parametrize("is_windows", [False, True])
273+
def test_generate_commands(self, is_windows):
274+
"""Testing command generation on Windows systems"""
275+
pc = PlayContext()
276+
new_stdin = StringIO()
277+
conn = connection_loader.get("community.aws.aws_ssm", pc, new_stdin)
278+
conn.get_option = MagicMock()
279+
280+
conn.is_windows = is_windows
281+
282+
mock_s3_client = MagicMock()
283+
mock_s3_client.generate_presigned_url.return_value = "https://test-url"
284+
conn._s3_client = mock_s3_client
285+
286+
test_command_generation = conn._generate_commands(
287+
"test_bucket",
288+
"test/s3/path",
289+
"test/in/path",
290+
"test/out/path",
291+
)
292+
293+
# Check contents of generated command dictionaries
294+
assert "command" in test_command_generation[0][0]
295+
assert "method" in test_command_generation[0][0]
296+
assert "headers" in test_command_generation[0][0]
297+
298+
if is_windows:
299+
assert "Invoke-WebRequest" in test_command_generation[0][1]["command"]
300+
assert test_command_generation[0][1]["method"] == "put"
301+
# Two command dictionaries are generated for Windows
302+
assert len(test_command_generation[0]) == 2
303+
else:
304+
assert "curl --request PUT -H" in test_command_generation[0][2]["command"]
305+
assert test_command_generation[0][2]["method"] == "put"
306+
# Three command dictionaries are generated on non-Windows systems
307+
assert len(test_command_generation[0]) == 3
308+
309+
# Ensure data types of command object are as expected
310+
assert isinstance(test_command_generation, tuple)
311+
assert isinstance(test_command_generation[0], list)
312+
assert isinstance(test_command_generation[0][0], dict)

0 commit comments

Comments
 (0)