Skip to content

Commit b99884e

Browse files
mw-rootleandrodamascenaanafalcaoAna Falcao
authored
feat(event_source): Extend CodePipeline Artifact Capabilities (#5448)
* feat(event_source): Extend CodePipeline Artifact Capabilities * Fix mypy warnings * Update docs * Add Unencrypted Artifact Test * deleting poetry.toml file * add a comment to explain none type for boto3 * fix the comment to explain none type for boto3 --------- Co-authored-by: Leandro Damascena <[email protected]> Co-authored-by: Ana Falcão <[email protected]> Co-authored-by: Ana Falcao <[email protected]>
1 parent 8fd7a0b commit b99884e

File tree

4 files changed

+225
-9
lines changed

4 files changed

+225
-9
lines changed

aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py

+84-8
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,25 @@ def find_input_artifact(self, artifact_name: str) -> CodePipelineArtifact | None
236236
return artifact
237237
return None
238238

239-
def get_artifact(self, artifact_name: str, filename: str) -> str | None:
239+
def find_output_artifact(self, artifact_name: str) -> CodePipelineArtifact | None:
240+
"""Find an output artifact by artifact name
241+
242+
Parameters
243+
----------
244+
artifact_name : str
245+
The name of the output artifact to look for
246+
247+
Returns
248+
-------
249+
CodePipelineArtifact, None
250+
Matching CodePipelineArtifact if found
251+
"""
252+
for artifact in self.data.output_artifacts:
253+
if artifact.name == artifact_name:
254+
return artifact
255+
return None
256+
257+
def get_artifact(self, artifact_name: str, filename: str | None = None) -> str | None:
240258
"""Get a file within an artifact zip on s3
241259
242260
Parameters
@@ -245,6 +263,7 @@ def get_artifact(self, artifact_name: str, filename: str) -> str | None:
245263
Name of the S3 artifact to download
246264
filename : str
247265
The file name within the artifact zip to extract as a string
266+
If None, this will return the raw object body.
248267
249268
Returns
250269
-------
@@ -255,10 +274,67 @@ def get_artifact(self, artifact_name: str, filename: str) -> str | None:
255274
if artifact is None:
256275
return None
257276

258-
with tempfile.NamedTemporaryFile() as tmp_file:
259-
s3 = self.setup_s3_client()
260-
bucket = artifact.location.s3_location.bucket_name
261-
key = artifact.location.s3_location.key
262-
s3.download_file(bucket, key, tmp_file.name)
263-
with zipfile.ZipFile(tmp_file.name, "r") as zip_file:
264-
return zip_file.read(filename).decode("UTF-8")
277+
s3 = self.setup_s3_client()
278+
bucket = artifact.location.s3_location.bucket_name
279+
key = artifact.location.s3_location.key
280+
281+
if filename:
282+
with tempfile.NamedTemporaryFile() as tmp_file:
283+
s3.download_file(bucket, key, tmp_file.name)
284+
with zipfile.ZipFile(tmp_file.name, "r") as zip_file:
285+
return zip_file.read(filename).decode("UTF-8")
286+
287+
return s3.get_object(Bucket=bucket, Key=key)["Body"].read()
288+
289+
def put_artifact(self, artifact_name: str, body: Any, content_type: str) -> None:
290+
"""Writes an object to an s3 output artifact.
291+
292+
Parameters
293+
----------
294+
artifact_name : str
295+
Name of the S3 artifact to upload
296+
body: Any
297+
The data to be written. Binary files should use io.BytesIO.
298+
content_type: str
299+
The content type of the data.
300+
301+
Returns
302+
-------
303+
None
304+
"""
305+
artifact = self.find_output_artifact(artifact_name)
306+
if artifact is None:
307+
raise ValueError(f"Artifact not found: {artifact_name}.")
308+
309+
s3 = self.setup_s3_client()
310+
bucket = artifact.location.s3_location.bucket_name
311+
key = artifact.location.s3_location.key
312+
313+
# boto3 doesn't support None to omit the parameter when using ServerSideEncryption and SSEKMSKeyId
314+
# So we are using if/else instead.
315+
316+
if self.data.encryption_key:
317+
318+
encryption_key_id = self.data.encryption_key.get_id
319+
encryption_key_type = self.data.encryption_key.get_type
320+
if encryption_key_type == "KMS":
321+
encryption_key_type = "aws:kms"
322+
323+
s3.put_object(
324+
Bucket=bucket,
325+
Key=key,
326+
ContentType=content_type,
327+
Body=body,
328+
ServerSideEncryption=encryption_key_type,
329+
SSEKMSKeyId=encryption_key_id,
330+
BucketKeyEnabled=True,
331+
)
332+
333+
else:
334+
s3.put_object(
335+
Bucket=bucket,
336+
Key=key,
337+
ContentType=content_type,
338+
Body=body,
339+
BucketKeyEnabled=True,
340+
)

docs/utilities/data_classes.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,13 @@ Data classes and utility functions to help create continuous delivery pipelines
679679
else:
680680
template = event.get_artifact(artifact_name, template_file)
681681
# Kick off a stack update or create
682-
start_update_or_create(job_id, stack, template)
682+
result = start_update_or_create(job_id, stack, template)
683+
artifact: io.BytesIO = zip_data(result)
684+
event.put_artifact(
685+
artifact_name=event.data.output_artifacts[0].name,
686+
body=artifact,
687+
content_type="application/zip"
688+
)
683689
except Exception as e:
684690
# If any other exceptions which we didn't expect are raised
685691
# then fail the job and log the exception message.

tests/events/codePipelineEventData.json

+4
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
"secretAccessKey": "6CGtmAa3lzWtV7a...",
4141
"sessionToken": "IQoJb3JpZ2luX2VjEA...",
4242
"expirationTime": 1575493418000
43+
},
44+
"encryptionKey": {
45+
"id": "someKey",
46+
"type": "KMS"
4347
}
4448
}
4549
}

tests/unit/data_classes/_boto3/test_code_pipeline_job_event.py

+130
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
22
import zipfile
3+
from io import StringIO
34

45
import pytest
6+
from botocore.response import StreamingBody
57
from pytest_mock import MockerFixture
68

79
from aws_lambda_powertools.utilities.data_classes import CodePipelineJobEvent
@@ -184,3 +186,131 @@ def download_file(bucket: str, key: str, tmp_name: str):
184186
},
185187
)
186188
assert artifact_str == file_contents
189+
190+
191+
def test_raw_code_pipeline_get_artifact(mocker: MockerFixture):
192+
raw_content = json.dumps({"steve": "french"})
193+
194+
class MockClient:
195+
@staticmethod
196+
def get_object(Bucket: str, Key: str):
197+
assert Bucket == "us-west-2-123456789012-my-pipeline"
198+
assert Key == "my-pipeline/test-api-2/TdOSFRV"
199+
return {"Body": StreamingBody(StringIO(str(raw_content)), len(str(raw_content)))}
200+
201+
s3 = mocker.patch("boto3.client")
202+
s3.return_value = MockClient()
203+
204+
event = CodePipelineJobEvent(load_event("codePipelineEventData.json"))
205+
206+
artifact_str = event.get_artifact(artifact_name="my-pipeline-SourceArtifact")
207+
208+
s3.assert_called_once_with(
209+
"s3",
210+
**{
211+
"aws_access_key_id": event.data.artifact_credentials.access_key_id,
212+
"aws_secret_access_key": event.data.artifact_credentials.secret_access_key,
213+
"aws_session_token": event.data.artifact_credentials.session_token,
214+
},
215+
)
216+
assert artifact_str == raw_content
217+
218+
219+
def test_code_pipeline_put_artifact(mocker: MockerFixture):
220+
221+
raw_content = json.dumps({"steve": "french"})
222+
artifact_content_type = "application/json"
223+
event = CodePipelineJobEvent(load_event("codePipelineEventData.json"))
224+
artifact_name = event.data.output_artifacts[0].name
225+
226+
class MockClient:
227+
@staticmethod
228+
def put_object(
229+
Bucket: str,
230+
Key: str,
231+
ContentType: str,
232+
Body: str,
233+
ServerSideEncryption: str,
234+
SSEKMSKeyId: str,
235+
BucketKeyEnabled: bool,
236+
):
237+
output_artifact = event.find_output_artifact(artifact_name)
238+
assert Bucket == output_artifact.location.s3_location.bucket_name
239+
assert Key == output_artifact.location.s3_location.key
240+
assert ContentType == artifact_content_type
241+
assert Body == raw_content
242+
assert ServerSideEncryption == "aws:kms"
243+
assert SSEKMSKeyId == event.data.encryption_key.get_id
244+
assert BucketKeyEnabled is True
245+
246+
s3 = mocker.patch("boto3.client")
247+
s3.return_value = MockClient()
248+
249+
event.put_artifact(
250+
artifact_name=artifact_name,
251+
body=raw_content,
252+
content_type=artifact_content_type,
253+
)
254+
255+
s3.assert_called_once_with(
256+
"s3",
257+
**{
258+
"aws_access_key_id": event.data.artifact_credentials.access_key_id,
259+
"aws_secret_access_key": event.data.artifact_credentials.secret_access_key,
260+
"aws_session_token": event.data.artifact_credentials.session_token,
261+
},
262+
)
263+
264+
265+
def test_code_pipeline_put_unencrypted_artifact(mocker: MockerFixture):
266+
267+
raw_content = json.dumps({"steve": "french"})
268+
artifact_content_type = "application/json"
269+
event_without_artifact_encryption = load_event("codePipelineEventData.json")
270+
event_without_artifact_encryption["CodePipeline.job"]["data"]["encryptionKey"] = None
271+
event = CodePipelineJobEvent(event_without_artifact_encryption)
272+
assert event.data.encryption_key is None
273+
artifact_name = event.data.output_artifacts[0].name
274+
275+
class MockClient:
276+
@staticmethod
277+
def put_object(
278+
Bucket: str,
279+
Key: str,
280+
ContentType: str,
281+
Body: str,
282+
BucketKeyEnabled: bool,
283+
):
284+
output_artifact = event.find_output_artifact(artifact_name)
285+
assert Bucket == output_artifact.location.s3_location.bucket_name
286+
assert Key == output_artifact.location.s3_location.key
287+
assert ContentType == artifact_content_type
288+
assert Body == raw_content
289+
assert BucketKeyEnabled is True
290+
291+
s3 = mocker.patch("boto3.client")
292+
s3.return_value = MockClient()
293+
294+
event.put_artifact(
295+
artifact_name=artifact_name,
296+
body=raw_content,
297+
content_type=artifact_content_type,
298+
)
299+
300+
s3.assert_called_once_with(
301+
"s3",
302+
**{
303+
"aws_access_key_id": event.data.artifact_credentials.access_key_id,
304+
"aws_secret_access_key": event.data.artifact_credentials.secret_access_key,
305+
"aws_session_token": event.data.artifact_credentials.session_token,
306+
},
307+
)
308+
309+
310+
def test_code_pipeline_put_output_artifact_not_found():
311+
raw_event = load_event("codePipelineEventData.json")
312+
parsed_event = CodePipelineJobEvent(raw_event)
313+
314+
assert parsed_event.find_output_artifact("not-found") is None
315+
with pytest.raises(ValueError):
316+
parsed_event.put_artifact(artifact_name="not-found", body="", content_type="text/plain")

0 commit comments

Comments
 (0)