DEV Community

Can Rau for CangleCode

Posted on

[Pulumi Custom Resource] AWS SNS E-Mail Subscription

Used Versions
{
  "dependencies: {
    "@pulumi/aws": "3.2.1",
    "@pulumi/pulumi": "2.9.2",
    "aws-sdk": "2.750.0",
    "netlify": "4.3.13"
  }
}
Enter fullscreen mode Exit fullscreen mode
# CLI
pulumi version # => 2.7.1
# &
pulumi version # => 2.10.0
Enter fullscreen mode Exit fullscreen mode

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 and I stumbled upon it in this Pulumi issue because Pulumi uses Terraform's code in many cases to build their providers.

Possible Workarounds

While in the aforementioned issue 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

// 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
});
Enter fullscreen mode Exit fullscreen mode

Complete Code

// 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 🌴

Top comments (0)