Checking Savings Bond Values with F#, Docker, and Azure

When I was growing up some of my family insisted on giving me savings bonds for things like Christmas and my birthday, in what I assume is was a frugal way to teach me about very (very) delayed gratification. I still have a bunch, but the value isn't particularly high and the process of checking their value is tedious, so I generally only end up checking it maybe every five years or so.

I figured it was about time to check again, but this time I wanted to think about what I could automate. The idea: read in a list of bonds from a CSV file, automatically enter those into the website for checking their value, and report their total value to me. Even having to remember to run this manually seems a bit tedious, so I also want it to automatically run every month and email me the results.

The App

For the app itself I decided to do it as an F# console app, and take advantage of canopy, a lovely library for automated browser testing. Using FSharp.Data's CSV type provider, I defined a simple time to match what's in the data file:

type Bonds = CsvProvider<HasHeaders = false, Schema = "SerialNumber(string),IssueDate(string)">

Next, I'll start running Chrome and browse to the site:

start chrome
url "https://www.treasurydirect.gov/BC/SBCPrice"

Now I want to enter each bond into the form on the page and submit it. Canopy makes this really easy and consise:

Bonds.Load("c:\\bonds.csv").Rows
    |> Seq.iter (fun bond ->
        let denomination = match bond.SerialNumber with
                           | serial when serial.StartsWith("L") -> 50
                           | serial when serial.StartsWith("C") -> 100
                           | _ -> failwith "Unknown bond denomination"

        "select[name=Denomination]" << string denomination
        "input[name=SerialNumber]" << bond.SerialNumber
        "input[name=IssueDate]" << bond.IssueDate
        click "input[name='btnAdd.x']"
)

With just those few lines of code, all the bonds will have been entered into the site and the totals are now available to read out:

let totals = elements "table#ta1 tr:nth-child(2) td"
let lines = seq {
    yield sprintf "Total Value: %s" totals.[1].Text
    yield sprintf "Total Price Paid: %s" totals.[0].Text
    yield sprintf "Total Interest: %s" totals.[2].Text
    yield sprintf "YTD Interest: %s" totals.[3].Text
} 
let content = lines |> String.concat "<br />"

For now this will just be a boring little report, with these four items printed on separate lines. Finally, all we need to do is send the email and quit the browser. To send the email I'm using a SendGrid account:

let client = SendGridClient(Environment.GetEnvironmentVariable "SendGridApiKey")
let emailAddress = EmailAddress("greg@gregshackles.com", "Savings Bond Calculator")

MailHelper.CreateSingleEmail(emailAddress, emailAddress, "Savings Bond Values", content, content)
|> client.SendEmailAsync
|> Async.AwaitTask
|> Async.Ignore
|> Async.RunSynchronously

quit()

That's the entire app! Running it successfully reminds me how little my stack of bonds is worth. I did say I wanted to run this on a schedule, though, so let's kick things up a notch.

Dockerizing the App

In order to run this easily on a machine other than my own, I decided to go ahead and create a Docker image for it that I can run in Azure. Here's my Dockerfile:

FROM microsoft/windowsservercore
COPY bin/Release/ /

ADD http://chromedriver.storage.googleapis.com/2.35/chromedriver_win32.zip /
ADD https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi /

RUN msiexec /i googlechromestandaloneenterprise64.msi /quiet

ENTRYPOINT BondCalculator.exe

I create a Windows image, copy over the compiled app, install Chrome and the Chrome web driver, and then finally run the task. Once the app finishes the image will terminate, since there's no reason to keep anything running most of the time for this.

I also threw together a little Powershell script to build and create the image:

Invoke-Expression "msbuild /p:VisualStudioVersion=14.0 /p:Configuration=Release"

Copy-Item C:\bonds.csv bin\Release\bonds.csv

Invoke-Expression "docker build -t bond-calculator ."

Once that runs we now have a ginormous 11.2GB image for the app:

> docker images --format "{{.Repository}}: {{.Size}}"
bond-calculator: 11.2GB

And now we can run the app with docker run:

docker run --env SendGridApiKey=<key> bond-calculator

Pushing to Azure

I have a private container registry in Azure that I can use to store my containers, so I need to push this image out to that:

docker tag bond-calculator myregistry.azurecr.io/bond-calculator
docker push myregistry.azurecr.io/bond-calculator

With that pushed, I can now use the Azure CLI to create a new container instance to actually run it in Azure:

az container create `
    --resource-group mygroup `
    --name bond-calculator `
    --image myregistry.azurecr.io/bond-calculator:latest `
    --cpu 1 --memory 1 `
    --registry-password topsecret `
    --restart-policy OnFailure `
    --os-type Windows `
    --environment-variables SendGridApiKey=<key>

This creates a new container with 1 CPU and 1GB of memory and runs my app on it. Sure enough, a few minutes later a new email report shows up in my inbox! Using the Azure CLI again we can look up more details about the container:

> az container show --resource-group gshackles --name bond-calculator
...
        "currentState": {
          "detailStatus": "Completed",
          "exitCode": 0,
          "finishTime": "2018-01-14T16:34:53+00:00",
          "startTime": "2018-01-14T16:34:15+00:00",
          "state": "Terminated"
        },
...

The container ran for 38 seconds, so that's all I'm going to have to pay for. I think I can live with that.

Running On A Schedule

Now that I've got it running in Azure, I want to get it running on a schedule so that I don't need to actually trigger it myself. At first I figured I'd just write up a quick Azure Function to do it, but that didn't pan out so well. I had planned to just write a little Powershell function that executes monthly and issues the Powershell equivalent of that az container create command above, but it turns out that the AzureRM modules loaded in the function host are pretty old, and don't yet include the cmdlets for container instances.

After toying around with a few different hacky ideas, I realized that Logic Apps actually have support for doing just what I need. I put together a simple little workflow that triggers monthly and creates a container instance with the same parameters as earlier:

Just to make sure it all still worked, I triggered a run for the logic app and sure enough, a new email arrived a little while after:


Was this all overkill for what I actually needed here? Almost certainly! In the end, it was a fun exercise to put all these pieces together like this, and turned out to be a pretty great option for these types of scenarios where I need something running on a schedule and it can't be supported by Azure Functions.

For anyone interested, the code for this app is all available on GitHub.