Automated Ledger Summaries By Email

A long time ago I wrote about my method of book-keeping using Ledger CLI. Nearly 10 years later, I'm still using it track my finances down to the penny. It's an amazing tool, and has helped me identify problematic spending habits. But it does have one achilles heel, it's not very friendly to non-technical people.

I share my finances with my family because I want to teach financial literacy and help them feel like they are a part of any decision-making activities related to money. Given that Ledger is text-based, it means they don't need to install any programs. But because it is text-based, it means they need to learn to use a text editor and the terminal (I could import it into things like Gnu Cash, but I'm not going down that rabbit hole). To improve this accessibility problem, I devised an automated notification scheme whereby once I finish updating the ledger, a summary is emailed to everyone. Here's how I did it.

Everything I wrote is using AWS, and fits well within the free tier. Do your own research to verify whether your usage of this solution fits within the free tier before starting.

The workflow is this:

  1. I make changes to the ledger.
  2. I commit the results to the git repository.
  3. I push the git repository to AWS CodeCommit.
  4. CodeCommit sends the push event to EventBridge.
  5. EventBridge Rule matches the CodeCommit push event and starts the CodeBuild project.
  6. CodeBuild runs
  7. CodeBuild downloads the contents of the ledger's Git repository
  8. CodeBuild generates a summary
  9. CodeBuild emails the summary to the family email address.
  10. CodeBuild writes the logs to a CloudWatch Logs group.

All of this infrastructure is created in CloudFormation to simplify the provisioning and also to cut down on the number of services needed (this could easily be translated into Terraform, but now you need to work with Terraform).

The CloudFormation stack looks like this.

	---
	AWSTemplateFormatVersion: 2010-09-09
	Description: >
	  Provisions the ledger repository and automated email summaries.
	
	Resources:
	  Repository:
	    Type: "AWS::CodeCommit::Repository"
	    Properties:
	      RepositoryDescription: >
	        Financial Ledger
	      RepositoryName: "financial-ledger"
	
	  SummaryBuild:
	    Type: "AWS::CodeBuild::Project"
	    Properties:
	      Description: >
	        Emails a summary of the ledger whenever the repository
	        gets a push to master.
	      Artifacts:
	        Type: NO_ARTIFACTS
	      Environment:
	        ComputeType: BUILD_GENERAL1_SMALL
	        Image: aws/codebuild/standard:5.0
	        ImagePullCredentialsType: CODEBUILD
	        Type: LINUX_CONTAINER
	      ServiceRole: !GetAtt CodeBuildRole.Arn
	      Source:
	        Type: "CODECOMMIT"
	        Location: !GetAtt Repository.CloneUrlHttp
	        GitCloneDepth: 1
	      SourceVersion: "refs/heads/master"
	      Cache:
	        Type: "NO_CACHE"
	      TimeoutInMinutes: 10
	      QueuedTimeoutInMinutes: 60
	      LogsConfig:
	        CloudWatchLogs:
	          Status: "ENABLED"
	          GroupName: !Ref CodeBuildLogGroup
	          StreamName: !GetAtt Repository.Name
	
	  CodeBuildLogGroup:
	    Type: AWS::Logs::LogGroup
	    Properties:
	      RetentionInDays: 5
	
	  CodeBuildRole:
	    Type: AWS::IAM::Role
	    Properties:
	      Description: >
	        Provides the CodeBuild Project with the permissions it needs.
	      Path: "/service-role/"
	      AssumeRolePolicyDocument:
	        Version: 2012-10-17
	        Statement:
	          Effect: Allow
	          Principal:
	            Service: codebuild.amazonaws.com
	          Action: sts:AssumeRole
	
	  AllowLoggingPolicy:
	    Type: AWS::IAM::Policy
	    Properties:
	      PolicyName: 'allow-sending-logs'
	      PolicyDocument:
	        Version: 2012-10-17
	        Statement:
	          - Effect: Allow
	            Action:
	              - logs:CreateLogStream
	              - logs:PutLogEvents
	            Resource:
	              - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${CodeBuildLogGroup}"
	              - !GetAtt CodeBuildLogGroup.Arn
	      Roles:
	        - !Ref CodeBuildRole
	
	  AllowRepoClonePolicy:
	    Type: AWS::IAM::Policy
	    Properties:
	      PolicyName: 'allow-cloning-repository'
	      PolicyDocument:
	        Version: 2012-10-17
	        Statement:
	          - Effect: Allow
	            Action: codecommit:GitPull
	            Resource: !GetAtt Repository.Arn
	      Roles:
	        - !Ref CodeBuildRole
	
	  AllowSendingEmailPolicy:
	    Type: AWS::IAM::Policy
	    Properties:
	      PolicyName: 'allow-send-email'
	      PolicyDocument:
	        Version: 2012-10-17
	        Statement:
	          - Effect: Allow
	            Action: ses:SendEmail
	            Resource: !Sub "arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/family@example.com"
	      Roles:
	        - !Ref CodeBuildRole
	
	  EventPermission:
	    Type: AWS::IAM::Role
	    Properties:
	      Description: >
	        Provides Event rules the permission to start specific code build
	        projects.
	      Path: "/service-role/"
	      AssumeRolePolicyDocument:
	        Version: 2012-10-17
	        Statement:
	          Effect: Allow
	          Principal:
	            Service: events.amazonaws.com
	          Action: sts:AssumeRole
	
	  StartBuildPolicy:
	    Type: AWS::IAM::Policy
	    Properties:
	      PolicyName: 'allow-starting-build'
	      PolicyDocument:
	        Version: 2012-10-17
	        Statement:
	          - Effect: Allow
	            Action: codebuild:StartBuild
	            Resource: !GetAtt SummaryBuild.Arn
	      Roles:
	        - !Ref EventPermission
	
	  EventRule:
	    Type: AWS::Events::Rule
	    Properties:
	      Description: >
	        Watches for changes to the financial-ledgers repository
	        and starts a CodeBuild project to email a summary report.
	      State: ENABLED
	      Targets:
	        - Id: codebuild-project
	          Arn: !GetAtt SummaryBuild.Arn
	          RoleArn: !GetAtt EventPermission.Arn
	      RoleArn: !GetAtt EventPermission.Arn
	      EventPattern:
	        source:
	          - "aws.codecommit"
	        detail-type:
	          - "CodeCommit Repository State Change"
	        resources:
	          - !GetAtt Repository.Arn
	        detail:
	          event:
	            - "referenceCreated"
	            - "referenceUpdated"
	          referenceType:
	            - "branch"
	          referenceName:
	            - "master"
	
	Outputs:
	  CloneUrlSsh:
	    Value:  !GetAtt Repository.CloneUrlSsh
	    Description: >
	      URL used to clone the git repository over SSH.
	    Export:
	      Name: "codecommit:repository:ledger:personal"
	  SummaryBuildName:
	    Value: !Ref SummaryBuild
	    Description: >
	      Name of the CodeBuild project that sends summary emails.

Let's examine each resource in detail.

AWS::CodeCommit::Repository

This is the Git repository, and it only requires a name to get things started. Everything else can be left as a default. Why not use GitHub? Because these are financial ledgers and I want to feel secure knowing that (a) the repository is encrypted with an encryption key known only to me, (b) it is stored in a region of my choosing, and (c) it is not accessible by anyone else (AWS being the exception, but they have controls in place to prevent this access).

I also chose CodeCommit because it integrates nicely into the AWS ecosystem, and I'm exploiting this fact to get automation working seamlessly.

AWS::CodeBuild::Project

This is where the majority of the automation lives for working with Ledger. We are ~~abusing~~ extending the CodeBuild service to act as a CI workflow. In essence, we aren't "building" or compiling anything, but rather we need a serverless tool that follows a set of procedures a step-by-step until it finishes, then it cleans up after itself.

CodePipeline, which is a CI service, is not used here because CodeBuild does what it needs to do, and that thing is "just run this script". If the automation required more logic, then I would look at using CodePipeline. As it is, CodeBuild does exactly what we need for this simple automation.

CodeBuild is also useful because it hooks into the event bus underlying all AWS services. Thus, we can write some extra notification and automation logic into the CloudFormation stack to ensure that the CodeBuild project we've defined will start only when we want.

The CodeBuild project will implicitly look for a BuildSpec (the set of steps to follow) in its project configuration or, by default, in the CodeCommit repository in a file named buildspec.yml. i'll examine the contents of this file later.

The rest of the project configuration is ensuring we're using the right Docker container, and giving it the right amount of timeouts (so it doesn't incur cost overruns).

AWS::Logs::LogGroup`

A custom CloudWatch Logs group is created that will store the build logs from CodeBuild. By creating this ourselves, we can control how long the logs are stored for. Since the log data isn't sensitive, I am not encrypting it with a customer-managed KMS key, but the logs are encrypted with an AWS-managed key.

We need this log in the event that the build fails. It will explain what happened within the CodeBuild project. For 99% of the time, we don't need to look at it, so the data is retained for a very short period of time.

AWS::IAM::Role for the CodeBuild Project

An IAM role is needed for the CodeBuild project to access other AWS services within the ecosystem. From here, several IAM policies will be attached to it, providing it with the least privilege permissions to get its job done.

These policies are not added inline to the IAM role simply because it looks nicer to abstract them into separate resources.

AWS::IAM::Policy for the CodeBuild IAM Role

These are the permissions attached to the IAM role used by CodeBuild. They allow the CodeBuild project to do everything is needs to do to complete the Buildspec, plus report on its progress.

Thus, allowing logging to a CloudWatch Logs group is necessary. Plus retrieving the project's source code from CodeCommit. And lastly the ability to send emails via the AWS SES service.

I should note here that this CloudFormation stack relies on the email address used in the project to be verified within AWS SES first. This happens once, outside of the CloudFormation stack. If it is not verified, an error is produced within the CodeBuild project's logs and the project will fail.

AWS::IAM::Role for the EventPermission

Another IAM role is required in this infrastructure, this time to hold the permissions for the EventBridge Event Rule to invoke the CodeBuild project. The policies for the IAM role are specified in their own resource block.

AWS::IAM::Policy for the EventPermission

This IAM policy is attached to the EventPermission IAM role, and it grants the permission for the EventBridge Event Rule to invoke the CodeBuild project. If this isn't present, EventBridge will silently fail and the CodeBuild project won't start. I really wish EventBridge had the concept of an invocation log to hold the failures. Otherwise, you just have to know what's happening and acts as a shibboleth between those that have run event-drive infrastructure and those that haven't.

AWS::Events::Rule

This is the rule that pattern matches the event within EventBridge coming from CodeCommit, and then passes onto CodeBuild. In effect, this is the serverless "glue" that binds the event-based automation between the two services.

Outputs Section

The stack outputs some variables that will be useful for Makefile automation, especially since many of the names are automatically generated by CloudFormaiton (i.e. not guessable). The CodeCommit repository name is exported as a specific variable, for even simpler discovery. This means any script can auto-discover what outputs are in the stack by running the command aws cloudformation list-exports.

Now that the CloudFormation is ready, the CodeBuild project needs the Buildspec to tell the project what steps to run within the CodeBuild docker container. I put the buildspec.yml file inside the CodeCommit repository, simply because I want to be able to change the build specification more often than I want to change the infrastructure. This is one of those "six of one, half-dozen of the other" situations and you just have to choose one and go with it.

The buildspec.yml for CodeBuild looks like this.

	---
	version: 0.2

	env:
	  variables:
	    EMAIL_FROM: family@example.com
	    EMAIL_TO: family@example.com
	    EMAIL_SUBJECT: Financial Summary

	phases:
	  pre_build:
	    commands:
	      - apt update -qq
	      - apt install ledger -y
	  build:
	    on-failure: ABORT
	    commands:
	      - ledger -f financial.ledger equity -e "$(date +%Y/%m/%d)" > summary.txt
	  post_build:
	    commands:
	      - aws ses send-email --from "$EMAIL_FROM" --to "$EMAIL_TO" --subject "$EMAIL_SUBJECT" --text file://summary.txt --region $AWS_DEFAULT_REGION

Let's dive into what is happening here by focusing on the top-level attributes.

env

Some project-specific environment variables are setup in order to reduce the duplication of values in the upcoming phases. Otherwise, making changes would require hunting for all instances of the value, getting it wrong, and then iterating toward perfection. Instead, use an env variable and be happy. Your future self will thank you for putting the important configuration items at the top of the build specification file.

The environment variables being setup are where the email is going, who it is from, and what the email should be called.

phases

This is where the meat of the CodeBuild project is. Recall that CodeBuild is meant for CI processes, but it is being extended here to be a serverless runner of things. The phases are broken up into specific goals I want to attain during the project. That's because each phase is logically separated from the other: gather dependencies, process the ledger, email the result.

The pre_build section installs all the various applications we need in the standard Linux container, namely the latest version of Ledger CLI.

The build section runs the ledger command on the current year's ledger file and uses the equity command to display the account balances as entries. It ensures that no future transactions are part of the display by filtering out anything after the date when the project is run. The standard output is saved into a summary.txt file. This is the file that is being emailed.

The post_build section has one job. Send an email to the appropriate email address and include the contents of the summary.txt file in the body of the email.

When the last phase completely successfully, the project signals that it has completed successful and destroys itself and any artifacts that was kept in the Docker container. The logs are kept for several days, but then those are deleted automatically too. There is basically no maintenance needed in this infrastructure.


I hope you enjoy this automation and how it can improve visibility in your finances to everyone. You are letting computers do most of the leg work for you, all for free, securely, and with little to no artifacts laying around. More importantly, you get to focus on the important part, which is keeping your ledger up to date.