Skip to content

Commit

Permalink
feat(billing-report): adding support for SNS/SES
Browse files Browse the repository at this point in the history
  • Loading branch information
kiraum committed Oct 3, 2024
1 parent d8b0f56 commit 60e9a13
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 30 deletions.
14 changes: 10 additions & 4 deletions environments/prod/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,19 @@ provider "aws" {
module "billing_report" {
source = "../../modules/billing_report"

lambda_function_name = "billing-report-lambda"
ses_sender_email = "[email protected]"
ses_recipient_email = "[email protected]"
ses_domain = "kiraum.it"
lambda_function_name = "billing-report-lambda"
ses_sender_email = "[email protected]"
ses_domain = "kiraum.it"
recipient_email = "[email protected]"
notification_service = "SNS"
daily_cost_threshold = "0.01"
weekly_cost_threshold = "1.00"
monthly_cost_threshold = "5.00"
yearly_cost_threshold = "60.00"
}



module "route53" {
source = "../../modules/route53"

Expand Down
160 changes: 142 additions & 18 deletions modules/billing_report/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
MONTHLY_COST_THRESHOLD = float(os.environ.get("MONTHLY_COST_THRESHOLD", "0.01"))
YEARLY_COST_THRESHOLD = float(os.environ.get("YEARLY_COST_THRESHOLD", "0.01"))

NOTIFICATION_SERVICE = os.environ.get("NOTIFICATION_SERVICE", "SNS").upper()


def calculate_time_periods(time_period, current_date):
"""
Expand Down Expand Up @@ -85,6 +87,80 @@ def process_cost_data(response, compare_response):
return current_costs, compare_costs, unit


def generate_text_report(
time_period,
start,
end,
current_costs,
compare_costs,
unit,
response,
compare_response,
cost_threshold,
):
"""
Generate a detailed text cost report.
Args:
time_period (str): The time period of the report.
start (datetime.date): The start date of the report.
end (datetime.date): The end date of the report.
current_costs (float): The total costs for the current period.
compare_costs (float): The total costs for the comparison period.
unit (str): The currency unit.
response (dict): The response from AWS Cost Explorer for the current period.
compare_response (dict): The response from AWS Cost Explorer for the comparison period.
cost_threshold (float): The cost threshold for the time period.
Returns:
str: A formatted text cost report.
"""
text_template = """
AWS Cost Report for {time_period}
Period: {start_date} to {end_date}
Summary:
Current {time_period} cost: {current_costs:.7f} {unit}
Previous {time_period} cost: {compare_costs:.7f} {unit}
Difference: {difference:.7f} {unit}
Threshold: {threshold:.7f} {unit}
Breakdown by Service:
{service_breakdown}
"""

current_services = {
group["Keys"][0]: float(group["Metrics"]["UnblendedCost"]["Amount"])
for result in response["ResultsByTime"]
for group in result.get("Groups", [])
}
previous_services = {
group["Keys"][0]: float(group["Metrics"]["UnblendedCost"]["Amount"])
for result in compare_response["ResultsByTime"]
for group in result.get("Groups", [])
}
service_breakdown = ""
for service, cost in current_services.items():
if cost > 0:
previous_cost = previous_services.get(service, 0)
difference = cost - previous_cost
service_breakdown += f"{service}:\n"
service_breakdown += f" Current: {cost:.7f} {unit}\n"
service_breakdown += f" Previous: {previous_cost:.7f} {unit}\n"
service_breakdown += f" Difference: {difference:.7f} {unit}\n\n"

return text_template.format(
time_period=time_period,
start_date=start.isoformat(),
end_date=(end - datetime.timedelta(days=1)).isoformat(),
current_costs=current_costs,
compare_costs=compare_costs,
unit=unit,
difference=current_costs - compare_costs,
threshold=cost_threshold,
service_breakdown=service_breakdown,
)


def generate_html_report(
time_period,
start,
Expand Down Expand Up @@ -160,6 +236,7 @@ def generate_html_report(
</body>
</html>
"""

current_services = {
group["Keys"][0]: float(group["Metrics"]["UnblendedCost"]["Amount"])
for result in response["ResultsByTime"]
Expand All @@ -177,12 +254,13 @@ def generate_html_report(
difference = cost - previous_cost
service_rows += f"""
<tr>
<td>{service}</td>
<td>{cost:.7f} {unit}</td>
<td>{previous_cost:.7f} {unit}</td>
<td>{difference:.7f} {unit}</td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: left;">{service}</td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: left;">{cost:.7f} {unit}</td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: left;">{previous_cost:.7f} {unit}</td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: left;">{difference:.7f} {unit}</td>
</tr>
"""

return html_template.format(
time_period=time_period,
start_date=start.isoformat(),
Expand All @@ -196,6 +274,26 @@ def generate_html_report(
)


def send_sns(message, subject):
"""
Send a message using AWS SNS.
Args:
message (str): The message to be sent.
subject (str): The subject of the message.
"""
sns = boto3.client("sns")
topic_arn = os.environ["SNS_TOPIC_ARN"]

if not topic_arn:
raise ValueError("SNS_TOPIC_ARN must be set in the environment variables")

try:
response = sns.publish(TopicArn=topic_arn, Message=message, Subject=subject)
print(f"Message sent to SNS! Message ID: {response['MessageId']}")
except ClientError as e:
print(f"An error occurred while sending message via SNS: {e}")


def send_ses(message, subject):
"""
Send an HTML message using AWS SES.
Expand Down Expand Up @@ -234,12 +332,25 @@ def send_ses(message, subject):
print(f"An error occurred while sending email via SES: {e}")


def send_notification(message, subject):
"""
Send a notification using either SES or SNS.
Args:
message (str): The message to be sent.
subject (str): The subject of the message.
"""
if NOTIFICATION_SERVICE == "SES":
send_ses(message, subject)
else: # Default to SNS
send_sns(message, subject)


def lambda_handler(event, context):
"""
AWS Lambda function to report AWS costs for various time periods.
This function retrieves cost data from AWS Cost Explorer for a specified time period,
compares it with the previous period, and generates an HTML cost report. If the cost exceeds
a predefined threshold, it sends a notification via SES.
compares it with the previous period, and generates a cost report. If the cost exceeds
a predefined threshold, it sends a notification via SNS or SES.
Args:
event (dict): The Lambda event object containing input parameters.
- time_period (str, optional): The time period for the cost report.
Expand Down Expand Up @@ -287,20 +398,33 @@ def lambda_handler(event, context):
"monthly": MONTHLY_COST_THRESHOLD,
"yearly": YEARLY_COST_THRESHOLD,
}.get(time_period, DAILY_COST_THRESHOLD)
html_report = generate_html_report(
time_period,
start,
end,
current_costs,
compare_costs,
unit,
response,
compare_response,
cost_threshold,
)
if NOTIFICATION_SERVICE == "SES":
report = generate_html_report(
time_period,
start,
end,
current_costs,
compare_costs,
unit,
response,
compare_response,
cost_threshold,
)
else:
report = generate_text_report(
time_period,
start,
end,
current_costs,
compare_costs,
unit,
response,
compare_response,
cost_threshold,
)
if current_costs > cost_threshold:
print("Cost threshold exceeded. Sending notification.")
send_ses(html_report, f"AWS Cost Report - {time_period.capitalize()}")
send_notification(report, f"AWS Cost Report - {time_period.capitalize()}")
else:
print(
f"Total cost ({current_costs:.7f} {unit}) did not exceed the threshold ({cost_threshold:.7f} {unit}). No notification sent."
Expand Down
29 changes: 27 additions & 2 deletions modules/billing_report/main.tf
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
terraform {
required_version = ">= 1.0.0"

required_providers {
aws = {
source = "hashicorp/aws"
Expand Down Expand Up @@ -27,8 +28,14 @@ resource "aws_lambda_function" "billing_report" {

environment {
variables = {
SES_SENDER_EMAIL = var.ses_sender_email
SES_RECIPIENT_EMAIL = var.ses_recipient_email
SES_SENDER_EMAIL = var.ses_sender_email
recipient_email = var.recipient_email
SNS_TOPIC_ARN = aws_sns_topic.billing_report.arn
NOTIFICATION_SERVICE = var.notification_service
DAILY_COST_THRESHOLD = var.daily_cost_threshold
WEEKLY_COST_THRESHOLD = var.weekly_cost_threshold
MONTHLY_COST_THRESHOLD = var.monthly_cost_threshold
YEARLY_COST_THRESHOLD = var.yearly_cost_threshold
}
}

Expand Down Expand Up @@ -93,11 +100,23 @@ resource "aws_iam_role_policy" "lambda_policy" {
"ses:SendRawEmail"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"sns:Publish"
]
Resource = aws_sns_topic.billing_report.arn
}
]
})
}

# Create SNS topic for billing report
resource "aws_sns_topic" "billing_report" {
name = "billing-report-topic"
}

# Create CloudWatch event rules
resource "aws_cloudwatch_event_rule" "daily_trigger" {
name = "billing-report-daily-schedule"
Expand Down Expand Up @@ -243,3 +262,9 @@ resource "aws_ses_domain_mail_from" "ses_domain_mail_from" {
domain = aws_ses_domain_identity.ses_domain.domain
mail_from_domain = "mail.${aws_ses_domain_identity.ses_domain.domain}"
}

resource "aws_sns_topic_subscription" "billing_report_email" {
topic_arn = aws_sns_topic.billing_report.arn
protocol = "email"
endpoint = var.recipient_email
}
6 changes: 3 additions & 3 deletions modules/billing_report/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ output "ses_sender_email" {
value = var.ses_sender_email
}

output "ses_recipient_email" {
description = "The email address receiving SES emails"
value = var.ses_recipient_email
output "recipient_email" {
description = "The email address receiving emails"
value = var.recipient_email
}
36 changes: 33 additions & 3 deletions modules/billing_report/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,42 @@ variable "ses_sender_email" {
type = string
}

variable "ses_recipient_email" {
description = "Email address to receive SES emails"
variable "recipient_email" {
description = "Email address to receive emails"
type = string
}

variable "ses_domain" {
description = "Domain for SES"
type = string
}
}

variable "notification_service" {
description = "The notification service to use (SNS or SES)"
type = string
default = "SNS"
}

variable "daily_cost_threshold" {
description = "The daily cost threshold for billing alerts"
type = string
default = "0.01"
}

variable "weekly_cost_threshold" {
description = "The weekly cost threshold for billing alerts"
type = string
default = "0.01"
}

variable "monthly_cost_threshold" {
description = "The monthly cost threshold for billing alerts"
type = string
default = "0.01"
}

variable "yearly_cost_threshold" {
description = "The yearly cost threshold for billing alerts"
type = string
default = "0.01"
}

0 comments on commit 60e9a13

Please sign in to comment.