[풀루미 커스텀 리소스] AWS SNS 이메일 구독

중고 버전


{
  "dependencies: {
    "@pulumi/aws": "3.2.1",
    "@pulumi/pulumi": "2.9.2",
    "aws-sdk": "2.750.0",
    "netlify": "4.3.13"
  }
}



# CLI
pulumi version # => 2.7.1
# &
pulumi version # => 2.10.0



문제



일부 인프라에서 작업하는 동안 마침내 CloudWatch 청구 경보를 추가하기로 결정했고 이메일이 현재 상황에서 가장 간단하고 효과적일 것이라고 생각했습니다. 그때 나는 다음 Intellisense 정보에 직면해야 했습니다.

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).



Terraform의 문서에 설명된 대로:

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.



protocols supported에서 찾을 수 있고 Pulumi가 공급자를 구축하기 위해 많은 경우 Terraform의 코드를 사용하기 때문에 나는 this Pulumi issue에서 그것을 발견했습니다.

가능한 해결 방법



aforementioned issue Luke Hoban(CTO Pulumi)이 제안한 설문조사에서 나는 30초 또는 60초와 같이 미리 설정된 시간만큼 "그냥"기다리기로 결정했습니다.

이것은 꽤 잘 작동하지만 나처럼 실행하는 동안 휴대전화를 손에 들고 있다면pulumi up 몇 초 만에 확인할 수 있고 나머지 시간을 기다리면 끝이 어떻게 되는지 알 수 있습니다. Twitter 스크롤링, 또는 인스타그램 😂

그래서 근육을 풀기 위해(🧠 & 👆) 추천 투표로 바꾸기로 했습니다. 나는 maxRetry 옵션과 중간에 약간의 지연을 추가했습니다. 🤓

이 기사를 작성하는 동안 이 모든 것이 전혀 필요하지 않다는 것을 깨닫고 내 코드를 더 많이 사용하지 않을 수 없었습니다. Terraform 문서의 인용문과 달리 ReturnSubscriptionArn 덕분에 true 로 설정했습니다. aws-sdk 는 구독의 ARN 를 반환합니다. 이것은 나중에 pulumi destroy 동안 구독을 제거할 수 있는 데 필요한 전부입니다. 지금 여러 번 시도했지만 구독을 확인하기 위해 메일에 있는 링크를 클릭해도 별로 문제가 되지 않습니다. 풀루미는 그냥 즐겁게 진행하고 절대 필요 이상으로 기다리지 않아도 됩니다 😍 🎉
나는 이 기능이 아마도 인용문보다 더 최신일 것이며 지금까지 아무도 이것을 발견하지 못했다는 것을 인정해야 합니다 🤷‍♂️

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



항상은 아니지만 아마도 대부분의 경우처럼 라인에서 몇 가지를 배웠습니다.

실제 사용 모습




// 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
});


완전한 코드




// 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);
  }
}


대안으로 원하는 경우 재사용 가능한 NPM 패키지로 바꿀 수 있습니다. 😉

질문이 있으면 알려주세요.

앞으로 풀미 관련 글 많이 올릴 예정 😍
하나는 HTTP API 관련 API Gateway v2이고 다른 하나는 Pulumi, Netlify 및 DNS 관리에 관한 것입니다 🚀

정글에서 온 화창한 인사🌴

좋은 웹페이지 즐겨찾기