0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudFormation で未対応の ActionAfterCompletion を指定した EventBridge Scheduler を作成する

0
Posted at

はじめに

こんにちは、ほうき星 @H0ukiStar です。

皆さんは CloudFormation で ActionAfterCompletion を指定した EventBridge Schedulder を作成したいと思ったことはありませんか?

私はあります。

EventBridge Scheduler を作成出来る AWS::Scheduler::Schedule は、以下のドキュメントの通り ActionAfterCompletion プロパティは存在せずサポートしていません。

これは、ActionAfterCompletion が DELETE の場合、スケジュールが動作を完了した後自動で削除されるため、ドリフト状態にならない様にこのプロパティを指定したスケジュールリソースは作成できないものと想像します。

しかし、この ActionAfterCompletion を指定したスケジュールを CloudFormation で作成したい時もあるでしょう。
例えば、一度だけ実行するスケジュールを CloudFormation で反復的に展開したい場合、実行後に自動削除される ActionAfterCompletion: DELETE を指定できると便利です。

そこで本記事では CloudFormation のカスタムリソースを使用して作成する方法をご紹介します。

カスタムリソース(AWS::CloudFormation::CustomResource)とは

このリソースは、CloudFormation 純正では対応していないリソースの作成や API 等を実行したいときに使用するリソースです。
指定した SNS や Lambda 関数を呼び出すことができ、呼び出し先の Lambda 関数等で以下のような処理をすることができます。

  • CloudFormation が純正で対応していない AWS リソースの作成
  • 外部サービス等との連携(API 呼び出しなど)
  • 特定の初期化処理や登録作業の自動化

今回はこのカスタムリソースと Lambda 関数を使用して、ActionAfterCompletion を指定したスケジュールリソースの作成を行います。

カスタムリソースとして ActionAfterCompletion を指定したスケジュールリソースを作成する Lambda 関数のサンプル

カスタムリソースは ServiceToken プロパティを持っており、ここに処理を行わせたい Lambda 関数の ARN を指定して利用します。

ServiceToken に指定する Lambda 関数はカスタムリソースを定義する CFn テンプレート内にインラインで定義するか、事前に用意した Lambda 関数を指定することができます。
今回は Lambda 関数を事前に用意することとし、以下のコードを SAM でデプロイしました。

SAM テンプレートを含む全ソースは以下のリポジトリに置いていますので、合わせて参考にしてください。

ActionAfterCompletion を指定したスケジュールリソースを作成する Lambda 関数のコード
lambda_function.py
from __future__ import print_function
import re
import json
from typing import Optional
from datetime import datetime

import boto3
import urllib3
from pydantic import BaseModel, ValidationError


class FlexibleTimeWindowProperty(BaseModel):
    """
    Flexible time window configuration for EventBridge Scheduler.
    """
    MaximumWindowInMinutes: Optional[int] = None
    Mode: Optional[str] = None


class TargetProperty(BaseModel):
    """
    Target configuration for EventBridge Scheduler.
    """
    class DeadLetterConfigProperty(BaseModel):
        """
        Dead letter queue configuration.
        """
        Arn: Optional[str] = None

    class EventBridgeParametersProperty(BaseModel):
        """
        EventBridge event parameters.
        """
        DetailType: Optional[str] = None
        Source: Optional[str] = None

    class KinesisParametersProperty(BaseModel):
        """
        Kinesis stream parameters.
        """
        PartitionKey: Optional[str] = None

    class RetryPolicyProperty(BaseModel):
        """
        Retry policy configuration.
        """
        MaximumEventAgeInSeconds: Optional[int] = None
        MaximumRetryAttempts: Optional[int] = None

    class SageMakerPipelineParametersProperty(BaseModel):
        """
        SageMaker Pipeline parameters.
        """
        class PipelineParameterListItemProperty(BaseModel):
            """
            Individual pipeline parameter.
            """
            Name: Optional[str] = None
            Value: Optional[str] = None

        PipelineParameterList: Optional[list[PipelineParameterListItemProperty]] = None

    class SqsParametersProperty(BaseModel):
        """
        SQS queue parameters.
        """
        MessageGroupId: Optional[str] = None

    Arn: Optional[str] = None
    DeadLetterConfig: Optional[DeadLetterConfigProperty] = None
    # EcsParameters: Optional[dict] = None
    EventBridgeParameters: Optional[EventBridgeParametersProperty] = None
    Input: Optional[str] = None
    KinesisParameters: Optional[KinesisParametersProperty] = None
    RetryPolicy: Optional[RetryPolicyProperty] = None
    RoleArn: Optional[str] = None
    SageMakerPipelineParameters: Optional[SageMakerPipelineParametersProperty] = None
    SqsParameters: Optional[SqsParametersProperty] = None


class ScheduleProperty(BaseModel):
    """
    EventBridge Scheduler schedule configuration.
    """
    ActionAfterCompletion: Optional[str] = None
    Description: Optional[str] = None
    EndDate: Optional[datetime] = None
    FlexibleTimeWindow: Optional[FlexibleTimeWindowProperty] = None
    GroupName: Optional[str] = None
    KmsKeyArn: Optional[str] = None
    Name: str
    ScheduleExpression: Optional[str] = None
    ScheduleExpressionTimezone: Optional[str] = None
    StartDate: Optional[datetime] = None
    State: Optional[str] = None
    Target: Optional[TargetProperty] = None


def lambda_handler(event: dict, context: object) -> None:
    """
    AWS Lambda handler for CloudFormation custom resource managing EventBridge Scheduler schedules.

    Handles Create, Update, and Delete operations for EventBridge Scheduler schedules
    as a CloudFormation custom resource. Supports ActionAfterCompletion property to enable
    automatic schedule actions (e.g., deletion) after completion.

    Parameters
    ----------
    event : dict
        Lambda event object containing CloudFormation request details.
    context : object
        Lambda context object containing runtime information.

    Returns
    -------
    None
        Sends response to CloudFormation via HTTP callback.

    Notes
    -----
    - For Create: Creates a new schedule and returns its ARN
    - For Update: Updates existing schedule or creates new one if Name changed
    - For Delete: Deletes the schedule unless it failed during creation/update
    """
    print(f"{event['ResourceProperties']=}")

    try:
        schedule_property: ScheduleProperty = ScheduleProperty(
            **event["ResourceProperties"]
        )
    except ValidationError as e:
        send(event, context, "FAILED", {}, reason=f"Resource properties pre validation failed: {e.errors()}")
        return

    scheduler_client = boto3.client("scheduler")
    request_type: str = event["RequestType"]
    print(f"{request_type=}")

    if request_type == "Create":
        try:
            response: dict = scheduler_client.create_schedule(
                **schedule_property.model_dump(exclude_none=True)
            )
            send(event, context, "SUCCESS", {"Arn": response["ScheduleArn"]}, physicalResourceId=schedule_property.Name)
        except Exception as e:
            send(event, context, "FAILED", {}, reason=f"{e}", physicalResourceId="CREATE_FAILED")
        return
    elif request_type == "Update":
        try:
            old_schedule_property: ScheduleProperty = ScheduleProperty(
                **event["OldResourceProperties"]
            )
        except ValidationError as e:
            send(event, context, "FAILED", {}, reason=f"Old resource properties pre validation failed: {e.errors()}")
            return

        physical_resource_id: str = event["PhysicalResourceId"]

        # Create a new schedule if Name has changed
        if old_schedule_property.Name != schedule_property.Name:
            try:
                # CloudFormation will automatically delete the old schedule when the physical ID changes, so no explicit deletion is needed in Update.
                response: dict = scheduler_client.create_schedule(
                    **schedule_property.model_dump(exclude_none=True)
                )
                send(event, context, "SUCCESS", {"Arn": response["ScheduleArn"]}, physicalResourceId=schedule_property.Name)
            except Exception as e:
                send(event, context, "FAILED", {}, reason=f"{e}", physicalResourceId="UPDATE_FAILED")
            return

        # Update the schedule if Name is the same
        try:
            existing_schedule: dict = scheduler_client.get_schedule(Name=physical_resource_id)
            existing_schedule_property: ScheduleProperty = ScheduleProperty(**existing_schedule)

            update_params: dict = existing_schedule_property.model_dump(exclude_none=True)
            new_params: dict = schedule_property.model_dump(exclude_none=True)
            update_params.update(new_params)

            response: dict = scheduler_client.update_schedule(**update_params)
            send(event, context, "SUCCESS", {"Arn": response["ScheduleArn"]}, physicalResourceId=schedule_property.Name)
        except Exception as e:
            send(event, context, "FAILED", {}, reason=f"{e}", physicalResourceId=physical_resource_id)
        return
    elif request_type == "Delete":
        physical_resource_id: str = event["PhysicalResourceId"]

        # Skip deletion and return success if CREATE_FAILED (schedule does not exist) or UPDATE_FAILED (should not delete)
        if physical_resource_id == "CREATE_FAILED" or physical_resource_id == "UPDATE_FAILED":
            send(event, context, "SUCCESS", {})
            return

        try:
            scheduler_client.delete_schedule(Name=physical_resource_id)
            send(event, context, "SUCCESS", {})
        except scheduler_client.exceptions.ResourceNotFoundException:
            # If the schedule is already deleted, consider it a success
            send(event, context, "SUCCESS", {})
        except Exception as e:
            send(event, context, "FAILED", {}, reason=f"{e}")
        return
    send(event, context, "FAILED", {}, reason=f"Unsupported request type: {request_type}")
    return


# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
http = urllib3.PoolManager()


def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None):
    responseUrl = event['ResponseURL']

    responseBody = {
        'Status': responseStatus,
        'Reason': reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name),
        'PhysicalResourceId': physicalResourceId or context.log_stream_name,
        'StackId': event['StackId'],
        'RequestId': event['RequestId'],
        'LogicalResourceId': event['LogicalResourceId'],
        'NoEcho': noEcho,
        'Data': responseData
    }

    json_responseBody = json.dumps(responseBody)

    print("Response body:")
    print(json_responseBody)

    headers = {
        'content-type': '',
        'content-length': str(len(json_responseBody))
    }

    try:
        response = http.request('PUT', responseUrl, headers=headers, body=json_responseBody)
        print("Status code:", response.status)

    except Exception as e:
        print("send(..) failed executing http.request(..):", mask_credentials_and_signature(e))


def mask_credentials_and_signature(message):
    """
    Mask AWS credentials and signatures in error messages.

    Redacts sensitive AWS credential and signature information from messages
    before logging to prevent credential exposure.

    Parameters
    ----------
    message : str
        Error message or string that may contain AWS credentials.

    Returns
    -------
    str
        Message with credentials and signatures masked.

    Notes
    -----
    Masks the following AWS authentication parameters:
    - X-Amz-Credential
    - X-Amz-Signature
    """
    message = re.sub(r'X-Amz-Credential=[^&\s]+', 'X-Amz-Credential=*****', message, flags=re.IGNORECASE)
    return re.sub(r'X-Amz-Signature=[^&\s]+', 'X-Amz-Signature=*****', message, flags=re.IGNORECASE)

リソースプロパティのPydanticによるバリデーションは主に datetime や int への変換のために使用しており、スケジュールリソースが許容している文字列や数値範囲等のバリデーションは boto3 に任せる実装としています。

また、EcsParameters プロパティは本サンプルでは実装していない点に注意ください。

動作確認

この Lambda 関数を用いて ActionAfterCompletion が有効なスケジュールを作成するサンプルのテンプレートは以下の通りです。

sample.yaml
AWSTemplateFormatVersion: 2010-09-09
Description: Sample template for managing EventBridge Scheduler with ActionAfterCompletion using a CloudFormation custom resource

Resources:
  CustomScheduleWithActionAfterCompletion:
    Type: Custom::ScheduleWithActionAfterCompletion
    Properties:
      ServiceTimeout: 30
      # Replace with the ARN of the deployed custom resource Lambda function
      ServiceToken: arn:aws:lambda:ap-northeast-1:123456789012:function:cfn-custom-resource-schedule-with-aac

      Name: schedule-with-aac
      ActionAfterCompletion: DELETE

      FlexibleTimeWindow:
        Mode: OFF

      # Replace with the desired schedule expression
      ScheduleExpression: at(2026-04-30T00:00:00)

      ScheduleExpressionTimezone: Asia/Tokyo

      Target:
        # Replace with the ARN of the target resource to invoke
        Arn: arn:aws:lambda:ap-northeast-1:123456789012:function:example-function

        # Replace with the ARN of the IAM role that EventBridge Scheduler assumes
        RoleArn: arn:aws:iam::123456789012:role/example-scheduler-role

  • ServiceToken には展開した Lambda 関数の ARN を記述してください
  • Target には任意のターゲットを記載してください

上記サンプルを CloudFormation で展開し、ActionAfterCompletion の指定ができているスケジュールが作成できていれば動作確認は OK です。

image.png

image.png

注意点

冒頭でも触れましたが ActionAfterCompletion を DELETE に指定したスケジュールは完了した際にリソースそのものが削除されます。

カスタムリソースはドリフトの確認に対応していないため、EventBridge Scheduler 側で削除されても CloudFormation スタックがドリフト状態としてマークされることはありません。
しかし CloudFormation スタックの削除等でリソースを削除する際、RequestType が Delete なイベントが Lambda 関数に渡されますので、削除の際の ResourceNotFoundException のエラーは SUCCESS となるようハンドリングしておくと良いでしょう。

抜粋
try:
    scheduler_client.delete_schedule(Name=physical_resource_id)
    send(event, context, "SUCCESS", {})
except scheduler_client.exceptions.ResourceNotFoundException:
    # If the schedule is already deleted, consider it a success
    send(event, context, "SUCCESS", {})
except Exception as e:
    send(event, context, "FAILED", {}, reason=f"{e}")
return

さいごに

本記事では ActionAfterCompletion を指定したスケジュールリソースを CloudFormation のカスタムリソースを利用して作成する方法をご紹介しました。

EventBridge Scheduler のように SDK(API) では利用可能でも CloudFormation では未対応なケースは他にもあるため、今回の方法はそのような場面でも応用することが可能です。

本記事が誰かの参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?