Terraform Cost Reporter Script

Published on 2025-05-16 by Mathieu

Terraform Cost Reporter Script

A simple script to calculate infrastructure costs for your Terraform projects and send detailed reports via email.

Infrastructure Cost Email Report

Overview

This script allows you to:

  1. Generate cost estimates for your Terraform project using Infracost
  2. Create a detailed HTML report with cost breakdowns
  3. Send the report via email to stakeholders

Prerequisites

  • Python 3.6+
  • Terraform installed locally
  • Infracost CLI installed (Installation Guide)
  • An Infracost API key (Sign up)
  • Email account for sending reports

Quick Start

  1. Clone this repository or download the files
  2. Install the required Python packages: pip install -r requirements.txt
  3. Set up your configuration in config.ini
  4. Run the script: python terraform_cost_reporter.py /path/to/terraform/project

Files Overview

terraform_cost_reporter.py

#!/usr/bin/env python3
"""
Terraform Cost Reporter
Generates cost estimates for Terraform projects and sends reports via email.
"""

import argparse
import configparser
import json
import os
import subprocess
import sys
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import smtplib
import ssl
import tempfile

def read_config(config_path='config.ini'):
    """Read configuration from config.ini file"""
    if not os.path.exists(config_path):
        print(f"Error: Configuration file '{config_path}' not found.")
        print("Please create a config.ini file based on config.ini.example")
        sys.exit(1)
        
    config = configparser.ConfigParser()
    config.read(config_path)
    return config

def run_infracost(terraform_dir, project_name, environment):
    """Run Infracost and generate cost reports"""
    # Create temp directory for outputs
    temp_dir = tempfile.mkdtemp()
    txt_report = os.path.join(temp_dir, f"infracost-{project_name}-{environment}.txt")
    json_report = os.path.join(temp_dir, f"infracost-{project_name}-{environment}.json")
    
    # Make sure terraform dir exists
    if not os.path.isdir(terraform_dir):
        print(f"Error: Terraform directory '{terraform_dir}' not found.")
        sys.exit(1)
    
    # Check if Infracost is installed
    try:
        subprocess.run(['infracost', '--version'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    except (subprocess.SubprocessError, FileNotFoundError):
        print("Error: Infracost not found. Please install Infracost CLI.")
        print("Visit: https://www.infracost.io/docs/#installation")
        sys.exit(1)
    
    # Set currency
    subprocess.run(['infracost', 'configure', 'set', 'currency', 'EUR'], check=True)
    
    # Run terraform init if .terraform directory doesn't exist
    terraform_initialized = os.path.isdir(os.path.join(terraform_dir, '.terraform'))
    if not terraform_initialized:
        print("Running 'terraform init'...")
        subprocess.run(['terraform', 'init'], cwd=terraform_dir, check=True)
    
    # Generate terraform plan in temporary file
    plan_file = os.path.join(temp_dir, "tfplan")
    print(f"Generating Terraform plan for {project_name} in {environment} environment...")
    
    try:
        subprocess.run(
            ['terraform', 'plan', '-out', plan_file], 
            cwd=terraform_dir, 
            check=True
        )
    except subprocess.SubprocessError as e:
        print(f"Error generating Terraform plan: {e}")
        sys.exit(1)
    
    # Run Infracost breakdown and save reports
    print("Generating Infracost breakdown...")
    try:
        # Generate text report
        subprocess.run([
            'infracost', 'breakdown', 
            '--path', plan_file,
            '--format', 'table', 
            '--out-file', txt_report
        ], check=True)
        
        # Generate JSON report
        subprocess.run([
            'infracost', 'breakdown', 
            '--path', plan_file,
            '--format', 'json', 
            '--out-file', json_report
        ], check=True)
        
        print(f"Infracost reports generated:")
        print(f"  - Text report: {txt_report}")
        print(f"  - JSON report: {json_report}")
        
        return txt_report, json_report
    except subprocess.SubprocessError as e:
        print(f"Error running Infracost: {e}")
        sys.exit(1)

def create_html_email_body(json_data, project_name, environment):
    """Create HTML email body from Infracost JSON data"""
    try:
        # Load the project from the first element if it exists
        project = json_data.get('projects', [{}])[0]
        currency = json_data.get('currency', '€')
        
        html_body = f"""
        <html>
        <body>
        <p>Hello,</p>
        <p>Please find below the Infracost estimate for the application: <b>{project_name}</b> infrastructure in the <b>{environment}</b> environment:</p>

        <h3>📊 Summary Breakdown</h3>
        <table border="1" style="border-collapse: collapse; width: 100%;">
            <tr style="background-color: #f2f2f2;">
                <th style="padding: 8px;">Project</th>
                <th style="padding: 8px;">Baseline Cost ({currency})</th>
                <th style="padding: 8px;">Usage Cost* ({currency})</th>
                <th style="padding: 8px;">Total Cost ({currency})</th>
            </tr>
        """

        breakdown = project.get('breakdown', {})
        total_cost = float(breakdown.get('totalMonthlyCost') or 0.0)
        usage_cost = float(breakdown.get('totalMonthlyUsageCost') or 0.0)
        baseline_cost = total_cost - usage_cost

        if float(total_cost) == 0:
            html_body += """
            <tr>
                <td style="padding: 8px;" colspan="4">No cost information available</td>
            </tr>
            """
        else:
            html_body += f"""
            <tr>
                <td style="padding: 8px;">{project.get('name', project_name)}</td>
                <td style="padding: 8px;">{currency}{format(float(baseline_cost), '.2f')}</td>
                <td style="padding: 8px;">{currency}{format(float(usage_cost), '.2f')}</td>
                <td style="padding: 8px;">{currency}{format(float(total_cost), '.2f')}</td>
            </tr>
            """

        html_body += f"""
        </table>

        <h3>📋 Detailed Resources Breakdown</h3>
        <table border="1" style="border-collapse: collapse; width: 100%;">
            <tr style="background-color: #f2f2f2;">
                <th style="padding: 8px;">Name</th>
                <th style="padding: 8px;">Monthly Quantity</th>
                <th style="padding: 8px;">Unit</th>
                <th style="padding: 8px;">Monthly Cost ({currency})</th>
            </tr>
        """

        resources = breakdown.get('resources', [])
        if not resources:
            html_body += """
            <tr>
                <td style="padding: 8px;" colspan="4">No resources breakdown found</td>
            </tr>
            """
        else:
            for resource in resources:
                for component in resource.get('costComponents', []):
                    name = component.get('name', 'Unknown')
                    unit = component.get('unit', 'N/A')
                    quantity = component.get('monthlyQuantity') or 0.0
                    cost = component.get('monthlyCost') or 0.0

                    html_body += f"""
                    <tr>
                        <td style="padding: 8px;">{name}</td>
                        <td style="padding: 8px;">{format(float(quantity), ',')}</td>
                        <td style="padding: 8px;">{unit}</td>
                        <td style="padding: 8px;">{currency}{format(float(cost), '.2f')}</td>
                    </tr>
                    """

        html_body += f"""
        </table>

        <h3>📈 Resource Summary</h3>
        <table border="1" style="border-collapse: collapse; width: 100%;">
            <tr style="background-color: #f2f2f2;">
                <th style="padding: 8px;">Total Detected Resources</th>
                <th style="padding: 8px;">Total Supported</th>
                <th style="padding: 8px;">Total Unsupported</th>
                <th style="padding: 8px;">Total No-Price</th>
            </tr>
        """

        summary = json_data.get('summary', {})
        total_detected = summary.get('totalDetectedResources', 0)
        total_supported = summary.get('totalSupportedResources', 0)
        total_unsupported = summary.get('totalUnsupportedResources', 0)
        total_no_price = summary.get('totalNoPriceResources', 0)

        html_body += f"""
        <tr>
            <td style="padding: 8px;">{total_detected}</td>
            <td style="padding: 8px;">{total_supported}</td>
            <td style="padding: 8px;">{total_unsupported}</td>
            <td style="padding: 8px;">{total_no_price}</td>
        </tr>
        </table>
        <p>* Usage-based costs are estimated and may vary.</p>
        </body>
        </html>
        """

        return html_body
    except Exception as e:
        print(f"Error creating HTML email body: {e}")
        # Return a simple fallback message
        return f"<html><body><p>Infrastructure cost report for {project_name} in {environment} environment.</p></body></html>"

def send_email(html_body, txt_report_path, config, project_name, environment, total_cost, currency='€'):
    """Send email with cost report"""
    try:
        smtp_server = config['Email']['smtp_server']
        smtp_port = int(config['Email']['smtp_port'])
        smtp_username = config['Email']['smtp_username']
        smtp_password = config['Email']['smtp_password']
        sender_email = config['Email']['sender_email']
        receiver_email = config['Email']['receiver_email']
        
        # Construct the email
        message = MIMEMultipart("alternative")
        subject = f"Infrastructure Cost Estimation - {project_name.upper()} ({environment}) - {currency}{total_cost}/month"
        message["Subject"] = subject
        message["From"] = sender_email
        message["To"] = receiver_email

        # Attach the HTML content
        html_part = MIMEText(html_body, "html")
        message.attach(html_part)

        # Attach the text report as a file
        with open(txt_report_path, "rb") as attachment:
            part = MIMEBase("application", "octet-stream")
            part.set_payload(attachment.read())
            encoders.encode_base64(part)
            part.add_header(
                "Content-Disposition",
                f"attachment; filename= {os.path.basename(txt_report_path)}",
            )
            message.attach(part)

        # Establish a secure connection with the server and send email
        context = ssl.create_default_context()
        
        with smtplib.SMTP(smtp_server, smtp_port) as server:
            server.ehlo()
            server.starttls(context=context)
            server.ehlo()
            server.login(smtp_username, smtp_password)
            server.sendmail(sender_email, receiver_email, message.as_string())
            
        print(f"Email sent successfully to {receiver_email}")
        return True
    except Exception as e:
        print(f"Error sending email: {e}")
        return False

def main():
    """Main function that runs the script"""
    parser = argparse.ArgumentParser(description='Generate and email Terraform infrastructure cost reports')
    parser.add_argument('terraform_dir', help='Path to the Terraform project directory')
    parser.add_argument('--project', '-p', default='terraform-project', help='Project name for the report')
    parser.add_argument('--env', '-e', default='dev', help='Environment name (dev, test, prod, etc.)')
    parser.add_argument('--config', '-c', default='config.ini', help='Path to configuration file')
    args = parser.parse_args()
    
    # Read configuration
    config = read_config(args.config)
    
    # Set Infracost API key from config
    os.environ['INFRACOST_API_KEY'] = config['Infracost']['api_key']
    
    # Run Infracost to generate reports
    txt_report, json_report = run_infracost(args.terraform_dir, args.project, args.env)
    
    # Read the JSON report
    with open(json_report, 'r') as file:
        json_data = json.load(file)
    
    # Create HTML email body
    html_body = create_html_email_body(json_data, args.project, args.env)
    
    # Get total cost for subject line
    total_cost = format(float(json_data.get('totalMonthlyCost', 0.0)), '.2f')
    currency = json_data.get('currency', '€')
    
    # Send email
    print("Sending cost report email...")
    success = send_email(html_body, txt_report, config, args.project, args.env, total_cost, currency)
    
    if success:
        print("✅ Cost report generated and sent successfully!")
    else:
        print("❌ Failed to send email, but cost reports were generated.")
        print(f"Text report available at: {txt_report}")
        print(f"JSON report available at: {json_report}")

if __name__ == "__main__":
    main()

config.ini.example

[Infracost]
api_key = your_infracost_api_key_here

[Email]
smtp_server = smtp.gmail.com
smtp_port = 587
smtp_username = your_email@gmail.com
smtp_password = your_email_password_or_app_password
sender_email = your_email@gmail.com
receiver_email = recipient@example.com

requirements.txt

configparser>=5.0.0

Usage

Basic Usage

python terraform_cost_reporter.py /path/to/terraform/project

With Project and Environment Names

python terraform_cost_reporter.py /path/to/terraform/project --project my-api --env production

Using a Different Config File

python terraform_cost_reporter.py /path/to/terraform/project --config my-config.ini

Sample Email Output

When run successfully, this script will send an email that looks like:

Subject: Infrastructure Cost Estimation - MY-PROJECT (production) - €123.45/month

Hello,

Please find below the Infracost estimate for the application: my-project infrastructure in the production environment:

📊 Summary Breakdown
[Table with baseline, usage, and total costs]

📋 Detailed Resources Breakdown
[Table listing all resources and their individual costs]

📈 Resource Summary
[Table showing resource statistics]

* Usage-based costs are estimated and may vary.

Customizing the Script

Changing Currency

To change the currency (default is EUR), modify line 61 in the script:

# Change EUR to USD, GBP, etc.
subprocess.run(['infracost', 'configure', 'set', 'currency', 'EUR'], check=True)

Adding Authentication for Cloud Providers

For AWS, Azure, or GCP authentication, set the appropriate environment variables before running the script:

AWS Example:

export AWS_ACCESS_KEY_ID=your_access_key
export AWS_SECRET_ACCESS_KEY=your_secret_key
export AWS_REGION=your_region
python terraform_cost_reporter.py /path/to/terraform/project

Azure Example:

export ARM_CLIENT_ID=your_client_id
export ARM_CLIENT_SECRET=your_client_secret
export ARM_SUBSCRIPTION_ID=your_subscription_id
export ARM_TENANT_ID=your_tenant_id
python terraform_cost_reporter.py /path/to/terraform/project

Scheduled Execution

You can set up a cron job to run this script regularly:

# Run every Monday at 8 AM
0 8 * * 1 /path/to/python /path/to/terraform_cost_reporter.py /path/to/terraform/project

Troubleshooting

SMTP Authentication Issues

If you're using Gmail, you might need to:

  1. Enable "Less secure app access" in your Google account
  2. Or (preferred) create an "App Password" for this script

Infracost API Key Issues

Make sure your Infracost API key is valid and properly set in your config.ini file. You can verify your API key with:

infracost configure get api_key

Terraform Plan Errors

If you encounter errors during Terraform plan generation:

  1. Try running terraform plan manually in your project directory
  2. Verify your Terraform version is compatible with your configuration
  3. Check if you need to set cloud provider credentials

License

This script is available under the MIT License. Feel free to use and modify it for your needs.

Tags: infracost, cloud, finops