diff --git a/app/integrations/aws/lambdas.py b/app/integrations/aws/lambdas.py new file mode 100644 index 00000000..86d4ff9f --- /dev/null +++ b/app/integrations/aws/lambdas.py @@ -0,0 +1,47 @@ +import logging +from integrations.aws.client import execute_aws_api_call, handle_aws_api_errors + +logger = logging.getLogger(__name__) + + +@handle_aws_api_errors +def list_functions(): + """List all Lambda functions. + + Returns: + list: A list of Lambda functions. + """ + return execute_aws_api_call( + "lambda", "list_functions", paginated=True, keys=["Functions"] + ) + + +@handle_aws_api_errors +def list_layers(): + """List all Lambda layers. + + Returns: + list: A list of Lambda layers. + """ + return execute_aws_api_call( + "lambda", "list_layers", paginated=True, keys=["Layers"] + ) + + +@handle_aws_api_errors +def get_layer_version(layer_name, version_number): + """Get a Lambda layer version. + + Args: + layer_name (str): The name of the layer. + version_number (int): The version number. + + Returns: + dict: The Lambda layer version. + """ + return execute_aws_api_call( + "lambda", + "get_layer_version", + LayerName=layer_name, + VersionNumber=version_number, + ) diff --git a/app/modules/aws/aws.py b/app/modules/aws/aws.py index 143f165a..abf9a559 100644 --- a/app/modules/aws/aws.py +++ b/app/modules/aws/aws.py @@ -16,7 +16,7 @@ from integrations.aws.organizations import get_account_id_by_name from integrations.aws import identity_store from integrations.slack import commands as slack_commands -from modules.aws import aws_access_requests, aws_account_health, groups, users +from modules.aws import aws_access_requests, aws_account_health, groups, users, lambdas PREFIX = os.environ.get("PREFIX", "") AWS_ADMIN_GROUPS = os.environ.get("AWS_ADMIN_GROUPS", "sre-ifs@cds-snc.ca").split(",") @@ -33,6 +33,8 @@ \n ``: `sync`, `list` \n ``: name of the group | nom du groupe (sync only) \n Usage: `/aws groups sync`, `/aws groups sync group-name` or/ou `/aws groups list` +\n `/aws lambdas ` +\n - Manage AWS Lambda functions | Gérer les fonctions Lambda AWS \n `/aws help | aide` \n - Show this help text | montre le dialogue d'aide \n `/aws health` @@ -92,6 +94,8 @@ def aws_command( users.command_handler(client, body, respond, args, logger) case "groups": groups.command_handler(client, body, respond, args, logger) + case "lambda" | "lambdas": + lambdas.command_handler(client, body, respond, args, logger) case _: respond( f"Unknown command: `{action}`. Type `/aws help` to see a list of commands.\n" diff --git a/app/modules/aws/lambdas.py b/app/modules/aws/lambdas.py new file mode 100644 index 00000000..6a660b55 --- /dev/null +++ b/app/modules/aws/lambdas.py @@ -0,0 +1,74 @@ +from slack_sdk.web import WebClient +from slack_bolt import Respond + +from integrations.aws import lambdas as aws_lambdas + +help_text = """ +\n *AWS Lambda*: +\n • `/aws lambda functions` - List all Lambda functions. +\n • `/aws lambda layers` - List all Lambda layers. +""" + + +def command_handler(client: WebClient, body, respond: Respond, args, logger): + """Handle the command. + + Args: + client (Slack WebClient): The Slack client. + body (dict): The request body. + respond (function): The function to respond to the request. + args (list[str]): The list of arguments. + logger (Logger): The logger. + """ + + action = args.pop(0) if args else "" + + match action: + case "help" | "aide": + respond(help_text) + case "functions" | "function": + request_list_functions(client, body, respond, logger) + case "layers" | "layer": + request_list_layers(client, body, respond, logger) + case _: + respond("Invalid command. Type `/aws lambda help` for more information.") + + +def request_list_functions(client: WebClient, body, respond: Respond, logger): + """List all Lambda functions. + + Args: + client (Slack WebClient): The Slack client. + body (dict): The request body. + respond (function): The function to respond to the request. + """ + respond("Fetching Lambda functions...") + response = aws_lambdas.list_functions() + if response: + logger.info(response) + function_string = "" + for function in response: + function_string += f"\n • {function['FunctionName']}" + respond(f"Lambda functions found:\n{function_string}") + else: + respond("Lambda functions management is currently disabled.") + + +def request_list_layers(client: WebClient, body, respond: Respond, logger): + """List all Lambda layers. + + Args: + client (Slack WebClient): The Slack client. + body (dict): The request body. + respond (function): The function to respond to the request. + """ + response = aws_lambdas.list_layers() + respond("Fetching Lambda layers...") + if response: + logger.info(response) + response_string = "" + for layer in response: + response_string += f"\n • {layer['LayerName']} " + respond(f"Lambda layers found:\n{response_string}") + else: + respond("Lambda layers management is currently disabled.") diff --git a/app/tests/integrations/aws/test_lambas.py b/app/tests/integrations/aws/test_lambas.py new file mode 100644 index 00000000..2659fbd3 --- /dev/null +++ b/app/tests/integrations/aws/test_lambas.py @@ -0,0 +1,45 @@ +from unittest.mock import patch +from integrations.aws.lambdas import list_functions, list_layers, get_layer_version + + +@patch("integrations.aws.lambdas.execute_aws_api_call") +def test_list_functions(mock_execute_aws_api_call): + mock_execute_aws_api_call.return_value = [ + {"FunctionName": "function1"}, + {"FunctionName": "function2"}, + ] + result = list_functions() + mock_execute_aws_api_call.assert_called_once_with( + "lambda", "list_functions", paginated=True, keys=["Functions"] + ) + assert result == [ + {"FunctionName": "function1"}, + {"FunctionName": "function2"}, + ] + + +@patch("integrations.aws.lambdas.execute_aws_api_call") +def test_list_layers(mock_execute_aws_api_call): + mock_execute_aws_api_call.return_value = [ + {"LayerName": "layer1", "LatestMatchingVersion": {"Version": 1}}, + {"LayerName": "layer2", "LatestMatchingVersion": {"Version": 23}}, + ] + + result = list_layers() + mock_execute_aws_api_call.assert_called_once_with( + "lambda", "list_layers", paginated=True, keys=["Layers"] + ) + assert result == [ + {"LayerName": "layer1", "LatestMatchingVersion": {"Version": 1}}, + {"LayerName": "layer2", "LatestMatchingVersion": {"Version": 23}}, + ] + + +@patch("integrations.aws.lambdas.execute_aws_api_call") +def test_get_layer_version(mock_execute_aws_api_call): + mock_execute_aws_api_call.return_value = {"LayerName": "layer1", "Version": 1} + result = get_layer_version("layer1", 1) + mock_execute_aws_api_call.assert_called_once_with( + "lambda", "get_layer_version", LayerName="layer1", VersionNumber=1 + ) + assert result == {"LayerName": "layer1", "Version": 1} diff --git a/app/tests/modules/aws/test_aws_lambdas.py b/app/tests/modules/aws/test_aws_lambdas.py new file mode 100644 index 00000000..c33bba33 --- /dev/null +++ b/app/tests/modules/aws/test_aws_lambdas.py @@ -0,0 +1,155 @@ +from unittest.mock import patch, MagicMock +from modules.aws import lambdas + + +def test_aws_lambdas_command_handles_empty_command(): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + args = "" + logger = MagicMock() + + lambdas.command_handler(client, body, respond, args, logger) + + respond.assert_called_once_with( + "Invalid command. Type `/aws lambda help` for more information." + ) + logger.info.assert_not_called() + + +def test_aws_lambdas_command_handles_help_command(): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + args = ["help"] + logger = MagicMock() + + lambdas.command_handler(client, body, respond, args, logger) + + respond.assert_called_once_with(lambdas.help_text) + logger.info.assert_not_called() + + +@patch("integrations.aws.lambdas.list_functions") +def test_aws_lambdas_command_handles_functions_command(mock_list_functions): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + args = ["functions"] + logger = MagicMock() + + mock_list_functions.return_value = [{"FunctionName": "test-function"}] + + lambdas.command_handler(client, body, respond, args, logger) + + respond.assert_any_call("Fetching Lambda functions...") + respond.assert_any_call("Lambda functions found:\n\n • test-function") + logger.info.assert_called_once_with([{"FunctionName": "test-function"}]) + + +@patch("integrations.aws.lambdas.list_layers") +def test_aws_lambdas_command_handles_layers_command(mock_list_layers): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + args = ["layers"] + logger = MagicMock() + + mock_list_layers.return_value = [ + { + "LayerName": "aws-sentinel-connector-layer", + "LatestMatchingVersion": {"Version": 163}, + } + ] + + lambdas.command_handler(client, body, respond, args, logger) + + respond.assert_any_call("Fetching Lambda layers...") + respond.assert_any_call( + "Lambda layers found:\n\n • aws-sentinel-connector-layer " + ) + logger.info.assert_called_once_with( + [ + { + "LayerName": "aws-sentinel-connector-layer", + "LatestMatchingVersion": {"Version": 163}, + } + ] + ) + + +@patch("integrations.aws.lambdas.list_functions") +def test_request_list_functions_handles_empty_response(mock_list_functions): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + logger = MagicMock() + + mock_list_functions.return_value = [] + + lambdas.request_list_functions(client, body, respond, logger) + + respond.assert_any_call("Fetching Lambda functions...") + logger.info.assert_not_called() + + +@patch("integrations.aws.lambdas.list_functions") +def test_request_list_functions_handles_non_empty_response(mock_list_functions): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + logger = MagicMock() + + mock_list_functions.return_value = [{"FunctionName": "test-function"}] + + lambdas.request_list_functions(client, body, respond, logger) + + respond.assert_any_call("Fetching Lambda functions...") + respond.assert_any_call("Lambda functions found:\n\n • test-function") + logger.info.assert_called_once_with([{"FunctionName": "test-function"}]) + + +@patch("integrations.aws.lambdas.list_layers") +def test_request_list_layers_handles_empty_response(mock_list_layers): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + logger = MagicMock() + + mock_list_layers.return_value = [] + + lambdas.request_list_layers(client, body, respond, logger) + + respond.assert_any_call("Fetching Lambda layers...") + respond.assert_any_call("Lambda layers management is currently disabled.") + logger.info.assert_not_called() + + +@patch("integrations.aws.lambdas.list_layers") +def test_request_list_layers_handles_non_empty_response(mock_list_layers): + client = MagicMock() + body = MagicMock() + respond = MagicMock() + logger = MagicMock() + + mock_list_layers.return_value = [ + { + "LayerName": "aws-sentinel-connector-layer", + "LatestMatchingVersion": {"Version": 163}, + } + ] + + lambdas.request_list_layers(client, body, respond, logger) + + respond.assert_any_call("Fetching Lambda layers...") + respond.assert_any_call( + "Lambda layers found:\n\n • aws-sentinel-connector-layer " + ) + logger.info.assert_called_once_with( + [ + { + "LayerName": "aws-sentinel-connector-layer", + "LatestMatchingVersion": {"Version": 163}, + } + ] + )