Featured image of post Simplifying AWS Certificate Management with Python

Simplifying AWS Certificate Management with Python

A deep dive into a handy Python tool for importing SSL certificates into AWS Certificate Manager

Simplifying AWS Certificate Management with Python

As part of my regular work managing cloud infrastructure, I often need to import SSL certificates into AWS Certificate Manager. It’s a straightforward but repetitive process that becomes tedious when done through the AWS console. To streamline this workflow, I created a Python tool that handles the certificate import process via CLI.

In this post, I’ll walk through the code and explain how it automates certificate imports while providing a more efficient user experience and it handles profile selection, certificate validation, and the actual import process—all from the comfort of your terminal.

Setting the Stage with Imports

Let’s start by examining the imports:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3
import os
import configparser
from prompt_toolkit import prompt
from prompt_toolkit.completion import WordCompleter
import boto3
import botocore.exceptions
from rich.console import Console
from rich.table import Table

The script uses several libraries to enhance functionality:

  • prompt_toolkit provides tab completion for command-line inputs
  • boto3 handles AWS API interactions
  • rich improves terminal output formatting
  • configparser parses AWS configuration files

I chose prompt_toolkit over standard input to enable tab completion, which reduces typing errors when entering profile names and file paths. The rich library provides better terminal formatting than standard print statements.

Loading AWS Profiles

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def load_aws_profiles():
    """
    Reads the AWS profiles from the ~/.aws/config file.
    The config file typically contains sections like '[default]' or '[profile myprofile]'.
    Returns a list of profile names.
    """
    config_path = os.path.expanduser("~/.aws/config")
    profiles = []
    if os.path.exists(config_path):
        config = configparser.ConfigParser()
        config.read(config_path)
        for section in config.sections():
            # Strip the "profile " prefix if it exists.
            if section.startswith("profile "):
                profiles.append(section.split("profile ")[1])
            else:
                profiles.append(section)
    return profiles  # Return empty list if config doesn't exist

This function extracts available AWS profiles from your config file. It’s designed to handle both formats AWS has used over time: sections with the “profile” prefix and those without it. The function works with any AWS credential setup including SSO, IAM roles, or direct key-based access.

File Management Helper

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def load_file_list(directory):
    """
    Returns a list of file names (not directories) found in the given directory.
    """
    try:
        files = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
        return files
    except Exception as e:
        console.print(f"[red]Error listing files in directory: {e}[/red]")
        return []

This small helper function lists files in a directory. It filters out directories and returns only filenames, enabling tab completion when selecting certificate files. The error handling safely returns an empty list if the directory can’t be accessed.

The Main Function

Now let’s look at the heart of our script—the main function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def main():
    console = Console()

    # Load AWS profiles from ~/.aws/config.
    profiles = load_aws_profiles()
    if not profiles:
        console.print("[red]Error: No AWS profiles found.[/red]")
        console.print("[yellow]Please set up AWS SSO login or configure your AWS credentials first.[/yellow]")
        console.print("[yellow]Run 'aws configure sso' or visit the AWS documentation for guidance on setting up credentials.[/yellow]")
        return

The script starts by initializing the Rich console and loading AWS profiles. If no profiles are found, it provides helpful guidance rather than just crashing with a cryptic error. Because there’s nothing worse than an unhelpful error message—except maybe meetings that could have been emails.

Profile Selection and Validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    profile_completer = WordCompleter(profiles, ignore_case=True)
    selected_profile = prompt("Select AWS Profile: ", completer=profile_completer)
    if selected_profile not in profiles:
        console.print(f"[red]Invalid profile '{selected_profile}' selected. Exiting.[/red]")
        return

    # Validate AWS credentials using the selected profile.
    try:
        session = boto3.Session(profile_name=selected_profile)
        sts_client = session.client('sts')
        with console.status("[bold green]Validating AWS credentials...[/bold green]", spinner="dots"):
            identity = sts_client.get_caller_identity()
        console.print(f"[green]AWS credentials validated for ARN: {identity.get('Arn', 'N/A')}[/green]")
    except botocore.exceptions.BotoCoreError as e:
        console.print(f"[red]AWS credentials validation failed: {e}[/red]")
        return
    except Exception as e:
        console.print(f"[red]Failed to validate AWS credentials: {e}[/red]")
        return

Here, the script implements tab-completion for AWS profiles and validates the credentials by making a test call to the AWS STS service. This step verifies that the credentials work before proceeding further, avoiding potential errors during the actual certificate import operation.

The status spinner gives a clear indication that something is happening during validation. This is particularly useful when working with SSO profiles where token retrieval might take a moment.

Certificate File Selection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    # Prompt for base directory for certificate files (Linux style).
    base_dir = prompt("Enter base directory for certificate files (Linux style): ")
    base_dir = os.path.expanduser(base_dir)  # Handle ~ in path
    if not os.path.isdir(base_dir):
        console.print(f"[red]Directory '{base_dir}' does not exist or is not a directory. Exiting.[/red]")
        return

    # Load available file names from the base directory.
    file_list = load_file_list(base_dir)
    if not file_list:
        console.print(f"[red]No files found in '{base_dir}'. Exiting.[/red]")
        return
    file_completer = WordCompleter(file_list, ignore_case=True)

    # Prompt for the certificate file.
    cert_file = prompt("Enter certificate file name: ", completer=file_completer)
    cert_path = os.path.join(base_dir, cert_file)
    if not os.path.exists(cert_path):
        console.print(f"[red]Certificate file '{cert_path}' does not exist. Exiting.[/red]")
        return

    # Prompt for the private key file.
    key_file = prompt("Enter private key file name: ", completer=file_completer)
    key_path = os.path.join(base_dir, key_file)
    if not os.path.exists(key_path):
        console.print(f"[red]Private key file '{key_path}' does not exist. Exiting.[/red]")
        return

This section handles certificate file selection. It prompts for a base directory and expands paths with tilde notation. The validation at each step prevents invalid inputs from causing problems later in the process. Path validation is important for reliability - checking file existence early prevents more cryptic errors when file reads fail.

Handling Certificate Chains

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    # Prompt for certificate chain files (multiple entries allowed).
    chain_files = []
    while True:
        chain_file = prompt("Enter certificate chain file name (or press enter to finish): ", completer=file_completer)
        if not chain_file:
            break
        chain_path = os.path.join(base_dir, chain_file)
        if not os.path.exists(chain_path):
            console.print(f"[red]Certificate chain file '{chain_path}' does not exist. Skipping.[/red]")
            continue
        chain_files.append(chain_path)

Certificate chains can have different formats. This loop allows for multiple chain files, accommodating both certificate authorities that provide chain certificates as a single file and those that split them across multiple files. Using a loop with an empty input as the exit condition provides a clean way to handle variable numbers of inputs.

The “press enter to finish” pattern is a simple but effective way to handle variable numbers of inputs in a CLI. It’s more user-friendly than asking “How many chain files do you have?” up front.

Reading Certificate Files

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    # Read certificate and key files.
    try:
        with open(cert_path, 'r') as f:
            certificate_body = f.read()
        with open(key_path, 'r') as f:
            private_key = f.read()

        certificate_chain = None
        if chain_files:
            chain_data = ""
            for cf in chain_files:
                with open(cf, 'r') as f:
                    chain_data += f.read() + "\n"
            if chain_data.strip():
                certificate_chain = chain_data
    except UnicodeDecodeError:
        console.print("[red]Error: Files appear to be binary. Make sure you're using PEM-formatted text files.[/red]")
        return
    except Exception as e:
        console.print(f"[red]Error reading files: {e}[/red]")
        return

This section reads the certificate files into memory. The specific error handler for UnicodeDecodeError provides targeted feedback when a user tries to use binary format certificates instead of PEM text format. The chain file processing concatenates multiple files with newlines, which works with most certificate authorities.

The Certificate Import Process

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    # Use the previously validated session to create an ACM client.
    acm_client = session.client('acm')

    # Import the certificate into AWS Certificate Manager.
    try:
        import_params = {
            'Certificate': certificate_body,
            'PrivateKey': private_key
        }
        if certificate_chain:
            import_params['CertificateChain'] = certificate_chain
            
        with console.status("[bold green]Importing certificate...[/bold green]", spinner="dots"):
            response = acm_client.import_certificate(**import_params)
            
        # Display the response in a table.
        table = Table(title="Certificate Import Result")
        table.add_column("Field", style="cyan", no_wrap=True)
        table.add_column("Value", style="magenta")
        certificate_arn = response.get("CertificateArn", "N/A")
        table.add_row("CertificateArn", certificate_arn)
        console.print(table)
        console.print("[green]Certificate successfully imported![/green]")
    except botocore.exceptions.ClientError as e:
        console.print(f"[red]AWS error: {e.response['Error']['Message']}[/red]")
    except Exception as e:
        console.print(f"[red]Failed to import certificate: {e}[/red]")

Finally, we reach the main goal: importing the certificate. The script creates an ACM client using the validated session and calls import_certificate() with the certificate data.

The output is formatted using Rich’s Table component, which provides a cleaner presentation of the certificate ARN than raw text output. The structuring of data into a table makes it easier to distinguish between field names and values.

Running the Script

1
2
if __name__ == '__main__':
    main()

This standard Python idiom allows the script to be both imported as a module and run directly from the command line.

Putting It All Together

After examining each component individually, let’s step back and appreciate the entire script as a cohesive unit. Here’s the complete code that ties everything together:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
#!/usr/bin/env python3
import os
import configparser
from prompt_toolkit import prompt
from prompt_toolkit.completion import WordCompleter
import boto3
import botocore.exceptions
from rich.console import Console
from rich.table import Table

def load_aws_profiles():
    """
    Reads the AWS profiles from the ~/.aws/config file.
    The config file typically contains sections like '[default]' or '[profile myprofile]'.
    Returns a list of profile names.
    """
    config_path = os.path.expanduser("~/.aws/config")
    profiles = []
    if os.path.exists(config_path):
        config = configparser.ConfigParser()
        config.read(config_path)
        for section in config.sections():
            # Strip the "profile " prefix if it exists.
            if section.startswith("profile "):
                profiles.append(section.split("profile ")[1])
            else:
                profiles.append(section)
    return profiles  # Return empty list if config doesn't exist

def load_file_list(directory):
    """
    Returns a list of file names (not directories) found in the given directory.
    """
    try:
        files = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
        return files
    except Exception as e:
        console.print(f"[red]Error listing files in directory: {e}[/red]")
        return []

def main():
    console = Console()

    # Load AWS profiles from ~/.aws/config.
    profiles = load_aws_profiles()
    if not profiles:
        console.print("[red]Error: No AWS profiles found.[/red]")
        console.print("[yellow]Please set up AWS SSO login or configure your AWS credentials first.[/yellow]")
        console.print("[yellow]Run 'aws configure sso' or visit the AWS documentation for guidance on setting up credentials.[/yellow]")
        return
        
    profile_completer = WordCompleter(profiles, ignore_case=True)
    selected_profile = prompt("Select AWS Profile: ", completer=profile_completer)
    if selected_profile not in profiles:
        console.print(f"[red]Invalid profile '{selected_profile}' selected. Exiting.[/red]")
        return

    # Validate AWS credentials using the selected profile.
    try:
        session = boto3.Session(profile_name=selected_profile)
        sts_client = session.client('sts')
        with console.status("[bold green]Validating AWS credentials...[/bold green]", spinner="dots"):
            identity = sts_client.get_caller_identity()
        console.print(f"[green]AWS credentials validated for ARN: {identity.get('Arn', 'N/A')}[/green]")
    except botocore.exceptions.BotoCoreError as e:
        console.print(f"[red]AWS credentials validation failed: {e}[/red]")
        return
    except Exception as e:
        console.print(f"[red]Failed to validate AWS credentials: {e}[/red]")
        return

    # Prompt for base directory for certificate files (Linux style).
    base_dir = prompt("Enter base directory for certificate files (Linux style): ")
    base_dir = os.path.expanduser(base_dir)  # Handle ~ in path
    if not os.path.isdir(base_dir):
        console.print(f"[red]Directory '{base_dir}' does not exist or is not a directory. Exiting.[/red]")
        return

    # Load available file names from the base directory.
    file_list = load_file_list(base_dir)
    if not file_list:
        console.print(f"[red]No files found in '{base_dir}'. Exiting.[/red]")
        return
    file_completer = WordCompleter(file_list, ignore_case=True)

    # Prompt for the certificate file.
    cert_file = prompt("Enter certificate file name: ", completer=file_completer)
    cert_path = os.path.join(base_dir, cert_file)
    if not os.path.exists(cert_path):
        console.print(f"[red]Certificate file '{cert_path}' does not exist. Exiting.[/red]")
        return

    # Prompt for the private key file.
    key_file = prompt("Enter private key file name: ", completer=file_completer)
    key_path = os.path.join(base_dir, key_file)
    if not os.path.exists(key_path):
        console.print(f"[red]Private key file '{key_path}' does not exist. Exiting.[/red]")
        return

    # Prompt for certificate chain files (multiple entries allowed).
    chain_files = []
    while True:
        chain_file = prompt("Enter certificate chain file name (or press enter to finish): ", completer=file_completer)
        if not chain_file:
            break
        chain_path = os.path.join(base_dir, chain_file)
        if not os.path.exists(chain_path):
            console.print(f"[red]Certificate chain file '{chain_path}' does not exist. Skipping.[/red]")
            continue
        chain_files.append(chain_path)

    # Read certificate and key files.
    try:
        with open(cert_path, 'r') as f:
            certificate_body = f.read()
        with open(key_path, 'r') as f:
            private_key = f.read()

        certificate_chain = None
        if chain_files:
            chain_data = ""
            for cf in chain_files:
                with open(cf, 'r') as f:
                    chain_data += f.read() + "\n"
            if chain_data.strip():
                certificate_chain = chain_data
    except UnicodeDecodeError:
        console.print("[red]Error: Files appear to be binary. Make sure you're using PEM-formatted text files.[/red]")
        return
    except Exception as e:
        console.print(f"[red]Error reading files: {e}[/red]")
        return

    # Use the previously validated session to create an ACM client.
    acm_client = session.client('acm')

    # Import the certificate into AWS Certificate Manager.
    try:
        import_params = {
            'Certificate': certificate_body,
            'PrivateKey': private_key
        }
        if certificate_chain:
            import_params['CertificateChain'] = certificate_chain
            
        with console.status("[bold green]Importing certificate...[/bold green]", spinner="dots"):
            response = acm_client.import_certificate(**import_params)
            
        # Display the response in a table.
        table = Table(title="Certificate Import Result")
        table.add_column("Field", style="cyan", no_wrap=True)
        table.add_column("Value", style="magenta")
        certificate_arn = response.get("CertificateArn", "N/A")
        table.add_row("CertificateArn", certificate_arn)
        console.print(table)
        console.print("[green]Certificate successfully imported![/green]")
    except botocore.exceptions.ClientError as e:
        console.print(f"[red]AWS error: {e.response['Error']['Message']}[/red]")
    except Exception as e:
        console.print(f"[red]Failed to import certificate: {e}[/red]")

if __name__ == '__main__':
    main()

When viewed as a complete script, you can see how it builds a straightforward workflow for importing certificates. The code handles the entire process from credential validation to selecting files to performing the import, with appropriate error handling at each step.

The workflow follows a logical progression, leading the user through each step required for the certificate import.

Wrapping Up

This script demonstrates a practical approach to DevOps automation with Python. It uses a few well-chosen libraries to simplify a common AWS task.

Key aspects of the implementation include:

  1. Comprehensive error checking at each step
  2. Tab completion to reduce input errors and typing
  3. Formatted terminal output for better readability
  4. Pre-validation of AWS credentials

This represents practical DevOps automation: improving reliability while reducing manual overhead. It’s a small script, but it addresses a genuine workflow issue that many of us face when managing AWS resources.

If you find yourself repeatedly performing the same tasks in the AWS console, consider developing similar automation tools. Even modest scripts can significantly reduce the time spent on routine operations.

Have you automated any of your own AWS workflows? I’d love to hear about your experiences in the comments below!


P.S. If you’re wondering why the script doesn’t have command-line arguments using argparse or click, it’s a deliberate choice. For tools like this that require multiple inputs, an interactive prompt is often more user-friendly than having to remember a bunch of command-line flags. But that’s a topic for another post!

Built with Hugo
Theme Stack designed by Jimmy