Sending Text Messages with AWS and Pulumi

At our house, Thursday is video-game day. Every week, the kids get a little over a half-hour each to play whatever they want, usually on the SwitchMinecraft, Super Mario Odyssey, Donkey Kong, Harry Potter — and since that’s their one time slot (we don’t really do screens, otherwise), they really look forward to it. But when the day finally comes, they invariably come running to me for answers: Who goes first? And in what order?

I’m a programmer, so instinct took over: This was a job for SOFTWARE! I threw together a little Node.js script to handle the decision-making for me — importantly, absolving me of all responsibility for the outcome — that shuffled the kids in random order:

console.log(
    ["Oliver", "Sam", "Rosemary"] .sort(() => Math.random() > 0.5 ? -1 : 1)
);

And when Thursday morning came around, I’d just run what the kids came to know as “the random program”:

$ node random.js
[ 'Rosemary', 'Sam', 'Oliver' ]

And it worked! Problem solved — for them, at least.

I, on the other hand, remained unsettled. I didn’t like having to run this program by hand — finding my laptop, opening it, signing in, typing things … I mean it worked, but it was still somewhat tedious. It’d have been way more awesome if, say, the universe could just tell me what the order should be every week, without my having to ask it, as it were.

It turns out this sort of thing is surprisingly easy — provided you know a little JavaScript, and can find your way around the cloud. Specifically, I wanted to see if I could figure out how to use Pulumi (the open-source project I work on) and Amazon Web Services to send myself a little message once a week telling me what this critically important data should be — and of course, at the same time, impress my kids with my amazing cloud-automating prowess.

Here’s how I did it.

First, the Actual Working Program

If you aren’t yet familiar with Pulumi, you can learn more about it here, but in a nutshell, it lets you write programs in various languages that build and manage software that runs in the cloud. For this little task, I used Pulumi to stitch together a CloudWatch event, a function handler to respond to that event, and a Simple Notification Service (SNS) “topic” and “subscription” (more on these later) to handle transmitting the actual message. It all comes together in a single Pulumi program, which I ultimately wrote in TypeScript (because I love TypeScript), and have included below, in its entirety, sprinkled liberally with comments to help explain what’s going on:

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

// Pulumi provides built-in support for configuring your cloud progams. Here, I'm
// using just one required configuration item, my phone number, which I've applied
// as an encrypted secret, so I can check it into GitHub without exposing it publicly.
const config = new pulumi.Config();
const endpoint = `+1${config.require("to_number")}`;

// The names of some humans to be randomized.
const kids = ["Oliver", "Sam", "Rosemary"];

// A cron expression representing "every Thursday at 16:20 UTC".
const schedule = "cron(20 16 ? * THU *)";

// An AWS "Topic" and "Topic Subscription". SNS is a "pub-sub" service, so we publish
// events to a topic, then add subscribers to that topic -- here, just one: an SMS subscriber
// whose "endpoint" is simply my phone number.
const topic = new aws.sns.Topic("topic");
const sub = new aws.sns.TopicSubscription("sub", { protocol: "sms", endpoint, topic });

// And the event handler, which brings it all together.
aws.cloudwatch.onSchedule("schedule", schedule, async () => {

    // The text of the message.
    const message = `This week's game-playing order: ${kids.sort(() => Math.random() > 0.5 ? -1 : 1).join(", ")}. Yay! 🎉 🕹 👾`;

    // ... and SEND. Using the AWS JavaScript SDK, which Pulumi provides for us,
    // we publish a message to the topic we defined above, then handle the result
    // by logging to the console.
    const sns = new aws.sdk.SNS();
    await sns.publish(
        {
            Message: message,
            TopicArn: topic.arn.get(),
        },
        (error, data) => {
            if (error) {
                console.error(error.message);
            } else {
                console.log(data.MessageId);
            }
        }
    ).promise();
});

The full source of the program is available on GitHub, of course.

When I run this program, Pulumi creates several things for me, including a new stack (which is like an environment — here, one I’ve called dev, based on the default), an SNS topic and subscription (the publish-subscribe mechanisms I mentioned above), and a new CloudWatch event-rule subscription, which contains an AWS Lambda created implicitly (by Pulumi) for the callback I defined as the event handler in my program:

$ pulumi up

Updating (dev):

     Type                                          Name                    Status
 +   pulumi:pulumi:Stack                           the-random-program-dev  created
 +   ├─ aws:cloudwatch:EventRuleEventSubscription  schedule                created
 +   │  ├─ aws:cloudwatch:EventRule                schedule                created
 +   │  ├─ aws:iam:Role                            schedule                created
 +   │  ├─ aws:iam:RolePolicyAttachment            schedule-32be53a2       created
 +   │  ├─ aws:lambda:Function                     schedule                created
 +   │  ├─ aws:lambda:Permission                   schedule                created
 +   │  └─ aws:cloudwatch:EventTarget              schedule                created
 +   ├─ aws:sns:Topic                              topic                   created
 +   └─ aws:sns:TopicSubscription                  sub                     created

Resources:
    + 10 created

Duration: 25s

And then this morning — finally, after days of anticipation — the satisfying result*:

I won’t dive into the details of getting up and running with Pulumi; we have a great getting-started guide that does that already. But I will run through the experience of putting this together, since for me, being somewhat new to more modern patterns in cloud automation, the hardest part wasn’t so much writing the code as knowing what code to write.

Starting from Scratch

To be honest, I had no idea how I’d do this when I got started — I just figured it should be doable, somehow. As a full-stack developer, I’d been working with AWS for several years, but the bulk of my experience had been with the more traditional stuff: virtual machines on EC2, S3 and CloudFront for serving static websites, RDS databases, and so on. Going in, I thought CloudWatch was just some monitoring thing; I had no idea it could be used as an event scheduler that could be wired up to cloud functions, nor that SNS (which I’d heard of, but never touched) could send text messages all by itself — at least not without some sort of up-front, manual phone-number registration process or something. Indeed, the first thing I reached for was Twilio, since I knew I could send messages with that; it wasn’t until I decided to try Googling for “aws sms” that I saw what I was looking for:

With Amazon SNS, you can send SMS (text) messages to 200+ countries and for an expanded set of use-cases such as Multi-Factor Authentication (MFA) and One Time Passwords (OTP)…

Whoa, nice! Just what I needed. But how to actually make it work?

Enter Pulumi

The pulumi/pulumi README actually contains an example of how to create and schedule a cloud “event” and associate a handler for it using only the @pulumi/aws module. It’s surprisingly easy, and also sort of magical; for example, to log something to the console once a minute, it’s just:

import * as aws from "@pulumi/aws";

aws.cloudwatch.onSchedule("logger", "rate(1 minute)", () => {
    console.log(`Hello! 👋 It's now ${new Date().toString()}`);
});

Running this program with pulumi up, then tailing the CloudWatch logs with pulumi logs -f (which, by the way, is pretty magical in its own right), we get:

$ pulumi up --yes --skip-preview && pulumi logs -f

Collecting logs for stack dev since 2019-09-05T13:30:50.000-07:00.
 2019-09-05T14:31:01.403-07:00[logger] Hello! 👋 It's now Thu Sep 05 2019 21:31:01 GMT+0000 (UTC)
 2019-09-05T14:32:01.054-07:00[logger] Hello! 👋 It's now Thu Sep 05 2019 21:32:01 GMT+0000 (UTC)

Neat! So that part worked. And I figured since AWS SNS “can send SMS messages to 200+ countries”, I should be able to drop a line or two into that same handler to send a message and be done — right?

Well, yes! Technically, anyway.

What tripped me up (and only for a bit — but a frustrating bit nonetheless) was simply not knowing how to express what I wanted to do using terms that matched the ones used by the cloud provider. As a JavaScript developer, I totally get event-driven, and I get pub-sub, but what I did not understand was what a topic was — or for that matter, once I’d created one of these topics and set up a subscriber for it (I’d found several examples of that, too), how to send an actual message somehow using that topic and subscriber. I knew this had to be simple; I just didn’t know how to look for what I was looking for — how to articulate the questions in a way that I could search for.

After some Googling, I stumbled upon the AWS CLI reference for SNS, which held an important clue. Here, for example, was how to send a text message using the AWS CLI, provided you’d created a topic and subscriber in advance (which by this time, I had):

aws sns publish --topic-arn "the-topic-ID-assigned-by-AWS" --message "Hello, world!"

That was it: I needed to publish (important word) to the topic, and the rest would take care of itself. I tested this with the CLI, and indeed it totally worked, so I knew I was close. All I had to do now was figure out how to express that in my program.

I expected to find maybe a publish method somewhere in the aws.sns namespace of the Pulumi AWS SDK — but alas, ‘twas not the case:

After a bit more poking around, I ran across the AWS JavaScript SDK, which indeed defined a publish method — and that’s when I realized what I’d been missing: in order to make calls on AWS services at runtime (as opposed to build-time, or provisioning-time; i.e., in my handler), I’d use the AWS SDK, and since Pulumi happened to provide that as well, a bit more hunting with VS Code helped me find what I needed:

YES. At that point, it really was just a matter of calling sns.publish(), passing in the message I wanted to send (with appropriately celebratory emojis, of course) and the topic to which to publish, available using the arn property of the topic variable I’d created for the topic itself:

...
const topic = new aws.sns.Topic("topic");
...

aws.cloudwatch.onSchedule("schedule", schedule, async () => {
    ...
    await sns.publish({
        Message: "This week's game-playing order...",
        TopicArn: topic.arn.get(),
    });
    ...
});

And that was it — a complete cloud program in a dozen lines of code, and no more confusion over who plays when. Problem solved, for reals this time.

Why This Stuff Is So Intersting to Me, and Why You Might Care, Too

What I love about all this, and why I decided to spend some time writing about it (and I’m sure I’ll write more in the weeks ahead), is not this particular solution: this is some pretty trivial stuff, after all, on the scale of Human Problems Yet to Be Solved with the Cloud. What I love is how this tiny bit of code expresses all that it ultimately does. I mean, just look at it:

const topic = new aws.sns.Topic("topic");
const sub = new aws.sns.TopicSubscription("sub", { protocol: "sms", endpoint, topic });

aws.cloudwatch.onSchedule("schedule", "cron(20 16 ? * THU *)", async () => {
    const sns = new aws.sdk.SNS();

    await sns
        .publish(
            { Message: "Hello, world!", TopicArn: topic.arn.get() },
            (err, data) => console.log(err || data)
        )
        .promise();
});

This is ten lines of code that orchestrates as many cloud resources, blends in some runtime logic, and does it all in a single JavaScript program: “When this, do this.” No separation between infrastructure and code, but infrastructure with code, and in an event-driven idiom that, as a JavaScript developer, is almost baked into my bones at this point. That we can do this now just amazes me, and it’s really why I chose to join Pulumi in the first place.

Stay tuned for more! And have fun storming the castle. 👋


* The astute reader will have noted that this particular message arrived a little over an hour late. After the first few minutes of waiting, and with completely empty logs, I went into the AWS console, and found that my cron expression was wrong: I’d written cron(16 20), which ended up translating as 20:16 UTC. I’d gotten it backwards.