From cba9daa588d1094fbb59e74de5a3cd02f1971227 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Wed, 19 Nov 2025 13:56:27 -0700 Subject: [PATCH 01/29] add lambda code --- .../lambda_src/lambda_function.py | 254 ++++++++++++++++++ terraform/services/cost-anomaly/main.tf | 53 +++- 2 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 terraform/services/cost-anomaly/lambda_src/lambda_function.py diff --git a/terraform/services/cost-anomaly/lambda_src/lambda_function.py b/terraform/services/cost-anomaly/lambda_src/lambda_function.py new file mode 100644 index 00000000..7001794e --- /dev/null +++ b/terraform/services/cost-anomaly/lambda_src/lambda_function.py @@ -0,0 +1,254 @@ +""" +Receives messages from Cost Anomaly Monitor via SQS subscription to SNS. +Forwards the message to Slack channel #dasg_metrics_and_insights. +""" + +from datetime import datetime, timezone +import json +import os +from urllib import request +from urllib.error import URLError +import boto3 +from botocore.exceptions import ClientError + +SSM_PARAMETER_CACHE = {} + +# pylint: disable=too-few-public-methods +class Field: + """Represents a field object from SNS JSON.""" + + def __init__(self, field_type, text, emoji): + """ + Initialize a Field object. + + Args: + field_type: The type of the field + text: Text to be displayed + emoji: Boolean indicating if emoji should be used + """ + self.type = field_type + self.text = text + self.emoji = emoji + +# pylint: disable=too-few-public-methods +class Block: + """Represents a block object from SNS JSON.""" + + def __init__(self, block_type, **kwargs): + """ + Initialize a Block object. + + Args: + block_type: The type of the block + **kwargs: Optional fields (fields, text) + """ + self.type = block_type + if kwargs.get("fields"): + self.fields = kwargs.get("fields") + if kwargs.get("text"): + self.text = kwargs.get("text") + +# pylint: disable=too-few-public-methods +class Text: + """Represents a text object from SNS JSON.""" + + def __init__(self, text_type, text, **kwargs): + """ + Initialize a Text object. + + Args: + text_type: The type of the text + text: Text to be displayed + **kwargs: Optional emoji parameter + """ + self.type = text_type + self.text = text + if kwargs.get("emoji"): + self.emoji = kwargs.get("emoji") + + +def get_ssm_client(): + """ + Lazy initialization of boto3 SSM client. + Prevents global instantiation to avoid NoRegionError during tests. + + Returns: + boto3.client: SSM client instance + """ + return boto3.client('ssm') + + +def get_ssm_parameter(name): + """ + Retrieve an SSM parameter and cache the value to prevent duplicate API calls. + Caches None if the parameter is not found or an error occurs. + + Args: + name: The name of the SSM parameter + + Returns: + str or None: The parameter value or None if not found + """ + if name not in SSM_PARAMETER_CACHE: + try: + ssm_client = get_ssm_client() + response = ssm_client.get_parameter(Name=name, WithDecryption=True) + value = response['Parameter']['Value'] + SSM_PARAMETER_CACHE[name] = value + except ClientError as error: + log({'msg': f'Error getting SSM parameter {name}: {error}'}) + SSM_PARAMETER_CACHE[name] = None + + return SSM_PARAMETER_CACHE[name] + + +def is_ignore_ok(): + """ + Return the current value of the IGNORE_OK environment variable. + This allows tests to patch the environment dynamically. + + Returns: + bool: True if IGNORE_OK is set to 'true', False otherwise + """ + return os.environ.get('IGNORE_OK', 'false').lower() == 'true' + +# pylint: disable=too-many-locals +def lambda_handler(event,context): + """ + Handle incoming Lambda events from Cost Anomaly Monitor. + + Args: + event: Lambda event containing SNS message + context: Lambda context (unused) + + Returns: + dict: Status code and response message + """ + print(json.dumps(event)) + print(json.dumps(context)) + + print("Retrieve Slack URL from Secrets Manager") + + slack_url = get_ssm_parameter('/cdap/sensitive/webhook/cost-anomaly') + + print("Slack Webhook URL retrieved") + + print("Initialise Slack Webhook Client") + + print("Decoding the SNS Message") + anomaly_event = json.loads(event["Records"][0]["Sns"]["Message"]) + + totalcost_impact = anomaly_event["impact"]["totalImpact"] + + anomaly_start_date = anomaly_event["anomalyStartDate"] + anomaly_end_date = anomaly_event["anomalyEndDate"] + + anomaly_details_link = anomaly_event["anomalyDetailsLink"] + + blocks = [] + + header_text = Text("plain_text", ":warning: Cost Anomaly Detected ", emoji=True) + total_anomaly_cost_text = Text( + "mrkdwn", f"*Total Anomaly Cost*: ${totalcost_impact}" + ) + root_causes_header_text = Text("mrkdwn", "*Root Causes* :mag:") + anomaly_start_date_text = Text( + "mrkdwn", f"*Anomaly Start Date*: {anomaly_start_date}" + ) + anomaly_end_date_text = Text( + "mrkdwn", f"*Anomaly End Date*: {anomaly_end_date}" + ) + anomaly_details_text = Text( + "mrkdwn", f"*Anomaly Details Link*: {anomaly_details_link}" + ) + + blocks.append(Block("header", text=header_text.__dict__)) + blocks.append(Block("section", text=total_anomaly_cost_text.__dict__)) + blocks.append(Block("section", text=anomaly_start_date_text.__dict__)) + blocks.append(Block("section", text=anomaly_end_date_text.__dict__)) + blocks.append(Block("section", text=anomaly_details_text.__dict__)) + blocks.append(Block("section", text=root_causes_header_text.__dict__)) + + for root_cause in anomaly_event["rootCauses"]: + fields = [] + for root_cause_attribute in root_cause: + if root_cause_attribute == "linkedAccount": + fields.append( + Field("plain_text", "accountName : non-prod", False) + ) + fields.append( + Field( + "plain_text", + f"{root_cause_attribute} : {root_cause[root_cause_attribute]}", + False + ) + ) + blocks.append(Block("section", fields=[ob.__dict__ for ob in fields])) + + send_message_to_slack( + slack_url, + anomaly_event, + json.dumps([ob.__dict__ for ob in blocks]) + ) + + return { + 'statusCode': 200, + 'responseMessage': 'Posted to Slack Channel Successfully' + } + + +def send_message_to_slack(webhook, message, message_id): + """ + Call the Slack webhook with the formatted message. + + Args: + webhook: Slack webhook URL + message: Message content to send + message_id: Identifier for the message + + Returns: + bool: True if successful, False otherwise + """ + if not webhook: + log({ + 'msg': 'Unable to send to Slack as webhook URL is not set', + 'messageId': message_id + }) + return False + + jsondata = json.dumps(message) + jsondataasbytes = jsondata.encode('utf-8') + req = request.Request(webhook) + req.add_header('Content-Type', 'application/json; charset=utf-8') + req.add_header('Content-Length', str(len(jsondataasbytes))) + + try: + with request.urlopen(req, jsondataasbytes) as resp: + if resp.status == 200: + log({ + 'msg': 'Successfully sent message to Slack', + 'messageId': message_id + }) + return True + log({ + 'msg': f'Unsuccessful attempt to send message to Slack ({resp.status})', + 'messageId': message_id + }) + return False + except URLError as error: + log({ + 'msg': f'Unsuccessful attempt to send message to Slack ({error.reason})', + 'messageId': message_id + }) + return False + + +def log(data): + """ + Enrich the log message with the current time and print it to standard out. + + Args: + data: Dictionary containing log data + """ + data['time'] = datetime.now().astimezone(tz=timezone.utc).isoformat() + print(json.dumps(data)) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index c240c624..14e3b00c 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -1,10 +1,9 @@ -data "aws_caller_identity" "current" {} - locals { function_name = "cost-anomaly-alert" app = "bcda" service = "cost-anomaly" default_tags = module.platform.default_tags + ssm_parameter = "/cdap/sensitive/webhook/cost-anomaly" } module "platform" { @@ -96,3 +95,53 @@ resource "aws_sns_topic_subscription" "this" { protocol = "sqs" topic_arn = aws_sns_topic.cost_anomaly_sns.arn } + +# IAM role for Lambda execution +data "aws_iam_policy_document" "assume_role" { + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + + actions = ["sts:AssumeRole"] + } +} + +resource "aws_iam_role" "cost_anomaly_alert" { + name = "lambda_execution_role" + assume_role_policy = data.aws_iam_policy_document.assume_role.json +} + +# Package the Lambda function code +data "archive_file" "cost_anomaly_alert" { + type = "zip" + source_file = "lambda_src/lambda_function.py" + output_path = "lambda/cost_anomaly_function.zip" +} + +# Lambda function +resource "aws_lambda_function" "cost_anomaly_alert" { + filename = data.archive_file.cost_anomaly_alert.output_path + function_name = "cost_anomaly_alert_lambda_function" + role = aws_iam_role.cost_anomaly_alert.arn + handler = "lambda_function.lambda_handler" + source_code_hash = data.archive_file.cost_anomaly_alert.output_base64sha256 + + runtime = "python3.13" + + environment { + variables = { + ENVIRONMENT = var.env + IGNORE_OK = "false" + WEBHOOK_PARAM = local.ssm_parameter + } + } + + tags = { + Environment = var.env + Application = "cost_anomaly_alert" + } +} From 68de2b1ce5babd31bd50389fc92d6f36b07b7b9f Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Thu, 20 Nov 2025 08:49:28 -0700 Subject: [PATCH 02/29] deployment changes --- terraform/services/cost-anomaly/main.tf | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index 14e3b00c..efeede3b 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -3,7 +3,6 @@ locals { app = "bcda" service = "cost-anomaly" default_tags = module.platform.default_tags - ssm_parameter = "/cdap/sensitive/webhook/cost-anomaly" } module "platform" { @@ -17,7 +16,7 @@ module "platform" { } resource "aws_ce_anomaly_monitor" "account_alerts" { - name = "AccountAlerts" + name = "BCDA Cost Anomaly Alerts" monitor_type = "DIMENSIONAL" monitor_dimension = "SERVICE" } @@ -45,14 +44,14 @@ resource "aws_ce_anomaly_subscription" "realtime_subscription" { dimension { key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE" match_options = ["GREATER_THAN_OR_EQUAL"] - values = ["20"] + values = ["0.1"] # non-testing value is 20 } } or { dimension { key = "ANOMALY_TOTAL_IMPACT_PERCENTAGE" match_options = ["GREATER_THAN_OR_EQUAL"] - values = ["5"] + values = ["1"] # non-testing value is 5 } } } @@ -111,7 +110,7 @@ data "aws_iam_policy_document" "assume_role" { } resource "aws_iam_role" "cost_anomaly_alert" { - name = "lambda_execution_role" + name = "cost_anomaly_lambda_execution_role" assume_role_policy = data.aws_iam_policy_document.assume_role.json } @@ -125,7 +124,7 @@ data "archive_file" "cost_anomaly_alert" { # Lambda function resource "aws_lambda_function" "cost_anomaly_alert" { filename = data.archive_file.cost_anomaly_alert.output_path - function_name = "cost_anomaly_alert_lambda_function" + function_name = local.function_name role = aws_iam_role.cost_anomaly_alert.arn handler = "lambda_function.lambda_handler" source_code_hash = data.archive_file.cost_anomaly_alert.output_base64sha256 @@ -136,12 +135,8 @@ resource "aws_lambda_function" "cost_anomaly_alert" { variables = { ENVIRONMENT = var.env IGNORE_OK = "false" - WEBHOOK_PARAM = local.ssm_parameter } } - tags = { - Environment = var.env - Application = "cost_anomaly_alert" - } + tags = module.platform.default_tags } From 4a65bb2de3bf72c5aecc5de14b65a492b6d99554 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Thu, 20 Nov 2025 09:05:01 -0700 Subject: [PATCH 03/29] adding sqs permission --- terraform/services/cost-anomaly/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index efeede3b..6540e89d 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -105,7 +105,7 @@ data "aws_iam_policy_document" "assume_role" { identifiers = ["lambda.amazonaws.com"] } - actions = ["sts:AssumeRole"] + actions = ["sts:AssumeRole","sqs:ReceiveMessage"] } } From 2f0d4afafb3f66768d3c46215591c90d953d6ccb Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Thu, 20 Nov 2025 09:20:52 -0700 Subject: [PATCH 04/29] adding sqs permission policy --- terraform/services/cost-anomaly/main.tf | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index 6540e89d..a2859437 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -105,7 +105,21 @@ data "aws_iam_policy_document" "assume_role" { identifiers = ["lambda.amazonaws.com"] } - actions = ["sts:AssumeRole","sqs:ReceiveMessage"] + actions = ["sts:AssumeRole"] + } +} + +data "aws_iam_policy_document" "lambda_permissions" { + statement { + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ] + + resources = [module.sns_to_slack_queue.arn] } } @@ -114,6 +128,13 @@ resource "aws_iam_role" "cost_anomaly_alert" { assume_role_policy = data.aws_iam_policy_document.assume_role.json } +# Attach the SQS permissions policy +resource "aws_iam_role_policy" "lambda_sqs_permissions" { + name = "lambda-sqs-permissions" + role = aws_iam_role.cost_anomaly_alert.id + policy = data.aws_iam_policy_document.lambda_permissions.json +} + # Package the Lambda function code data "archive_file" "cost_anomaly_alert" { type = "zip" From 9d1e109dd37b2dbe958c5e128e91f35661e345d4 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Thu, 20 Nov 2025 10:26:30 -0700 Subject: [PATCH 05/29] python lint fix to use context --- terraform/services/cost-anomaly/lambda_src/lambda_function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/services/cost-anomaly/lambda_src/lambda_function.py b/terraform/services/cost-anomaly/lambda_src/lambda_function.py index 7001794e..8308d0d1 100644 --- a/terraform/services/cost-anomaly/lambda_src/lambda_function.py +++ b/terraform/services/cost-anomaly/lambda_src/lambda_function.py @@ -125,7 +125,7 @@ def lambda_handler(event,context): dict: Status code and response message """ print(json.dumps(event)) - print(json.dumps(context)) + print(context) print("Retrieve Slack URL from Secrets Manager") From d917f9e64b823fd34b7167fe6765a6056a8a0314 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Thu, 20 Nov 2025 11:29:41 -0700 Subject: [PATCH 06/29] testing --- .../lambda_src/lambda_function.py | 221 +++++++++++------- terraform/services/cost-anomaly/main.tf | 3 +- 2 files changed, 145 insertions(+), 79 deletions(-) diff --git a/terraform/services/cost-anomaly/lambda_src/lambda_function.py b/terraform/services/cost-anomaly/lambda_src/lambda_function.py index 8308d0d1..5333fccf 100644 --- a/terraform/services/cost-anomaly/lambda_src/lambda_function.py +++ b/terraform/services/cost-anomaly/lambda_src/lambda_function.py @@ -6,11 +6,16 @@ from datetime import datetime, timezone import json import os +import logging +from logging import Logger from urllib import request from urllib.error import URLError import boto3 from botocore.exceptions import ClientError +logger: Logger = logging.getLogger() +logger.setLevel(logging.INFO) + SSM_PARAMETER_CACHE = {} # pylint: disable=too-few-public-methods @@ -113,89 +118,149 @@ def is_ignore_ok(): return os.environ.get('IGNORE_OK', 'false').lower() == 'true' # pylint: disable=too-many-locals -def lambda_handler(event,context): +def lambda_handler(event, context): + """ + Parse AWS Cost Anomaly Detection SNS messages """ - Handle incoming Lambda events from Cost Anomaly Monitor. + logger.info(f"Received event: {json.dumps(event)}") - Args: - event: Lambda event containing SNS message - context: Lambda context (unused) + message = "test" - Returns: - dict: Status code and response message - """ - print(json.dumps(event)) - print(context) - - print("Retrieve Slack URL from Secrets Manager") - - slack_url = get_ssm_parameter('/cdap/sensitive/webhook/cost-anomaly') - - print("Slack Webhook URL retrieved") - - print("Initialise Slack Webhook Client") - - print("Decoding the SNS Message") - anomaly_event = json.loads(event["Records"][0]["Sns"]["Message"]) - - totalcost_impact = anomaly_event["impact"]["totalImpact"] - - anomaly_start_date = anomaly_event["anomalyStartDate"] - anomaly_end_date = anomaly_event["anomalyEndDate"] - - anomaly_details_link = anomaly_event["anomalyDetailsLink"] - - blocks = [] - - header_text = Text("plain_text", ":warning: Cost Anomaly Detected ", emoji=True) - total_anomaly_cost_text = Text( - "mrkdwn", f"*Total Anomaly Cost*: ${totalcost_impact}" - ) - root_causes_header_text = Text("mrkdwn", "*Root Causes* :mag:") - anomaly_start_date_text = Text( - "mrkdwn", f"*Anomaly Start Date*: {anomaly_start_date}" - ) - anomaly_end_date_text = Text( - "mrkdwn", f"*Anomaly End Date*: {anomaly_end_date}" - ) - anomaly_details_text = Text( - "mrkdwn", f"*Anomaly Details Link*: {anomaly_details_link}" - ) - - blocks.append(Block("header", text=header_text.__dict__)) - blocks.append(Block("section", text=total_anomaly_cost_text.__dict__)) - blocks.append(Block("section", text=anomaly_start_date_text.__dict__)) - blocks.append(Block("section", text=anomaly_end_date_text.__dict__)) - blocks.append(Block("section", text=anomaly_details_text.__dict__)) - blocks.append(Block("section", text=root_causes_header_text.__dict__)) - - for root_cause in anomaly_event["rootCauses"]: - fields = [] - for root_cause_attribute in root_cause: - if root_cause_attribute == "linkedAccount": - fields.append( - Field("plain_text", "accountName : non-prod", False) - ) - fields.append( - Field( - "plain_text", - f"{root_cause_attribute} : {root_cause[root_cause_attribute]}", - False - ) - ) - blocks.append(Block("section", fields=[ob.__dict__ for ob in fields])) - - send_message_to_slack( - slack_url, - anomaly_event, - json.dumps([ob.__dict__ for ob in blocks]) - ) - - return { - 'statusCode': 200, - 'responseMessage': 'Posted to Slack Channel Successfully' + try: + # Handle SQS trigger (SNS messages delivered via SQS) + if 'Records' in event: + for record in event['Records']: + # Extract SNS message from SQS + if 'body' in record: + body = json.loads(record['body']) + + # Check if it's an SNS message + if 'Message' in body: + sns_message = json.loads(body['Message']) + message = process_cost_anomaly(sns_message) + else: + logger.warning("No SNS Message found in SQS body") + + # Handle direct SNS trigger + elif 'Records' in event and event['Records'][0].get('EventSource') == 'aws:sns': + for record in event['Records']: + sns_message = json.loads(record['Sns']['Message']) + message = process_cost_anomaly(sns_message) + + # Handle direct invocation with message + else: + message = process_cost_anomaly(event) + + webhook = get_ssm_parameter("/cdap/sensitive/webhook/cost-anomaly") + send_message_to_slack(webhook,message,0) + + return { + 'statusCode': 200, + 'body': json.dumps('Successfully processed cost anomaly alert') + } + + except Exception as e: + logger.error(f"Error processing message: {str(e)}", exc_info=True) + raise + +def process_cost_anomaly(message): + """ + Process and parse the cost anomaly detection message + """ + logger.info("Processing cost anomaly message") + + # Extract key information + account_id = message.get('accountId', 'Unknown') + anomaly_id = message.get('anomalyId', 'Unknown') + anomaly_score = message.get('anomalyScore', 0) + + # Get impact details + impact = message.get('impact', {}) + max_impact = impact.get('maxImpact', 0) + total_impact = impact.get('totalImpact', 0) + + # Get date information + anomaly_start = message.get('anomalyStartDate', 'Unknown') + anomaly_end = message.get('anomalyEndDate', 'Unknown') + + # Get root causes + root_causes = message.get('rootCauses', []) + + # Get dimension details + dimension_value = message.get('dimensionValue', 'Unknown') + + # Format the parsed data + parsed_data = { + 'account_id': account_id, + 'anomaly_id': anomaly_id, + 'anomaly_score': anomaly_score, + 'max_impact': max_impact, + 'total_impact': total_impact, + 'start_date': anomaly_start, + 'end_date': anomaly_end, + 'dimension_value': dimension_value, + 'root_causes': root_causes, + 'severity': get_severity(anomaly_score), + 'timestamp': datetime.utcnow().isoformat() } + logger.info(f"Parsed anomaly data: {json.dumps(parsed_data, indent=2)}") + + # Format alert message + alert_message = format_alert_message(parsed_data) + logger.info(f"Alert message:\n{alert_message}") + + + return parsed_data + +def get_severity(score): + """ + Determine severity based on anomaly score + """ + if score["currentScore"] >= 80: + return "CRITICAL" + elif score["currentScore"] >= 60: + return "HIGH" + elif score["currentScore"] >= 40: + return "MEDIUM" + else: + return "LOW" + +def format_alert_message(data): + """ + Format a human-readable alert message + """ + message = f""" +๐Ÿšจ AWS Cost Anomaly Detected + +Severity: {data['severity']} +Anomaly Score: {data['anomaly_score']} + +๐Ÿ’ฐ Financial Impact: +- Max Impact: ${data['max_impact']:.2f} +- Total Impact: ${data['total_impact']:.2f} + +๐Ÿ“… Time Period: +- Start: {data['start_date']} +- End: {data['end_date']} + +๐Ÿ” Details: +- Account ID: {data['account_id']} +- Anomaly ID: {data['anomaly_id']} +- Dimension: {data['dimension_value']} + +๐Ÿ“Š Root Causes: +""" + + for i, cause in enumerate(data['root_causes'], 1): + service = cause.get('service', 'Unknown') + region = cause.get('region', 'Unknown') + usage_type = cause.get('usageType', 'Unknown') + message += f"\n {i}. Service: {service}" + message += f"\n Region: {region}" + message += f"\n Usage Type: {usage_type}" + + return message def send_message_to_slack(webhook, message, message_id): """ diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index a2859437..deaab1d6 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -116,7 +116,8 @@ data "aws_iam_policy_document" "lambda_permissions" { actions = [ "sqs:ReceiveMessage", "sqs:DeleteMessage", - "sqs:GetQueueAttributes" + "sqs:GetQueueAttributes", + "ssm:GetParameter" ] resources = [module.sns_to_slack_queue.arn] From ce86bed5ed30ac5c0aba5a88fcbc9e31fda24610 Mon Sep 17 00:00:00 2001 From: Sean Fern Date: Mon, 24 Nov 2025 15:15:56 -0500 Subject: [PATCH 07/29] Trigger python-checks on all lambda_src directories --- .github/workflows/python-checks.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index b69623db..ca48acea 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: paths: - - 'terraform/services/alarm-to-slack/lambda_src/**' + - '**/lambda_src/**' jobs: python-checks: @@ -14,8 +14,7 @@ jobs: - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47 id: changed-dirs with: - files: | - terraform/services/alarm-to-slack/lambda_src/** + files: '**/lambda_src/**' dir_names: 'true' - run: scripts/python-checks env: From 424b6e09475e1c36e2dc4e37e804d715c8ebb8d2 Mon Sep 17 00:00:00 2001 From: Sean Fern Date: Mon, 24 Nov 2025 15:19:02 -0500 Subject: [PATCH 08/29] Add conf.sh for cost-anomaly --- terraform/services/cost-anomaly/conf.sh | 1 + 1 file changed, 1 insertion(+) create mode 100644 terraform/services/cost-anomaly/conf.sh diff --git a/terraform/services/cost-anomaly/conf.sh b/terraform/services/cost-anomaly/conf.sh new file mode 100644 index 00000000..f0249126 --- /dev/null +++ b/terraform/services/cost-anomaly/conf.sh @@ -0,0 +1 @@ +TARGET_ENVS=account From b859e62eb345475a73cec7da1f2398c5138afc9e Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Wed, 10 Dec 2025 14:52:48 -0700 Subject: [PATCH 09/29] update platform hash reference --- terraform/services/cost-anomaly/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index deaab1d6..4b374a3e 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -6,7 +6,7 @@ locals { } module "platform" { - source = "github.com/CMSgov/cdap//terraform/modules/platform?ref=plt-1358_sops" + source = "github.com/CMSgov/cdap//terraform/modules/platform?ref=8fd0c1c27b16358d1ea03186afee81f08d57862a" providers = { aws = aws, aws.secondary = aws.secondary } app = local.app From ff32001b04723d57d41b31fc19a3ed4934b3362e Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Wed, 10 Dec 2025 14:54:27 -0700 Subject: [PATCH 10/29] tf fmt --- terraform/services/cost-anomaly/main.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index 4b374a3e..def32e8b 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -51,7 +51,7 @@ resource "aws_ce_anomaly_subscription" "realtime_subscription" { dimension { key = "ANOMALY_TOTAL_IMPACT_PERCENTAGE" match_options = ["GREATER_THAN_OR_EQUAL"] - values = ["1"] # non-testing value is 5 + values = ["1"] # non-testing value is 5 } } } @@ -155,8 +155,8 @@ resource "aws_lambda_function" "cost_anomaly_alert" { environment { variables = { - ENVIRONMENT = var.env - IGNORE_OK = "false" + ENVIRONMENT = var.env + IGNORE_OK = "false" } } From dfb47326d94b3398b5f46e2e79487b12007cb56e Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Wed, 10 Dec 2025 15:11:32 -0700 Subject: [PATCH 11/29] add boto3 to python-checks --- .github/workflows/python-checks.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index ca48acea..9d8bcfe0 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -16,6 +16,8 @@ jobs: with: files: '**/lambda_src/**' dir_names: 'true' + - name: Install dependencies + run: pip install boto3 - run: scripts/python-checks env: CHANGED_DIRS: ${{ steps.changed-dirs.outputs.all_changed_files }} From a6de5c6e1fb6de428e76794389c0d146e4f13ebd Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Wed, 10 Dec 2025 15:13:55 -0700 Subject: [PATCH 12/29] revert add boto3 to python-checks needs its own pr --- .github/workflows/python-checks.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 9d8bcfe0..ca48acea 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -16,8 +16,6 @@ jobs: with: files: '**/lambda_src/**' dir_names: 'true' - - name: Install dependencies - run: pip install boto3 - run: scripts/python-checks env: CHANGED_DIRS: ${{ steps.changed-dirs.outputs.all_changed_files }} From 81c306ec9e21cd7cce32b4115dc9a217c5ffffe6 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Wed, 10 Dec 2025 15:23:40 -0700 Subject: [PATCH 13/29] Add requirements.txt for boto3. --- .github/workflows/python-checks.yml | 2 ++ terraform/services/cost-anomaly/lambda_src/requirements.txt | 1 + 2 files changed, 3 insertions(+) create mode 100644 terraform/services/cost-anomaly/lambda_src/requirements.txt diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index ca48acea..00e8cc0b 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -16,6 +16,8 @@ jobs: with: files: '**/lambda_src/**' dir_names: 'true' + - name: Install dependencies + run: pip install boto3 pylint - run: scripts/python-checks env: CHANGED_DIRS: ${{ steps.changed-dirs.outputs.all_changed_files }} diff --git a/terraform/services/cost-anomaly/lambda_src/requirements.txt b/terraform/services/cost-anomaly/lambda_src/requirements.txt new file mode 100644 index 00000000..0df32a53 --- /dev/null +++ b/terraform/services/cost-anomaly/lambda_src/requirements.txt @@ -0,0 +1 @@ +boto3==1.40.52 From 62fba0a4943450ed3581e20fb1df5c099de3b02c Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Wed, 10 Dec 2025 15:27:08 -0700 Subject: [PATCH 14/29] revert python-checks.yml --- .github/workflows/python-checks.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 00e8cc0b..ca48acea 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -16,8 +16,6 @@ jobs: with: files: '**/lambda_src/**' dir_names: 'true' - - name: Install dependencies - run: pip install boto3 pylint - run: scripts/python-checks env: CHANGED_DIRS: ${{ steps.changed-dirs.outputs.all_changed_files }} From b733d7b7435e56a42792269ba39bca4a97264c36 Mon Sep 17 00:00:00 2001 From: jscott-nava Date: Fri, 12 Dec 2025 08:18:13 -0800 Subject: [PATCH 15/29] [PLT-1390] Removing DPC sns topic key references. (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽซ Ticket https://jira.cms.gov/browse/PLT-1390 ## ๐Ÿ›  Changes This PR removes TF data references to the now deleted DPC SNS topic key, which was removed from the dpc-ops repo in a related PR. ## โ„น๏ธ Context As part of the alarm-to-slack service work the DPC CloudWatch alarm topic was updated to use the shared dpc-\ KMS key instead of the custom DPC SNS topic key, and that custom key was destroyed. This PR cleans up references to that custom key that continued to exist in the github-actions-role service. ## ๐Ÿงช Validation
Tofu plan output (DPC-DEV) ``` OpenTofu will perform the following actions: # aws_iam_role_policy.github_actions_role_policy will be updated in-place ~ resource "aws_iam_role_policy" "github_actions_role_policy" { id = "dpc-dev-github-actions:terraform-2025xxxxxxxxxxxxxxxxxxxxxx" name = "terraform-2025xxxxxxxxxxxxxxxxxxxxxx" ~ policy = jsonencode( ~ { ~ Statement = [ # (12 unchanged elements hidden) { Action = [ "kms:ListAliases", "kms:GetKeyRotationStatus", "kms:GetKeyPolicy", "kms:EnableKeyRotation", "kms:CreateKey", "kms:CreateAlias", ] Effect = "Allow" Resource = "*" Sid = "KmsUsage" }, ~ { ~ Resource = [ # (3 unchanged elements hidden) "arn:aws:kms:us-east-1:xxxxxxxxxxxx:alias/dpc-dev-web-admin-cloudwatch-key", - "arn:aws:kms:us-east-1:xxxxxxxxxxxx:alias/dpc-dev-sns-topic-key", "arn:aws:kms:us-east-1:xxxxxxxxxxxx:alias/dpc-dev-master-key", # (4 unchanged elements hidden) ] # (3 unchanged attributes hidden) }, { Action = [ "firehose:StartDeliveryStreamEncryption", "firehose:DescribeDeliveryStream", "firehose:CreateDeliveryStream", ] Effect = "Allow" Resource = "*" }, # (12 unchanged elements hidden) ] # (1 unchanged attribute hidden) } ) # (2 unchanged attributes hidden) } Plan: 0 to add, 1 to change, 0 to destroy. ```
--- terraform/services/github-actions-role/data.tf | 5 ----- terraform/services/github-actions-role/main.tf | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/terraform/services/github-actions-role/data.tf b/terraform/services/github-actions-role/data.tf index e5d5aeb7..0f6e9ab6 100644 --- a/terraform/services/github-actions-role/data.tf +++ b/terraform/services/github-actions-role/data.tf @@ -65,11 +65,6 @@ data "aws_kms_alias" "dpc_ecr" { name = "alias/dpc-ecr" } -data "aws_kms_alias" "dpc_sns_topic" { - count = var.app == "dpc" ? 1 : 0 - name = "alias/dpc-${var.env}-sns-topic-key" -} - data "aws_kms_alias" "dpc_cloudwatch_keys" { for_each = toset([for k in local.dpc_services : k if var.app == "dpc"]) name = "alias/dpc-${var.env}-${each.key}-cloudwatch-key" diff --git a/terraform/services/github-actions-role/main.tf b/terraform/services/github-actions-role/main.tf index 3a3f4609..94306d69 100644 --- a/terraform/services/github-actions-role/main.tf +++ b/terraform/services/github-actions-role/main.tf @@ -290,8 +290,7 @@ data "aws_iam_policy_document" "github_actions_policy" { var.app == "dpc" ? concat( [for key in data.aws_kms_alias.dpc_cloudwatch_keys : key.arn], data.aws_kms_alias.dpc_app_config[*].arn, - data.aws_kms_alias.dpc_ecr[*].arn, - data.aws_kms_alias.dpc_sns_topic[*].arn + data.aws_kms_alias.dpc_ecr[*].arn ) : [] ) } From 59be9b77002350c34d89f9492c4ac9352f942cec Mon Sep 17 00:00:00 2001 From: Grant Freeman <129095098+gfreeman-navapbc@users.noreply.github.com> Date: Fri, 12 Dec 2025 08:50:11 -0800 Subject: [PATCH 16/29] PLT-1482: Add Necessary KMS Keys to WAF Sync Lambda Permissions (#353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽซ Ticket https://jira.cms.gov/browse/PLT-1482 ## ๐Ÿ›  Changes Adds environment and app config keys to lambda permissions passed through to function module. ## โ„น๏ธ Context The DPC WAF sync lambdas have been very error heavy and the source is invalid key configuration in the terraform service. This change aims to add the necessary permissions to stop the lambdas from erroring out. ## ๐Ÿงช Validation
Tofu Plan Output (DPC/DEV) ``` OpenTofu will perform the following actions: # module.api_waf_sync_function.aws_iam_role_policy.default_function will be updated in-place ~ resource "aws_iam_role_policy" "default_function" { id = "dpc-dev-api-waf-sync-function:default-function" name = "default-function" ~ policy = jsonencode( ~ { ~ Statement = [ { Action = [ "ssm:GetParameters", "ssm:GetParameter", "sqs:ReceiveMessage", "sqs:GetQueueAttributes", "sqs:DeleteMessage", "logs:PutLogEvents", "logs:CreateLogStream", "logs:CreateLogGroup", "ec2:DescribeNetworkInterfaces", "ec2:DescribeAccountAttributes", "ec2:DeleteNetworkInterface", "ec2:CreateNetworkInterface", ] Effect = "Allow" Resource = "*" }, ~ { ~ Resource = [ + "arn:aws:kms:us-east-1::key/69fc1eca-71e6-43e6-acd1-53f0b80a7ef6", "arn:aws:kms:us-east-1::key/601028a8-2ef7-4bec-9e39-af26d91e07b9", # (1 unchanged element hidden) ] # (2 unchanged attributes hidden) }, ] # (1 unchanged attribute hidden) } ) # (1 unchanged attribute hidden) } Plan: 0 to add, 1 to change, 0 to destroy. ```
--- terraform/services/api-waf-sync/main.tf | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/terraform/services/api-waf-sync/main.tf b/terraform/services/api-waf-sync/main.tf index 4517b91a..e0109b6c 100644 --- a/terraform/services/api-waf-sync/main.tf +++ b/terraform/services/api-waf-sync/main.tf @@ -7,6 +7,15 @@ data "aws_kms_alias" "bcda_app_config_kms_key" { name = "alias/bcda-${var.env}-app-config-kms" } +data "aws_kms_alias" "dpc_app_config" { + count = var.app == "dpc" ? 1 : 0 + name = "alias/dpc-${var.env}-master-key" +} + +data "aws_kms_alias" "environment_key" { + name = "alias/${var.app}-${var.env}" +} + module "api_waf_sync_function" { source = "../../modules/function" @@ -31,7 +40,13 @@ module "api_waf_sync_function" { DB_HOST = data.aws_ssm_parameter.dpc_db_host.value } - extra_kms_key_arns = [data.aws_kms_alias.bcda_app_config_kms_key.target_key_arn] + extra_kms_key_arns = concat( + [ + data.aws_kms_alias.environment_key.target_key_arn, + data.aws_kms_alias.bcda_app_config_kms_key.target_key_arn + ], + var.app == "dpc" ? data.aws_kms_alias.dpc_app_config[*].target_key_arn : [] + ) } # Add a rule to the database security group to allow access from the function From 6c3751fa7e937ea87e79e3cf29d46b63965cfd5d Mon Sep 17 00:00:00 2001 From: Sadibhatla <121067659+Sadibhatla@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:27:36 -0800 Subject: [PATCH 17/29] Added contract name to ab2d_prod_benes_searched (#357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐Ÿ›  Changes Added contract name ## โ„น๏ธ Context Doug requested to add contract name for the existing dataset : https://cmsgov.slack.com/archives/CNHDC8HCZ/p1765474837300259 ## ๐Ÿงช Validation Ran in the quicksights and it displays contract names and no duplicates: image --- .../views/ab2d/prod-benes-searched.view.sql | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/terraform/services/insights/views/ab2d/prod-benes-searched.view.sql b/terraform/services/insights/views/ab2d/prod-benes-searched.view.sql index ac3437ed..8e2582ba 100644 --- a/terraform/services/insights/views/ab2d/prod-benes-searched.view.sql +++ b/terraform/services/insights/views/ab2d/prod-benes-searched.view.sql @@ -1,9 +1,10 @@ CREATE VIEW ab2d_prod_benes_searched AS - SELECT + SELECT contract_number, + contract_name, job_uuid, benes_searched, - TO_CHAR(created_at, 'yyyy-MM-ddThh:mm:ss') created_at, + TO_CHAR(created_at, 'yyyy-MM-ddThh:mm:ss') created_at, TO_CHAR(completed_at, 'yyyy-MM-ddThh:mm:ss') completed_at, eobs_written, time_to_complete, @@ -11,8 +12,8 @@ CREATE VIEW ab2d_prod_benes_searched AS since, fhir_version, status, - contract_number AS "Contract Number", - job_uuid AS "Job ID", + contract_number AS "Contract Number", + job_uuid AS "Job ID", benes_searched AS "# Bene Searched", completed_at AS "Completed At", eobs_written AS "# EoBs Written", @@ -25,13 +26,14 @@ CREATE VIEW ab2d_prod_benes_searched AS TO_CHAR(created_at, 'yyyy-MM-ddThh:mm:ss') job_start_time, TO_CHAR(completed_at, 'yyyy-MM-ddThh:mm:ss') job_complete_time FROM ( - SELECT - s.contract_number, - j.job_uuid, - s.benes_searched, - j.created_at, - j.completed_at, - s.eobs_written, + SELECT + s.contract_number, + c.contract_name, + j.job_uuid, + s.benes_searched, + j.created_at, + j.completed_at, + s.eobs_written, j.completed_at - j.created_at as time_to_complete, CASE WHEN j.since is null @@ -48,6 +50,6 @@ CREATE VIEW ab2d_prod_benes_searched AS j.status FROM job j LEFT JOIN event.event_bene_search s ON s.job_id = j.job_uuid - LEFT JOIN contract_view c ON c.contract_number = j.contract_number + LEFT JOIN contract_view c ON c.contract_number = j.contract_number and c.contract_name is not null WHERE j.started_by='PDP') t ORDER BY "Job Start Time" DESC; From 0f580cec24e84e669b0091df1a6ea11894fe7319 Mon Sep 17 00:00:00 2001 From: Michael Valdes Date: Tue, 16 Dec 2025 15:07:19 -0500 Subject: [PATCH 18/29] BCDA-9633: add health_check var to ecs_service module (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽซ Ticket https://jira.cms.gov/browse/BCDA-9633 Related tickets: [bcda-ops](https://github.com/CMSgov/bcda-ops/pull/1303) [bcda-app](https://github.com/CMSgov/bcda-app/pull/1276) ## ๐Ÿ›  Changes - added an optional health check variable for the ecs service module ## โ„น๏ธ Context The BCDA worker service runs containers that don't have a target group or a load balancer. It would be helpful to have a container health check that can monitor unhealthy containers. ## ๐Ÿงช Validation Tested with bcda ecs services in dev environment. --- terraform/modules/service/main.tf | 2 +- terraform/modules/service/variables.tf | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/terraform/modules/service/main.tf b/terraform/modules/service/main.tf index 96d5fe47..210904b0 100644 --- a/terraform/modules/service/main.tf +++ b/terraform/modules/service/main.tf @@ -29,7 +29,7 @@ resource "aws_ecs_task_definition" "this" { awslogs-stream-prefix = "${var.platform.app}-${var.platform.env}" } } - healthCheck = null + healthCheck = var.health_check } ])) diff --git a/terraform/modules/service/variables.tf b/terraform/modules/service/variables.tf index dc10512e..a2831464 100644 --- a/terraform/modules/service/variables.tf +++ b/terraform/modules/service/variables.tf @@ -100,6 +100,18 @@ variable "port_mappings" { default = null } +variable "health_check" { + description = "Health check that monitors the service." + type = object({ + command = list(string), + interval = optional(number), + retries = optional(number), + startPeriod = optional(number), + timeout = optional(number) + }) + default = null +} + variable "security_groups" { description = "List of security groups to associate with the service." type = list(string) From c4439ff45ee1df0975a1dc39decf162001d91e4d Mon Sep 17 00:00:00 2001 From: Julia Reynolds Date: Thu, 18 Dec 2025 09:29:58 -0700 Subject: [PATCH 19/29] [PLT-1425] Add the key used to decrypt cdap sops files to correct failed workflow (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽซ Ticket https://jira.cms.gov/browse/PLT-1425 ## ๐Ÿ›  Changes Granted codebuild-runner access to decrypt the bcda-prod or bcda-test keys as they are used to encrypt cdap sops values. ## โ„น๏ธ Context Added the kms key to correct this permissions error: `Error: User: arn:aws:sts::{$account}:assumed-role/codebuild-runner/AWSCodeBuild-axxxxxxx-7272-421e-8d25-d4f58468c162 is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:us-east-1:{$account}:key/37584589-3eb7-437a-9f20-b00000000b3 because no identity-based policy allows the kms:Decrypt action (Service: AWSKMS; Status Code: 400; Error Code: AccessDeniedException; roxy: null)` ## ๐Ÿงช Validation https://github.com/CMSgov/cdap/actions/runs/20241886420/job/58111901612?pr=352 --------- Co-authored-by: Sean Fern --- terraform/services/github-actions-role/data.tf | 7 +++++++ terraform/services/github-actions-role/main.tf | 1 + 2 files changed, 8 insertions(+) diff --git a/terraform/services/github-actions-role/data.tf b/terraform/services/github-actions-role/data.tf index 0f6e9ab6..38d927fa 100644 --- a/terraform/services/github-actions-role/data.tf +++ b/terraform/services/github-actions-role/data.tf @@ -23,6 +23,9 @@ locals { "web-admin", ] : [], ) + + # TODO Replace with cdap-test and cdap-prod when those environments are set up + account_env = contains(["dev", "test"], var.env) ? "bcda-test" : "bcda-prod" } # KMS keys needed for IAM policy @@ -30,6 +33,10 @@ data "aws_kms_alias" "environment_key" { name = "alias/${var.app}-${var.env}" } +data "aws_kms_alias" "account_env" { + name = "alias/${local.account_env}" +} + data "aws_kms_alias" "ab2d_tfstate_bucket" { count = var.env == "ab2d" ? 1 : 0 name = "alias/ab2d-${var.env}-tfstate-bucket" diff --git a/terraform/services/github-actions-role/main.tf b/terraform/services/github-actions-role/main.tf index 94306d69..2e0a73c9 100644 --- a/terraform/services/github-actions-role/main.tf +++ b/terraform/services/github-actions-role/main.tf @@ -278,6 +278,7 @@ data "aws_iam_policy_document" "github_actions_policy" { ] resources = concat( [data.aws_kms_alias.environment_key.arn], + [data.aws_kms_alias.account_env.arn], var.app == "ab2d" ? concat( data.aws_kms_alias.ab2d_ecr[*].arn, data.aws_kms_alias.ab2d_tfstate_bucket[*].arn, From 3485294a47d3a46d8f860fe265216d05f73e0d96 Mon Sep 17 00:00:00 2001 From: mianava Date: Mon, 22 Dec 2025 14:38:09 -0500 Subject: [PATCH 20/29] [PLT-1418] Update web module to support DPC, leverage STS headers and cloudfront logging; (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽซ Ticket jira.cms.gov/browse/PLT-1418 ## ๐Ÿ›  Changes This PR configures: 1) The 'web' module to configure an existing cloudfront deployment that supports STS headers, has a regional domain name ("domain_name_overwrite"). 2) The platform module to accommodate bucket logging in regional paths for Cloudfront logs passed into S3. This enables the passing of logs, by administrative AWS configuration, to an external provider. 3) Simplifies some variables into strings instead of objects. ## โ„น๏ธ Context These changes were made to support oversight and standardization of static site management through the centralization of terraform. ## ๐Ÿงช Validation These changes require validation in the sandbox environment. The module source will be updated to point to this github commit hash as ref. Once the sandbox site is determined mostly unchanged, the module ref can be updated for the production site. --- terraform/modules/platform/main.tf | 4 ++ terraform/modules/platform/outputs.tf | 6 +-- terraform/modules/web/README.md | 6 ++- terraform/modules/web/logs.tf | 23 ++++++++++ terraform/modules/web/main.tf | 62 +++++++++++++-------------- terraform/modules/web/origin.tf | 58 +++++++++++++++++++++++++ terraform/modules/web/variables.tf | 42 +++++++++--------- 7 files changed, 144 insertions(+), 57 deletions(-) create mode 100644 terraform/modules/web/logs.tf create mode 100644 terraform/modules/web/origin.tf diff --git a/terraform/modules/platform/main.tf b/terraform/modules/platform/main.tf index e5a04e06..e0978f89 100644 --- a/terraform/modules/platform/main.tf +++ b/terraform/modules/platform/main.tf @@ -114,6 +114,10 @@ data "aws_s3_bucket" "access_logs" { bucket = local.access_logs_bucket[local.parent_env] } +data "aws_s3_bucket" "logs_to_splunk" { + bucket = "cms-cloud-${data.aws_caller_identity.this.account_id}-${data.aws_region.primary.name}" +} + data "aws_security_groups" "this" { filter { name = "vpc-id" diff --git a/terraform/modules/platform/outputs.tf b/terraform/modules/platform/outputs.tf index 246e7621..031fba48 100644 --- a/terraform/modules/platform/outputs.tf +++ b/terraform/modules/platform/outputs.tf @@ -144,7 +144,7 @@ output "ssm" { value = { for named_root, data in data.aws_ssm_parameters_by_path.ssm : named_root => { for each in [for arn, value in zipmap(data.arns, data.values) : { "value" = value, "arn" = arn }] : reverse(split("/", each.arn))[0] => each } } } -output "network_access_logs_bucket" { - description = "Standardized CMS Hybrid Cloud Providued Network Access Logs bucket Name" - value = "cms-cloud-${data.aws_caller_identity.this.account_id}-${data.aws_region.primary.name}" +output "splunk_logging_bucket" { + description = "Bucket created by the CMS Hybrid Cloud team where logs are ingested into Splunk" + value = data.aws_s3_bucket.logs_to_splunk } diff --git a/terraform/modules/web/README.md b/terraform/modules/web/README.md index 00d868a7..166820bc 100644 --- a/terraform/modules/web/README.md +++ b/terraform/modules/web/README.md @@ -1,6 +1,9 @@ # CDAP Web Module -This module creates a CloudFront distribution and origin access control intended for use with the AB2D, BCDA and DPC static websites. A sample usage is as follows: +This module creates a CloudFront distribution and origin access control intended for use with the AB2D, BCDA and DPC static websites. +This module assumes an S3 bucket as the origin has already been created. This module currently assumes a single domain with an already issued certificate. + +A sample usage is as follows : ``` module "platform" { @@ -56,7 +59,6 @@ module "web" { source = "../modules/web" certificate = aws_acm_certificate.cert - logging_bucket = module.logging_bucket origin_bucket = module.origin_bucket platform = module.platform web_acl = module.web_acl diff --git a/terraform/modules/web/logs.tf b/terraform/modules/web/logs.tf new file mode 100644 index 00000000..b4b9aa9e --- /dev/null +++ b/terraform/modules/web/logs.tf @@ -0,0 +1,23 @@ +resource "aws_cloudwatch_log_delivery_source" "this" { + name = "${var.platform.app}-${var.platform.env}" + log_type = "ACCESS_LOGS" + resource_arn = aws_cloudfront_distribution.this.arn +} + +resource "aws_cloudwatch_log_delivery_destination" "this" { + name = "${var.platform.app}-${var.platform.env}" + output_format = "parquet" + + delivery_destination_configuration { + destination_resource_arn = var.platform.splunk_logging_bucket.arn + } +} + +resource "aws_cloudwatch_log_delivery" "this" { + delivery_source_name = aws_cloudwatch_log_delivery_source.this.name + delivery_destination_arn = aws_cloudwatch_log_delivery_destination.this.arn + + s3_delivery_configuration { + suffix_path = "/AWSLogs/${data.aws_caller_identity.this.account_id}/Cloudfront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}" + } +} \ No newline at end of file diff --git a/terraform/modules/web/main.tf b/terraform/modules/web/main.tf index 1049e647..1f686b84 100644 --- a/terraform/modules/web/main.tf +++ b/terraform/modules/web/main.tf @@ -1,26 +1,44 @@ +data "aws_caller_identity" "this" {} + +data "aws_acm_certificate" "issued" { + domain = var.domain_name + statuses = ["ISSUED"] +} + resource "aws_cloudfront_function" "redirects" { - name = "redesign-redirects" + name = "${var.domain_name}-redirects" runtime = "cloudfront-js-2.0" comment = "Function that handles cool URIs and redirects." code = templatefile("${path.module}/redirects-function.tftpl", { redirects = var.redirects }) } resource "aws_cloudfront_origin_access_control" "this" { - name = var.origin_bucket.bucket_regional_domain_name + name = "${var.domain_name}-s3-origin" description = "Manages an AWS CloudFront Origin Access Control, which is used by CloudFront Distributions with an Amazon S3 bucket as the origin." origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" } +resource "aws_cloudfront_response_headers_policy" "this" { + name = "${var.platform.app}-${var.platform.env}-StsHeaderPolicy" + + security_headers_config { + strict_transport_security { + access_control_max_age_sec = 31536000 + override = false + include_subdomains = true + } + } +} + resource "aws_cloudfront_distribution" "this" { - aliases = var.certificate == null ? [] : [var.certificate.domain_name] + aliases = [var.domain_name] comment = "Distribution for the ${var.platform.app}-${var.platform.env} website" default_root_object = "index.html" enabled = var.enabled http_version = "http2and3" is_ipv6_enabled = true - price_class = "PriceClass_100" web_acl_id = var.web_acl.arn custom_error_response { @@ -41,7 +59,7 @@ resource "aws_cloudfront_distribution" "this" { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] compress = true - target_origin_id = "s3_origin" + target_origin_id = var.s3_origin_id viewer_protocol_policy = "redirect-to-https" cache_policy_id = ( @@ -50,6 +68,8 @@ resource "aws_cloudfront_distribution" "this" { "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled managed policy ) + response_headers_policy_id = aws_cloudfront_response_headers_policy.this.id + function_association { event_type = "viewer_request" function_arn = aws_cloudfront_function.redirects.arn @@ -59,7 +79,7 @@ resource "aws_cloudfront_distribution" "this" { origin { domain_name = var.origin_bucket.bucket_regional_domain_name origin_access_control_id = aws_cloudfront_origin_access_control.this.id - origin_id = "s3_origin" + origin_id = var.s3_origin_id } restrictions { @@ -70,29 +90,9 @@ resource "aws_cloudfront_distribution" "this" { } viewer_certificate { - cloudfront_default_certificate = var.certificate == null ? true : false - acm_certificate_arn = var.certificate == null ? null : var.certificate.arn - minimum_protocol_version = var.certificate == null ? null : "TLSv1.2_2021" - ssl_support_method = var.certificate == null ? null : "sni-only" + cloudfront_default_certificate = false + acm_certificate_arn = data.aws_acm_certificate.issued.arn + minimum_protocol_version = "TLSv1.2_2021" + ssl_support_method = "sni-only" } -} - -resource "aws_cloudwatch_log_delivery_source" "this" { - name = "${var.platform.app}-${var.platform.env}" - log_type = "ACCESS_LOGS" - resource_arn = aws_cloudfront_distribution.this.arn -} - -resource "aws_cloudwatch_log_delivery_destination" "this" { - name = "${var.platform.app}-${var.platform.env}" - output_format = "parquet" - - delivery_destination_configuration { - destination_resource_arn = "${var.logging_bucket.arn}/${var.origin_bucket.bucket_regional_domain_name}" - } -} - -resource "aws_cloudwatch_log_delivery" "this" { - delivery_source_name = aws_cloudwatch_log_delivery_source.this.name - delivery_destination_arn = aws_cloudwatch_log_delivery_destination.this.arn -} +} \ No newline at end of file diff --git a/terraform/modules/web/origin.tf b/terraform/modules/web/origin.tf new file mode 100644 index 00000000..397f1358 --- /dev/null +++ b/terraform/modules/web/origin.tf @@ -0,0 +1,58 @@ +/* To reduce module nesting and adhere to current configurations, S3 buckets are managed outside of this module. +Permissions for Cloudfront, however, are managed here. */ + +resource "aws_s3_bucket_policy" "allow_cloudfront_access" { + bucket = var.origin_bucket.id + policy = data.aws_iam_policy_document.allow_cloudfront_access.json +} + +# S3 static site host bucket policy document +data "aws_iam_policy_document" "allow_cloudfront_access" { + # There are no dev or test environments for the static site + + statement { + sid = "AllowCloudfrontAccess" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = [ + "s3:GetObject", + "s3:ListBucket" + ] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [ + aws_cloudfront_distribution.this.arn + ] + } + + resources = [ + var.origin_bucket.arn + ] + } + statement { + sid = "AllowSSLRequestsOnly" + effect = "Deny" + principals { + type = "AWS" + identifiers = ["*"] + } + actions = ["s3:*"] + resources = [ + var.origin_bucket.arn, + "${var.origin_bucket.arn}/*", + ] + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + diff --git a/terraform/modules/web/variables.tf b/terraform/modules/web/variables.tf index 7a75c23b..40203f27 100644 --- a/terraform/modules/web/variables.tf +++ b/terraform/modules/web/variables.tf @@ -1,29 +1,14 @@ -variable "certificate" { - default = null - description = "Object representing the website certificate." - type = object({ - arn = string - domain_name = string - }) -} - -variable "enabled" { - default = true - description = "Whether the distribution is enabled to accept end user requests for content." - type = bool -} - -variable "logging_bucket" { - description = "Object representing the logging S3 bucket." - type = object({ - arn = string - }) +variable "domain_name" { + description = "An externally managed domain that points to this distribution. A matching ACM certificate must already be issued." + type = string } variable "origin_bucket" { description = "Object representing the origin S3 bucket." type = object({ bucket_regional_domain_name = string, + arn = string, + id = string }) } @@ -31,7 +16,10 @@ variable "platform" { description = "Object representing the CDAP plaform module." type = object({ app = string, - env = string + env = string, + splunk_logging_bucket = object({ + arn = string + }) }) } @@ -46,3 +34,15 @@ variable "web_acl" { arn = string }) } + +variable "enabled" { + default = true + description = "Whether the distribution is enabled to accept end user requests for content." + type = bool +} + +variable "s3_origin_id" { + default = "s3_origin" + description = "Variable to manage existing s3 origins without recreation. All new instances of this module can leave the default." + type = string +} From d26cf494981d1f12d90e2b54fdb0e8b2b5bbd3ab Mon Sep 17 00:00:00 2001 From: Grant Freeman <129095098+gfreeman-navapbc@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:22:43 -0800 Subject: [PATCH 21/29] PLT-1456: Bootstrap cdap-test and cdap-prod environments (#362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽซ Ticket https://jira.cms.gov/browse/PLT-1456 ## ๐Ÿ›  Changes - Adds backends for cdap-prod and cdap-test. - Adds standards module to github_actions_role service. _**(Will update all roles in-place.)**_ - Adds permissions for KMS key management and s3 usage. - Adds cdap-test and cdap-prod to plan and apply workflows. ## โ„น๏ธ Context We want to move away from the current configuration of all CDAP resources living in the singular cdap-mgmt VPC which exists in the prod account, or overloading `bcda-prod` and `bcda-test` in each account. This way we can test changes and not have to bother with peering requests and ingress rules from the management VPC in the lower environments. Also, resources we manage will more clearly be owned by CDAP. ## ๐Ÿงช Validation See plans --- .github/workflows/tofu-apply.yml | 4 ++ .github/workflows/tofu-plan.yml | 4 ++ terraform/backends/cdap-prod.s3.tfbackend | 2 + terraform/backends/cdap-test.s3.tfbackend | 2 + .../services/github-actions-role/data.tf | 14 ++++++- .../services/github-actions-role/main.tf | 40 +++++++++++++------ .../services/github-actions-role/terraform.tf | 15 ++++--- 7 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 terraform/backends/cdap-prod.s3.tfbackend create mode 100644 terraform/backends/cdap-test.s3.tfbackend diff --git a/.github/workflows/tofu-apply.yml b/.github/workflows/tofu-apply.yml index 3b1dbdba..c6d1b8c4 100644 --- a/.github/workflows/tofu-apply.yml +++ b/.github/workflows/tofu-apply.yml @@ -30,6 +30,10 @@ jobs: include: - app: cdap env: mgmt + - app: cdap + env: prod + - app: cdap + env: test steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 diff --git a/.github/workflows/tofu-plan.yml b/.github/workflows/tofu-plan.yml index 7431342d..3f779f85 100644 --- a/.github/workflows/tofu-plan.yml +++ b/.github/workflows/tofu-plan.yml @@ -26,6 +26,10 @@ jobs: include: - app: cdap env: mgmt + - app: cdap + env: prod + - app: cdap + env: test steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 diff --git a/terraform/backends/cdap-prod.s3.tfbackend b/terraform/backends/cdap-prod.s3.tfbackend new file mode 100644 index 00000000..56f0cc18 --- /dev/null +++ b/terraform/backends/cdap-prod.s3.tfbackend @@ -0,0 +1,2 @@ +bucket = "cdap-prod-tfstate-20251230175647601000000001" +use_lockfile = true diff --git a/terraform/backends/cdap-test.s3.tfbackend b/terraform/backends/cdap-test.s3.tfbackend new file mode 100644 index 00000000..d6a0917a --- /dev/null +++ b/terraform/backends/cdap-test.s3.tfbackend @@ -0,0 +1,2 @@ +bucket = "cdap-test-tfstate-20251230174431053500000001" +use_lockfile = true diff --git a/terraform/services/github-actions-role/data.tf b/terraform/services/github-actions-role/data.tf index 38d927fa..22e8f02d 100644 --- a/terraform/services/github-actions-role/data.tf +++ b/terraform/services/github-actions-role/data.tf @@ -24,8 +24,9 @@ locals { ] : [], ) - # TODO Replace with cdap-test and cdap-prod when those environments are set up - account_env = contains(["dev", "test"], var.env) ? "bcda-test" : "bcda-prod" + # TODO Drop account_env_old when we are fully migrated to cdap-test and cdap-prod + account_env_old = contains(["dev", "test"], var.env) ? "bcda-test" : "bcda-prod" + account_env = contains(["dev", "test"], var.env) ? "cdap-test" : "cdap-prod" } # KMS keys needed for IAM policy @@ -33,10 +34,19 @@ data "aws_kms_alias" "environment_key" { name = "alias/${var.app}-${var.env}" } +data "aws_kms_alias" "account_env_old" { + name = "alias/${local.account_env_old}" +} + data "aws_kms_alias" "account_env" { name = "alias/${local.account_env}" } +data "aws_kms_alias" "account_env_secondary" { + provider = aws.secondary + name = "alias/${local.account_env}" +} + data "aws_kms_alias" "ab2d_tfstate_bucket" { count = var.env == "ab2d" ? 1 : 0 name = "alias/ab2d-${var.env}-tfstate-bucket" diff --git a/terraform/services/github-actions-role/main.tf b/terraform/services/github-actions-role/main.tf index 2e0a73c9..8449b6c1 100644 --- a/terraform/services/github-actions-role/main.tf +++ b/terraform/services/github-actions-role/main.tf @@ -1,3 +1,11 @@ +module "standards" { + source = "github.com/CMSgov/cdap//terraform/modules/standards?ref=0bd3eeae6b03cc8883b7dbdee5f04deb33468260" + app = var.app + env = var.env + root_module = "https://github.com/CMSgov/cdap/tree/main/terraform/services/github-actions-role" + service = "github-actions-role" +} + locals { provider_domain = "token.actions.githubusercontent.com" repos = { @@ -274,24 +282,27 @@ data "aws_iam_policy_document" "github_actions_policy" { "kms:GenerateDataKey", "kms:GenerateDataKeyWithoutPlaintext", "kms:DescribeKey", - "kms:CreateGrant" + "kms:CreateGrant", + "kms:ListResourceTags" ] resources = concat( - [data.aws_kms_alias.environment_key.arn], - [data.aws_kms_alias.account_env.arn], + [data.aws_kms_alias.environment_key.target_key_arn], + [data.aws_kms_alias.account_env_old.target_key_arn], + [data.aws_kms_alias.account_env.target_key_arn], + [data.aws_kms_alias.account_env_secondary.target_key_arn], var.app == "ab2d" ? concat( - data.aws_kms_alias.ab2d_ecr[*].arn, - data.aws_kms_alias.ab2d_tfstate_bucket[*].arn, + data.aws_kms_alias.ab2d_ecr[*].target_key_arn, + data.aws_kms_alias.ab2d_tfstate_bucket[*].target_key_arn, ) : [], var.app == "bcda" ? concat( - data.aws_kms_alias.bcda_aco_creds[*].arn, - data.aws_kms_alias.bcda_app_config[*].arn, - data.aws_kms_alias.bcda_insights_data_sampler[*].arn, + data.aws_kms_alias.bcda_aco_creds[*].target_key_arn, + data.aws_kms_alias.bcda_app_config[*].target_key_arn, + data.aws_kms_alias.bcda_insights_data_sampler[*].target_key_arn, ) : [], var.app == "dpc" ? concat( - [for key in data.aws_kms_alias.dpc_cloudwatch_keys : key.arn], - data.aws_kms_alias.dpc_app_config[*].arn, - data.aws_kms_alias.dpc_ecr[*].arn + [for key in data.aws_kms_alias.dpc_cloudwatch_keys : key.target_key_arn], + data.aws_kms_alias.dpc_app_config[*].target_key_arn, + data.aws_kms_alias.dpc_ecr[*].target_key_arn ) : [] ) } @@ -409,6 +420,7 @@ data "aws_iam_policy_document" "github_actions_policy" { "s3:GetBucketOwnershipControls", "s3:GetBucketPolicy", "s3:GetBucketRequestPayment", + "s3:GetBucketTagging", "s3:GetBucketVersioning", "s3:GetBucketWebsite", "s3:GetEncryptionConfiguration", @@ -420,7 +432,11 @@ data "aws_iam_policy_document" "github_actions_policy" { "s3:PutBucketPolicy", "s3:PutBucketVersioning", "s3:PutEncryptionConfiguration", - "s3:PutLifecycleConfiguration" + "s3:PutLifecycleConfiguration", + "s3:ListBucket", + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" ] resources = ["*"] } diff --git a/terraform/services/github-actions-role/terraform.tf b/terraform/services/github-actions-role/terraform.tf index 5009a1bd..cd31a55e 100644 --- a/terraform/services/github-actions-role/terraform.tf +++ b/terraform/services/github-actions-role/terraform.tf @@ -1,11 +1,14 @@ provider "aws" { default_tags { - tags = { - business = "oeda" - code = "https://github.com/CMSgov/cdap/tree/main/terraform/services/github-actions-role" - component = "github-actions" - terraform = true - } + tags = module.standards.default_tags + } +} + +provider "aws" { + alias = "secondary" + region = "us-west-2" + default_tags { + tags = module.standards.default_tags } } From 4fd15acc8aaf006bff6eda14009f41e5bc1ffd62 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Tue, 6 Jan 2026 09:19:38 -0700 Subject: [PATCH 22/29] Additional permissions to lambda role policy --- terraform/services/cost-anomaly/main.tf | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index def32e8b..e1bd1bc0 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -117,7 +117,13 @@ data "aws_iam_policy_document" "lambda_permissions" { "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes", - "ssm:GetParameter" + "ssm:GetParameter", + "kms:GenerateDataKey", + "kms:Encrypt" , + "kms:Decrypt", + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" ] resources = [module.sns_to_slack_queue.arn] From b1035a11bb4b89d77aacf09cc83ad447cbeb3d86 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Tue, 6 Jan 2026 09:22:21 -0700 Subject: [PATCH 23/29] Revert "Additional permissions to lambda role policy" This reverts commit 4fd15acc8aaf006bff6eda14009f41e5bc1ffd62. --- terraform/services/cost-anomaly/main.tf | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index e1bd1bc0..def32e8b 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -117,13 +117,7 @@ data "aws_iam_policy_document" "lambda_permissions" { "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes", - "ssm:GetParameter", - "kms:GenerateDataKey", - "kms:Encrypt" , - "kms:Decrypt", - "logs:PutLogEvents", - "logs:CreateLogStream", - "logs:CreateLogGroup" + "ssm:GetParameter" ] resources = [module.sns_to_slack_queue.arn] From 4f00df9dc5db1cf6573ac5f6784e1dedc4b1b10a Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Tue, 6 Jan 2026 09:45:36 -0700 Subject: [PATCH 24/29] update branch --- .github/workflows/python-checks.yml | 5 +++-- .github/workflows/tofu-apply.yml | 4 ---- .github/workflows/tofu-plan.yml | 4 ---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index ca48acea..b69623db 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: paths: - - '**/lambda_src/**' + - 'terraform/services/alarm-to-slack/lambda_src/**' jobs: python-checks: @@ -14,7 +14,8 @@ jobs: - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47 id: changed-dirs with: - files: '**/lambda_src/**' + files: | + terraform/services/alarm-to-slack/lambda_src/** dir_names: 'true' - run: scripts/python-checks env: diff --git a/.github/workflows/tofu-apply.yml b/.github/workflows/tofu-apply.yml index c6d1b8c4..3b1dbdba 100644 --- a/.github/workflows/tofu-apply.yml +++ b/.github/workflows/tofu-apply.yml @@ -30,10 +30,6 @@ jobs: include: - app: cdap env: mgmt - - app: cdap - env: prod - - app: cdap - env: test steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 diff --git a/.github/workflows/tofu-plan.yml b/.github/workflows/tofu-plan.yml index 3f779f85..7431342d 100644 --- a/.github/workflows/tofu-plan.yml +++ b/.github/workflows/tofu-plan.yml @@ -26,10 +26,6 @@ jobs: include: - app: cdap env: mgmt - - app: cdap - env: prod - - app: cdap - env: test steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 From e48debf2f34b16d83c24c07fe1041933e417a9fb Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Tue, 6 Jan 2026 09:52:31 -0700 Subject: [PATCH 25/29] update branch --- terraform/modules/platform/main.tf | 4 -- terraform/modules/platform/outputs.tf | 6 +-- terraform/modules/web/README.md | 6 +-- terraform/modules/web/logs.tf | 23 ---------- terraform/modules/web/main.tf | 62 +++++++++++++-------------- terraform/modules/web/origin.tf | 58 ------------------------- terraform/modules/web/variables.tf | 42 +++++++++--------- 7 files changed, 57 insertions(+), 144 deletions(-) delete mode 100644 terraform/modules/web/logs.tf delete mode 100644 terraform/modules/web/origin.tf diff --git a/terraform/modules/platform/main.tf b/terraform/modules/platform/main.tf index e0978f89..e5a04e06 100644 --- a/terraform/modules/platform/main.tf +++ b/terraform/modules/platform/main.tf @@ -114,10 +114,6 @@ data "aws_s3_bucket" "access_logs" { bucket = local.access_logs_bucket[local.parent_env] } -data "aws_s3_bucket" "logs_to_splunk" { - bucket = "cms-cloud-${data.aws_caller_identity.this.account_id}-${data.aws_region.primary.name}" -} - data "aws_security_groups" "this" { filter { name = "vpc-id" diff --git a/terraform/modules/platform/outputs.tf b/terraform/modules/platform/outputs.tf index 031fba48..246e7621 100644 --- a/terraform/modules/platform/outputs.tf +++ b/terraform/modules/platform/outputs.tf @@ -144,7 +144,7 @@ output "ssm" { value = { for named_root, data in data.aws_ssm_parameters_by_path.ssm : named_root => { for each in [for arn, value in zipmap(data.arns, data.values) : { "value" = value, "arn" = arn }] : reverse(split("/", each.arn))[0] => each } } } -output "splunk_logging_bucket" { - description = "Bucket created by the CMS Hybrid Cloud team where logs are ingested into Splunk" - value = data.aws_s3_bucket.logs_to_splunk +output "network_access_logs_bucket" { + description = "Standardized CMS Hybrid Cloud Providued Network Access Logs bucket Name" + value = "cms-cloud-${data.aws_caller_identity.this.account_id}-${data.aws_region.primary.name}" } diff --git a/terraform/modules/web/README.md b/terraform/modules/web/README.md index 166820bc..00d868a7 100644 --- a/terraform/modules/web/README.md +++ b/terraform/modules/web/README.md @@ -1,9 +1,6 @@ # CDAP Web Module -This module creates a CloudFront distribution and origin access control intended for use with the AB2D, BCDA and DPC static websites. -This module assumes an S3 bucket as the origin has already been created. This module currently assumes a single domain with an already issued certificate. - -A sample usage is as follows : +This module creates a CloudFront distribution and origin access control intended for use with the AB2D, BCDA and DPC static websites. A sample usage is as follows: ``` module "platform" { @@ -59,6 +56,7 @@ module "web" { source = "../modules/web" certificate = aws_acm_certificate.cert + logging_bucket = module.logging_bucket origin_bucket = module.origin_bucket platform = module.platform web_acl = module.web_acl diff --git a/terraform/modules/web/logs.tf b/terraform/modules/web/logs.tf deleted file mode 100644 index b4b9aa9e..00000000 --- a/terraform/modules/web/logs.tf +++ /dev/null @@ -1,23 +0,0 @@ -resource "aws_cloudwatch_log_delivery_source" "this" { - name = "${var.platform.app}-${var.platform.env}" - log_type = "ACCESS_LOGS" - resource_arn = aws_cloudfront_distribution.this.arn -} - -resource "aws_cloudwatch_log_delivery_destination" "this" { - name = "${var.platform.app}-${var.platform.env}" - output_format = "parquet" - - delivery_destination_configuration { - destination_resource_arn = var.platform.splunk_logging_bucket.arn - } -} - -resource "aws_cloudwatch_log_delivery" "this" { - delivery_source_name = aws_cloudwatch_log_delivery_source.this.name - delivery_destination_arn = aws_cloudwatch_log_delivery_destination.this.arn - - s3_delivery_configuration { - suffix_path = "/AWSLogs/${data.aws_caller_identity.this.account_id}/Cloudfront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}" - } -} \ No newline at end of file diff --git a/terraform/modules/web/main.tf b/terraform/modules/web/main.tf index 1f686b84..1049e647 100644 --- a/terraform/modules/web/main.tf +++ b/terraform/modules/web/main.tf @@ -1,44 +1,26 @@ -data "aws_caller_identity" "this" {} - -data "aws_acm_certificate" "issued" { - domain = var.domain_name - statuses = ["ISSUED"] -} - resource "aws_cloudfront_function" "redirects" { - name = "${var.domain_name}-redirects" + name = "redesign-redirects" runtime = "cloudfront-js-2.0" comment = "Function that handles cool URIs and redirects." code = templatefile("${path.module}/redirects-function.tftpl", { redirects = var.redirects }) } resource "aws_cloudfront_origin_access_control" "this" { - name = "${var.domain_name}-s3-origin" + name = var.origin_bucket.bucket_regional_domain_name description = "Manages an AWS CloudFront Origin Access Control, which is used by CloudFront Distributions with an Amazon S3 bucket as the origin." origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" } -resource "aws_cloudfront_response_headers_policy" "this" { - name = "${var.platform.app}-${var.platform.env}-StsHeaderPolicy" - - security_headers_config { - strict_transport_security { - access_control_max_age_sec = 31536000 - override = false - include_subdomains = true - } - } -} - resource "aws_cloudfront_distribution" "this" { - aliases = [var.domain_name] + aliases = var.certificate == null ? [] : [var.certificate.domain_name] comment = "Distribution for the ${var.platform.app}-${var.platform.env} website" default_root_object = "index.html" enabled = var.enabled http_version = "http2and3" is_ipv6_enabled = true + price_class = "PriceClass_100" web_acl_id = var.web_acl.arn custom_error_response { @@ -59,7 +41,7 @@ resource "aws_cloudfront_distribution" "this" { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] compress = true - target_origin_id = var.s3_origin_id + target_origin_id = "s3_origin" viewer_protocol_policy = "redirect-to-https" cache_policy_id = ( @@ -68,8 +50,6 @@ resource "aws_cloudfront_distribution" "this" { "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled managed policy ) - response_headers_policy_id = aws_cloudfront_response_headers_policy.this.id - function_association { event_type = "viewer_request" function_arn = aws_cloudfront_function.redirects.arn @@ -79,7 +59,7 @@ resource "aws_cloudfront_distribution" "this" { origin { domain_name = var.origin_bucket.bucket_regional_domain_name origin_access_control_id = aws_cloudfront_origin_access_control.this.id - origin_id = var.s3_origin_id + origin_id = "s3_origin" } restrictions { @@ -90,9 +70,29 @@ resource "aws_cloudfront_distribution" "this" { } viewer_certificate { - cloudfront_default_certificate = false - acm_certificate_arn = data.aws_acm_certificate.issued.arn - minimum_protocol_version = "TLSv1.2_2021" - ssl_support_method = "sni-only" + cloudfront_default_certificate = var.certificate == null ? true : false + acm_certificate_arn = var.certificate == null ? null : var.certificate.arn + minimum_protocol_version = var.certificate == null ? null : "TLSv1.2_2021" + ssl_support_method = var.certificate == null ? null : "sni-only" } -} \ No newline at end of file +} + +resource "aws_cloudwatch_log_delivery_source" "this" { + name = "${var.platform.app}-${var.platform.env}" + log_type = "ACCESS_LOGS" + resource_arn = aws_cloudfront_distribution.this.arn +} + +resource "aws_cloudwatch_log_delivery_destination" "this" { + name = "${var.platform.app}-${var.platform.env}" + output_format = "parquet" + + delivery_destination_configuration { + destination_resource_arn = "${var.logging_bucket.arn}/${var.origin_bucket.bucket_regional_domain_name}" + } +} + +resource "aws_cloudwatch_log_delivery" "this" { + delivery_source_name = aws_cloudwatch_log_delivery_source.this.name + delivery_destination_arn = aws_cloudwatch_log_delivery_destination.this.arn +} diff --git a/terraform/modules/web/origin.tf b/terraform/modules/web/origin.tf deleted file mode 100644 index 397f1358..00000000 --- a/terraform/modules/web/origin.tf +++ /dev/null @@ -1,58 +0,0 @@ -/* To reduce module nesting and adhere to current configurations, S3 buckets are managed outside of this module. -Permissions for Cloudfront, however, are managed here. */ - -resource "aws_s3_bucket_policy" "allow_cloudfront_access" { - bucket = var.origin_bucket.id - policy = data.aws_iam_policy_document.allow_cloudfront_access.json -} - -# S3 static site host bucket policy document -data "aws_iam_policy_document" "allow_cloudfront_access" { - # There are no dev or test environments for the static site - - statement { - sid = "AllowCloudfrontAccess" - effect = "Allow" - - principals { - type = "Service" - identifiers = ["cloudfront.amazonaws.com"] - } - - actions = [ - "s3:GetObject", - "s3:ListBucket" - ] - - condition { - test = "StringEquals" - variable = "AWS:SourceArn" - values = [ - aws_cloudfront_distribution.this.arn - ] - } - - resources = [ - var.origin_bucket.arn - ] - } - statement { - sid = "AllowSSLRequestsOnly" - effect = "Deny" - principals { - type = "AWS" - identifiers = ["*"] - } - actions = ["s3:*"] - resources = [ - var.origin_bucket.arn, - "${var.origin_bucket.arn}/*", - ] - condition { - test = "Bool" - variable = "aws:SecureTransport" - values = ["false"] - } - } -} - diff --git a/terraform/modules/web/variables.tf b/terraform/modules/web/variables.tf index 40203f27..7a75c23b 100644 --- a/terraform/modules/web/variables.tf +++ b/terraform/modules/web/variables.tf @@ -1,14 +1,29 @@ -variable "domain_name" { - description = "An externally managed domain that points to this distribution. A matching ACM certificate must already be issued." - type = string +variable "certificate" { + default = null + description = "Object representing the website certificate." + type = object({ + arn = string + domain_name = string + }) +} + +variable "enabled" { + default = true + description = "Whether the distribution is enabled to accept end user requests for content." + type = bool +} + +variable "logging_bucket" { + description = "Object representing the logging S3 bucket." + type = object({ + arn = string + }) } variable "origin_bucket" { description = "Object representing the origin S3 bucket." type = object({ bucket_regional_domain_name = string, - arn = string, - id = string }) } @@ -16,10 +31,7 @@ variable "platform" { description = "Object representing the CDAP plaform module." type = object({ app = string, - env = string, - splunk_logging_bucket = object({ - arn = string - }) + env = string }) } @@ -34,15 +46,3 @@ variable "web_acl" { arn = string }) } - -variable "enabled" { - default = true - description = "Whether the distribution is enabled to accept end user requests for content." - type = bool -} - -variable "s3_origin_id" { - default = "s3_origin" - description = "Variable to manage existing s3 origins without recreation. All new instances of this module can leave the default." - type = string -} From 9f19889de738cccb701b9b1690c368c74d78dfd7 Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Tue, 6 Jan 2026 09:53:23 -0700 Subject: [PATCH 26/29] update branch --- terraform/backends/cdap-prod.s3.tfbackend | 2 -- terraform/backends/cdap-test.s3.tfbackend | 2 -- 2 files changed, 4 deletions(-) delete mode 100644 terraform/backends/cdap-prod.s3.tfbackend delete mode 100644 terraform/backends/cdap-test.s3.tfbackend diff --git a/terraform/backends/cdap-prod.s3.tfbackend b/terraform/backends/cdap-prod.s3.tfbackend deleted file mode 100644 index 56f0cc18..00000000 --- a/terraform/backends/cdap-prod.s3.tfbackend +++ /dev/null @@ -1,2 +0,0 @@ -bucket = "cdap-prod-tfstate-20251230175647601000000001" -use_lockfile = true diff --git a/terraform/backends/cdap-test.s3.tfbackend b/terraform/backends/cdap-test.s3.tfbackend deleted file mode 100644 index d6a0917a..00000000 --- a/terraform/backends/cdap-test.s3.tfbackend +++ /dev/null @@ -1,2 +0,0 @@ -bucket = "cdap-test-tfstate-20251230174431053500000001" -use_lockfile = true From 4153a1f97a0285bd62bf750afc3130054f5f86aa Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Tue, 6 Jan 2026 09:54:22 -0700 Subject: [PATCH 27/29] update branch --- .../services/github-actions-role/terraform.tf | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/terraform/services/github-actions-role/terraform.tf b/terraform/services/github-actions-role/terraform.tf index cd31a55e..5009a1bd 100644 --- a/terraform/services/github-actions-role/terraform.tf +++ b/terraform/services/github-actions-role/terraform.tf @@ -1,14 +1,11 @@ provider "aws" { default_tags { - tags = module.standards.default_tags - } -} - -provider "aws" { - alias = "secondary" - region = "us-west-2" - default_tags { - tags = module.standards.default_tags + tags = { + business = "oeda" + code = "https://github.com/CMSgov/cdap/tree/main/terraform/services/github-actions-role" + component = "github-actions" + terraform = true + } } } From 58b02a0eb03ab82503ef1c58729e7f6f6a262b8a Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Tue, 6 Jan 2026 09:57:45 -0700 Subject: [PATCH 28/29] add permissions --- terraform/services/cost-anomaly/main.tf | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/terraform/services/cost-anomaly/main.tf b/terraform/services/cost-anomaly/main.tf index def32e8b..5b61a18c 100644 --- a/terraform/services/cost-anomaly/main.tf +++ b/terraform/services/cost-anomaly/main.tf @@ -117,7 +117,13 @@ data "aws_iam_policy_document" "lambda_permissions" { "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes", - "ssm:GetParameter" + "ssm:GetParameter", + "kms:GenerateDataKey", + "kms:Encrypt", + "kms:Decrypt", + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" ] resources = [module.sns_to_slack_queue.arn] From 10c945ea64191699625e40596651a36862197feb Mon Sep 17 00:00:00 2001 From: juliareynolds-nava Date: Thu, 8 Jan 2026 14:59:56 -0700 Subject: [PATCH 29/29] remove logger --- .../lambda_src/lambda_function.py | 38 ++++++------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/terraform/services/cost-anomaly/lambda_src/lambda_function.py b/terraform/services/cost-anomaly/lambda_src/lambda_function.py index 5333fccf..a13ab12f 100644 --- a/terraform/services/cost-anomaly/lambda_src/lambda_function.py +++ b/terraform/services/cost-anomaly/lambda_src/lambda_function.py @@ -6,16 +6,11 @@ from datetime import datetime, timezone import json import os -import logging -from logging import Logger from urllib import request from urllib.error import URLError import boto3 from botocore.exceptions import ClientError -logger: Logger = logging.getLogger() -logger.setLevel(logging.INFO) - SSM_PARAMETER_CACHE = {} # pylint: disable=too-few-public-methods @@ -101,7 +96,7 @@ def get_ssm_parameter(name): value = response['Parameter']['Value'] SSM_PARAMETER_CACHE[name] = value except ClientError as error: - log({'msg': f'Error getting SSM parameter {name}: {error}'}) + print({'msg': f'Error getting SSM parameter {name}: {error}'}) SSM_PARAMETER_CACHE[name] = None return SSM_PARAMETER_CACHE[name] @@ -122,7 +117,7 @@ def lambda_handler(event, context): """ Parse AWS Cost Anomaly Detection SNS messages """ - logger.info(f"Received event: {json.dumps(event)}") + print(f"Received event: {json.dumps(event)}") message = "test" @@ -139,7 +134,7 @@ def lambda_handler(event, context): sns_message = json.loads(body['Message']) message = process_cost_anomaly(sns_message) else: - logger.warning("No SNS Message found in SQS body") + print("No SNS Message found in SQS body") # Handle direct SNS trigger elif 'Records' in event and event['Records'][0].get('EventSource') == 'aws:sns': @@ -160,14 +155,14 @@ def lambda_handler(event, context): } except Exception as e: - logger.error(f"Error processing message: {str(e)}", exc_info=True) + print(f"Error processing message: {str(e)}") raise def process_cost_anomaly(message): """ Process and parse the cost anomaly detection message """ - logger.info("Processing cost anomaly message") + print("Processing cost anomaly message") # Extract key information account_id = message.get('accountId', 'Unknown') @@ -204,11 +199,11 @@ def process_cost_anomaly(message): 'timestamp': datetime.utcnow().isoformat() } - logger.info(f"Parsed anomaly data: {json.dumps(parsed_data, indent=2)}") + print(f"Parsed anomaly data: {json.dumps(parsed_data, indent=2)}") # Format alert message alert_message = format_alert_message(parsed_data) - logger.info(f"Alert message:\n{alert_message}") + print(f"Alert message:\n{alert_message}") return parsed_data @@ -275,7 +270,7 @@ def send_message_to_slack(webhook, message, message_id): bool: True if successful, False otherwise """ if not webhook: - log({ + print({ 'msg': 'Unable to send to Slack as webhook URL is not set', 'messageId': message_id }) @@ -290,30 +285,19 @@ def send_message_to_slack(webhook, message, message_id): try: with request.urlopen(req, jsondataasbytes) as resp: if resp.status == 200: - log({ + print({ 'msg': 'Successfully sent message to Slack', 'messageId': message_id }) return True - log({ + print({ 'msg': f'Unsuccessful attempt to send message to Slack ({resp.status})', 'messageId': message_id }) return False except URLError as error: - log({ + print({ 'msg': f'Unsuccessful attempt to send message to Slack ({error.reason})', 'messageId': message_id }) return False - - -def log(data): - """ - Enrich the log message with the current time and print it to standard out. - - Args: - data: Dictionary containing log data - """ - data['time'] = datetime.now().astimezone(tz=timezone.utc).isoformat() - print(json.dumps(data))