[Pulumi Custom Resource] AWS SNS E-Mail Subscription
Tags: pulumi, aws, infrastructureascode, serverless
Published: 2020-09-12 20:19:14

{% collapsible Used Versions %}
```json
{
  "dependencies: {
    "@pulumi/aws": "3.2.1",
    "@pulumi/pulumi": "2.9.2",
    "aws-sdk": "2.750.0",
    "netlify": "4.3.13"
  }
}
```

```bash
# CLI
pulumi version # => 2.7.1
# &
pulumi version # => 2.10.0
```
{% endcollapsible %}

## Problem
While working on some infrastructure I finally decided to add a CloudWatch billing alarm and thought e-mail would be the simplest and most effective in my current situation. That's when I had to face the following Intellisense info

> The protocol to use. The possible values for this are: sqs, sms, lambda, application. (http or https are partially supported, see below) (email is an option but is unsupported, see below).

As described in Terraform's docs:

> These [email & email-json] are unsupported because the endpoint needs to be authorized and does not generate an ARN until the target email address has been validated. This breaks the Terraform model and as a result are not currently supported.

which you can find under [protocols supported](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic_subscription#protocols-supported) and I stumbled upon it in [this Pulumi issue](https://github.com/pulumi/pulumi/issues/1660#issuecomment-408213388) because Pulumi uses Terraform's code in many cases to build their providers.

## Possible Workarounds

While in the [aforementioned issue](https://github.com/pulumi/pulumi/issues/1660#issuecomment-408213388) Luke Hoban (CTO Pulumi) suggested polling, I decided to "just" wait a pre-set amount of time like 30 or 60 seconds.

This works pretty good, but if you, like me, have your phone on hand while running `pulumi up` you can confirm in a matter of seconds, then wait the rest of the time and we all know how that ends.. scrolling Twitter, or Instagram 😅

So to flex my muscles (🧠 & 👆) I decided to turn it into the suggested polling. I added some delay in-between and a `maxRetry` option. 🤓

While writing this article I couldn't resist playing more with my code, realizing that all of this isn't needed at all. Contrary to the quoted statement from the Terraform docs and thanks to `ReturnSubscriptionArn` which I've set to `true`. The `aws-sdk` will just return the `ARN` of the subscription, which is all we need to be able to remove the subscription later on during `pulumi destroy`. I tried it various times now and it doesn't really matter when I click the link in the mail to confirm the subscription. Pulumi just proceeds happily and I don't have to wait any longer than absolutely necessary 😍 🎉
I've to admit that this feature is probably newer than the quote and no-one stumbled upon this so far 🤷‍♂️

> You can't `destroy` the stack before the subscription has been confirmed, though!

I have to say I learned a few things on the line, as probably most of the time, if not always 😁

## How the actual usage looks like

```ts
// index.ts
// ... pulumi imports
import {SnsEmailSubscription} from "./sns-email-subscription";

const billingTopic = new aws.sns.Topic("topic");
const emailSubscription = new SnsEmailSubscription("subscription", {
  topic: billingTopic.arn,
  endpoint: cfg.billingAlarmEmail,
  region: cfg.region,
  waitMs: 10000,
  validationMethod: "poll", // or "sleep" or undefined to not wait
});
```

## Complete Code

```ts
// sns-email-subscription.ts
import * as pulumi from "@pulumi/pulumi";
import {SNS} from "aws-sdk";
import isEmail from "validator/lib/isEmail";
import {
  CheckFailure,
  CheckResult,
  CreateResult,
  DiffResult,
  ReadResult,
  Resource,
  ResourceProvider,
} from "@pulumi/pulumi/dynamic";

type ValidationMethod = "sleep" | "poll" | undefined;

export type SnsInputs = {
  topic: string | pulumi.Input<string>;
  endpoint: string | pulumi.Input<string>;
  region: string | pulumi.Input<string>;
  validationMethod?: ValidationMethod | pulumi.Input<ValidationMethod>;
  waitMs?: number | pulumi.Input<number>;
  maxRetry?: number | pulumi.Input<number>;
};

type ProviderInputs = {
  topic: string;
  endpoint: string;
  region: string;
  validationMethod?: ValidationMethod;
  waitMs?: number;
  maxRetry?: number;
};

// https://gist.github.com/joepie91/2664c85a744e6bd0629c
// sometimes I'm just to lazy to think about certain things so I just search 🤷‍♂️
const sleep = (duration: number) =>
  new Promise((resolve) => setTimeout(resolve, duration));

const subscriptionValidation = async (
  id: string,
  props: ProviderInputs,
): Promise<boolean> => {
  const maxRetry = props.maxRetry ?? 5;
  const sns = new SNS({apiVersion: "2010-03-31", region: props.region});
  let confirmed = false;
  let intents = 0;
  while (intents < maxRetry && !confirmed) {
    intents++;
    await sleep(props.waitMs ?? 10000);
    const request = sns.getSubscriptionAttributes({SubscriptionArn: id});
    const response = await request.promise();
    // note that AWS is returning strings here
    if (response?.Attributes?.PendingConfirmation === "false") {
      confirmed = true;
    }
  }
  return true;
};

const snsEmailSubscriptionProvider: ResourceProvider = {
  async check(
    olds: ProviderInputs,
    news: ProviderInputs,
  ): Promise<CheckResult> {
    const failures: CheckFailure[] = [];

    if (!news.topic) {
      failures.push({
        property: "topic",
        reason: "Please provide a valid SNS Topic ARN",
      });
    }

    // ensuring it's a string to provide our own error message
    if (!isEmail(news.endpoint || "")) {
      failures.push({
        property: "endpoint",
        reason: "Please provide a valid email address",
      });
    }

    if (!news.region) {
      failures.push({
        property: "region",
        reason: "Please provide the AWS region of the corresponding SNS Topic",
      });
    }

    if (news.maxRetry !== undefined && news.maxRetry < 5) {
      failures.push({
        property: "maxRetry",
        reason: "Please provide a number greater than 4",
      });
    }

    if (news.waitMs !== undefined && news.waitMs < 1000) {
      failures.push({
        property: "waitMs",
        reason: "Please provide a number >= 1000",
      });
    }

    if (!["sleep", "poll", undefined].includes(news.validationMethod)) {
      failures.push({
        property: "validationMethod",
        reason: `Has to be one of "sleep" | "poll" | undefined`,
      });
    }

    if (failures.length > 0) {
      return {failures: failures};
    }

    return {inputs: news};
  },

  async create(props: ProviderInputs): Promise<CreateResult> {
    const sns = new SNS({apiVersion: "2010-03-31", region: props.region});
    const {topic, endpoint} = props;

    const params: SNS.SubscribeInput = {
      Protocol: "email",
      TopicArn: topic,
      Endpoint: endpoint,
      // That's the reason why it shouldn't be necessary to wait
      // as AWS provides us already the ARN if it's needed at all
      // note that the resource returns the subscription ARN as `id`
      ReturnSubscriptionArn: true,
    };

    try {
      const subscription = sns.subscribe(params);
      const response = await subscription.promise();
      const subscriptionArn = response.SubscriptionArn;

      if (!subscriptionArn) {
        throw new Error("Missing subscriptionArn");
      }

      if (props.validationMethod === "sleep") {
        await sleep(props.waitMs ?? 60 * 1000);
      } else if (props.validationMethod === "poll") {
        await subscriptionValidation(subscriptionArn, props);
      }

      return {id: subscriptionArn, outs: props};
    } catch (error) {
      console.log(error.message);
      throw error;
    }
  },

  // diff will be used to determine if the resource needs to be changed
  // it allows to decide between an update or a full replacement
  // in this case, as SNS Subscriptions can't be replaced as far as I know
  // we force it to replace if any of the properties has changed
  async diff(
    id: string,
    olds: ProviderInputs,
    news: ProviderInputs,
  ): Promise<DiffResult> {
    const replaces: string[] = [];
    let changes = false;
    let deleteBeforeReplace = false;

    if (
      olds.topic !== news.topic ||
      olds.endpoint !== news.endpoint ||
      olds.region !== news.region
    ) {
      changes = true;
      // we could decide to delete the old subscription before creating the new one
      // from my testing it doesn't seem necessary
      // so I'll let Pulumi delete it after the new one has been created
      // deleteBeforeReplace = true;

      // For simplicity I just push all properties into the replaces array
      // The replaces array is what forces Pulumi to replace instead of update
      replaces.push(...Object.keys(news));
    }

    return {changes, replaces, deleteBeforeReplace};
  },

  async delete(id: string, outs: ProviderInputs) {
    const sns = new SNS({apiVersion: "2010-03-31", region: outs.region});
    const params: SNS.UnsubscribeInput = {SubscriptionArn: id};
    const subscription = sns.unsubscribe(params);
    await subscription.promise();
  },

  // I'm actually not sure when exactly this method will be called
  // or what it's used for 🤷‍♂️ 😅
  async read(id: string, props: ProviderInputs): Promise<ReadResult> {
    const sns = new SNS({apiVersion: "2010-03-31", region: props.region});
    const params: SNS.GetSubscriptionAttributesInput = {SubscriptionArn: id};
    const subscription = sns.getSubscriptionAttributes(params);
    const response = await subscription.promise();
    return {id, props: {...props, ...response}};
  },
};

export class SnsEmailSubscription extends Resource {
  /**
   * Creates a new SNS E-Mail Subscription
   *
   * @param topic - The SNS topic ARN
   * @param endpoint - The e-mail address
   * @param region - The AWS region of the SNS topic
   * @param [validationMethod] - The method used to "validate" the successfull email confirmation, one of "sleep" | "poll" | undefined
   * @param [waitMs] - Time in ms to wait for you to confirm the subscription defaults to 1 min in "sleep" and 10sec in "poll" mode. Has to be greater than 999ms
   * @param [maxRetry=5] - How often the "poll" "validationMethod should retry
   */
  constructor(
    name: string,
    props: SnsInputs,
    opts?: pulumi.CustomResourceOptions,
  ) {
    super(snsEmailSubscriptionProvider, name, props, opts);
  }
}
```

You can alternatively turn it into a reusable NPM package if that's your thing. 😉

Let me know if you've got any questions.

I plan to post more Pulumi related articles soon 😍
One will be API Gateway v2 aka HTTP API related and the other one is about Pulumi, Netlify & DNS management 🚀

Sunny greetings from the jungle 🌴