Using AWS Lambda with Golang to Generate On-call Calendars

GOLANG
9 min read

We love developing in Go here at DoltHub. Our core product is DoltDB – an open-source, versioned, relational database that combines Git's distributed versioning features with all the power and expressiveness of a SQL database. Dolt is built from the ground up with Golang, and we've been very happy with the development experience as well as the efficient binaries we get from Golang.

Every three weeks or so, we put out a new blog in our Golang series. We often talk about nuances or sharp edges in the Go language, new features coming out in upcoming Go releases, or fun side projects we explore in Go, such as Swiss Map. In today's post, we're exploring another fun side-project we wrote in Go, this time to solve a pain point in our on-call experience. It's a quick project I hacked up one afternoon that uses AWS Lambda to run a Go function that queries our on-call schedule from AWS Incident Manager, and creates an Internet Calendaring and Scheduling (ICS) calendar feed that all our team can use to easily see who's on-call and who's coming up next in the rotation.

Golang Lambda On-call Calendar Generator

A few weeks ago, Dustin wrote about how we manage our on-call rotation using the AWS Incident Manager service. We've been pretty happy with the simplicity of that solution so far, but logging into the AWS Console just to see who's on-call, or when your next on-call shift is coming up, was a little annoying. So, I thought it would be a fun side project to whip up a little bit of Go code to run in an AWS Lambda function that would allow us to connect our calendars to a feed of the on-call schedule. If you're using AWS Incident Manager, this might be a useful function for you, too.

To pull this off, we just need to:

  • Create a Golang Lambda function
  • Retrieve the on-call schedule from the AWS SSM Contacts API
  • Turn that into an ICS formatted response

oncall calendar generator system diagram

Let's take a look at each of those steps in more detail...

Creating a Golang Lambda function

Javascript and Python may get the lion's share of attention for being used in AWS Lambda functions, but Golang is also a great choice. Go's fast performance, compiling down to a single binary, and ease of cross-compilation make it very easy to work with in AWS Lambda.

We use Terraform extensively to manage our infrastructure, but there are many other great Infrastructure as Code (IaC) choices for managing infrastructure – CDK, SAM, CloudFormation, etc. If you're still managing your cloud resources manually, by logging into the AWS Console and using the web UI, I hope you'll consider trying out an IaC tool instead!

Friends don't let friends manually create cloud resources

There's not much cloud infrastructure needed for this project, so the Terraform configuration is pretty minimal. Let's take a look at the major parts of our infrastructure configuration...

The first resource we need to configure is an execution role for our Lambda function to give our function permission to call the ssm-contacts:ListRotationShifts API. The policy below allows that, as well as access to a few other read-only APIs.

resource "aws_iam_policy" "ssm-contact-ics-export" {
  name        = "SSMContactICSExportPolicy"
  description = "Policy for lambda function used to expose our oncall rotation through an .ics endpoint."
  policy      = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowSSMContactsReadOnly",
            "Effect": "Allow",
            "Action": [
                "ssm-contacts:Describe*",
                "ssm-contacts:List*",
                "ssm-contacts:Get*"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
EOF
}

Next we need to define our Lambda function resource. Notice that we are using the provided.al2 custom runtime for our Golang function. There are several Lambda runtime choices available for running Golang Lambda functions, but provided.al2 is currently the best supported and the recommended option. The previous Go-specific runtime, Go 1.x, is being deprecated at the end of this year, so you don't want to use that. Plus, the provided.al2 custom runtime gets you support for ARM64 architecture (e.g. AWS Graviton2 processors), smaller binaries, and faster invoke times. The AWS Lambda Developer Guide has a nice section on Building Lambda functions with Go that goes into more detail if you're curious. Note also that we're building our Go binary for arm64 architecture, so we need to set the architecture param, otherwise it defaults to x86_64, which won't work with our binary. Finally, take a look at the environment section where we declare the ROTATION_ID_ARN environment variable. Our Go code will use this environment variable to determine what rotation in AWS Incident Manager we want to use to generate our calendar.

resource "aws_lambda_function" "dolthub-prod-ssm-contact-ics-function" {
  filename      = "dolthub-prod-ssm-contact-ics-function.zip"
  function_name = "dolthub-prod-ssm-contact-ics-function"
  role          = aws_iam_role.ssm-contact-ics-export.arn
  handler       = "bootstrap"
  architectures = ["arm64"]

  environment {
    variables = {
      ROTATION_ID_ARN = "<your-ARN-here>"
    }
  }

  # The filebase64sha256() function is available in Terraform 0.11.12 and later
  # For Terraform 0.11.11 and earlier, use the base64sha256() function and the file() function:
  # source_code_hash = "${base64sha256(file("lambda_function_payload.zip"))}"
  source_code_hash = filebase64sha256("dolthub-prod-ssm-contact-ics-function.zip")

  runtime = "provided.al2"
}

Since we want this Lambda function to be accessible from an HTTPS URL on the internet, we also create a resource for a Lambda function URL. Lambda function URLs were introduced about a year ago, and they're a really easy way to allow your Lambda functions to be triggered from HTTPS requests. Before Lambda function URLs were launched, this was possible by setting up an API Gateway to front your Lambda function, but there was a LOT more configuration involved. As you can see below, Lambda function URLs make this trivially easy:

resource "aws_lambda_function_url" "dolthub-prod-ssm-contact-ics-function-url" {
    function_name      = "${aws_lambda_function.dolthub-prod-ssm-contact-ics-function.function_name}"
    authorization_type = "NONE"
}

Before we deploy those Terraform changes, let's take a look at our function code to see how it interacts with the AWS Incident Manager API and how it creates the ICS feed.

Using the AWS Incident Manager API from Golang

The code in our Lambda function is extremely simple. We just need to call the ListRotationShifts API, which is part of the AWS Incident Manager Contacts service. The AWS SDK for Go v2 makes it extremely easy to call any AWS service, including AWS Incident Manager.

The first thing we need to do is construct a client with our credentials that we can use to call the AWS Incident Manager Contacts service. Note that we're using an init function to create our client so that we can share this client across multiple Lambda function invocations, which is more efficient than re-creating a new client for every single invocation of our function. The AWS SDKs will create connection pools, so reusing them where possible is recommended.

func init() {
	cfg, _ := config.LoadDefaultConfig(context.Background())
	client = ssmcontacts.NewFromConfig(cfg)
}

Next, inside our Lambda function, we have a function called loadOncallShifts that calls the ListRotationShifts API to get the on-call shifts from one week ago, for the new few months. Note that this API does send paginated results, but to keep it simple, we're only including the first page of results in the calendars we create. This is plenty of data to display at one time, but you could easily imagine adding pagination support. The AWS SDK for Go v2 even provides a handy paginator utility for the ListRotationShifts API.

func loadOncallShifts(ctx context.Context) ([]types.RotationShift, error) {
    rotationIdArn, ok := os.LookupEnv("ROTATION_ID_ARN")
    if !ok {
        return nil, fmt.Errorf("ROTATION_ID_ARN environment variable not set with the on-call rotation to query")
    }
    
    // Get the first page of results for the oncall schedule
    output, err := client.ListRotationShifts(ctx, &ssmcontacts.ListRotationShiftsInput{
        EndTime:    aws.Time(time.Now().Add(12 * weekDuration)),
        RotationId: aws.String(rotationIdArn),
        StartTime:  aws.Time(time.Now().Add(-1 * weekDuration)),
    })
    if err != nil {
        return nil, err
    }
    
    return output.RotationShifts, nil
}

Creating ICS Data

Now that we've got access to the upcoming on-call shifts, we can start building our calendar! One of the many things we love about developing in Golang is the Golang community and the ecosystem of libraries they have developed. There were a few libraries available for creating Internet Calendaring and Scheduling (ICS) files that can be easily consumed by most calendar apps, such as Mac Calendar, Outlook, or Google Calendar. I ended up going with github.com/arran4/golang-ical because it had some development activity this year, it has a decent stargazer count, and its documentation was easy to read.

I started off with the simple example of serializing an ICS file in their README. This was enough to get me pretty close to what I was looking for, so then it was just a matter of tweaking the generated calendar fields and plugging in the data from our on-call rotation shifts.

One place where I did hit a snag was the cal.SetMethod function. I started off with cal.SetMethod(ics.MethodRequest), as shown in the README's example of creating a calendar, but it was preventing my calendar client from finding multiple events in the same ICS file. After reading up a bit on the ICS data format, I learned that for an ICS file that publishes a complete set of events, it's better to use cal.SetMethod(ics.MethodPublish) and after that my calendar started correctly showing all the on-call events.

Another thing that I appreciated about this library was that it was super easy to turn events into all day events. For Mac Calendar, I prefer to see our on-calls shifts listed as all-day events at the top of the calendar, instead of stretching across the full calendar day and cluttering things up. arran4/golang-ical made this very easy – just change from event.SetStartsAt(time.Time) to event.SetAllDayStartsAt(time.Time).

func createOnCallCalendar(shifts []types.RotationShift) *ics.Calendar {
	cal := ics.NewCalendar()
	// MethodPublish should be used for ICS files that contain the full set of calendar events
	// https://stackoverflow.com/questions/28552946/ics-ical-publish-request-cancel
	cal.SetMethod(ics.MethodPublish)
	for _, shift := range shifts {
		// use the shift start time as the persistent, unique identifier for this event
		event := cal.AddEvent(fmt.Sprintf("%d", shift.StartTime.UTC().Unix()))

		// Create an all day event, instead of using the exact start/end time, so that
		// the event displays cleaner on calendars
		event.SetAllDayStartAt(*shift.StartTime)
		event.SetAllDayEndAt(*shift.EndTime)

		splits := strings.Split(shift.ContactIds[0], "/")
		oncall := strings.Title(splits[len(splits)-1])

		event.SetSummary("On-Call: " + oncall)
		event.SetDescription(fmt.Sprintf("%s is on-call for DoltHub", oncall))
	}

	return cal
}

The code to create the ICS calendar data turned out to be really simple. There are still a few tweaks I'd like to play with, such as looking at shift override information and including notes in each event about any on-call who was overridden. I'm also still relying on the deprecated strings.Title function and should really switch over to golang.org/x/text/cases.

Demo!

After we build our binary and deploy our Terraform changes, we're able to access the function URL for our Lambda function. We can then give that URL to our Calendar program and our Lambda function will generate an ICS file and send it back to our Calendar.

In Mac's Calendar app, open the File menu and select the New Calendar Subscription... menu item:

Open File -> New Calendar Subscription...

Then enter in the Lambda function URL in the dialog that pops up:

Then enter in your Lambda function URL

After we subscribe, Calendar will download the ICS data from our Lambda function and we can see our oncall schedule right in our calendar!

Mac Calendar now shows our oncall schedule

By default, Mac's Calendar app will automatically refresh this calendar subscription. You can click on the subscription settings to adjust the refresh rate. I turned mine to daily so that when teammates need to swap on-call shifts, the latest updates each day show up for me.

Conclusion

This was a really fun little side project to spin up, and it'll be super handy to simply switch over to my calendar app to quickly see who's currently on-call and when my next shift is. All in all, it only took me about a day to stitch this together and get it working (and write this blog post), thanks in large part to the existing support for running Golang code in AWS Lambda, arran4's great golang-ical ICS library, and the AWS SDK for Golang. If you want to dig in more, you can find the source code for this project on GitHub.

I hope this also helped convince you that Golang is a fantastic choice for using with AWS Lambda. Being able to cross compile so easily to other architectures and producing small, performant, and efficient binaries (without having to deal with dynamically linked libraries!!! 🙀) makes Golang a great fit for using with AWS Lambda.

Even though this didn't take much code to write, I'd still love to see AWS Incident Manager support this feature directly, so that we didn't have to run this Lambda function. If you're on the AWS Incident Manager team and see this, please consider this feature request! 🙏

Thanks for reading! If you have comments or questions about this project, please drop by the DoltHub Discord and say hello! Our entire dev team hangs out on Discord all day and we're always happy to talk about Golang, Git, and databases! 🤓

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.