AWSTemplateFormatVersion: 2010-09-09 Description: >- Deploys the cost optimizer for Amazon AppStream 2.0. Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: Notifications Parameters: - ImageBuilderActiveNotificationThreshold - ImageBuilderActiveNotificationInterval - ImageBuilderStopThreshold - ImageBuilderStopNotifications - NotificationEmailAddress - TopicKmsMasterKeyId - Label: default: 'Lambda function' Parameters: - LogLevel - LogRetentionDays - Timeout - Architecture ParameterLabels: Architecture: default: 'Architecture' ImageBuilderActiveNotificationThreshold: default: 'Image builder notification threshold' ImageBuilderActiveNotificationInterval: default: 'Image builder notification interval' ImageBuilderStopThreshold: default: 'Image builder stop threshold' ImageBuilderStopNotifications: default: 'Image builder stop notifications' LogLevel: default: 'Python logging level' LogRetentionDays: default: 'Log retention period' NotificationEmailAddress: default: 'Email address' Timeout: default: 'Timeout' TopicKmsMasterKeyId: default: 'SNS topic customer master key (CMK)' Parameters: Architecture: Type: String Description: 'x86_64 is supported in all AWS Regions. arm64 costs less, but is not supported in all AWS Regions.' Default: 'x86_64' AllowedValues: - 'arm64' - 'x86_64' ImageBuilderActiveNotificationThreshold: Type: Number Description: 'How long (in hours) an image builder can run before notifications are sent. Set to 0 to disable notifications.' Default: 2 MinValue: 0 ConstraintDescription: 'Must be a number greater than or equal to 0.' ImageBuilderActiveNotificationInterval: Type: Number Description: 'How long (in hours) between image builder active notifications.' Default: 1 MinValue: 1 ConstraintDescription: 'Must be a number greater than or equal to 1.' ImageBuilderStopThreshold: Type: Number Description: 'How long (in hours) an image builder can run before being stopped. Set to 0 to allow image builders to run indefinitely.' Default: 12 MinValue: 0 ConstraintDescription: 'Must be a number greater than or equal to 0.' ImageBuilderStopNotifications: Type: String Description: 'Whether or not to send notifications before stopping an image builder.' Default: 'Yes' AllowedValues: - 'No' - 'Yes' LogLevel: Type: String Description: 'Python logging level (CRITICAL, ERROR, WARNING, INFO, or DEBUG). Default is INFO.' Default: 'INFO' AllowedValues: - 'CRITICAL' - 'ERROR' - 'WARNING' - 'INFO' - 'DEBUG' LogRetentionDays: Type: Number Description: 'Days to retain function logs.' Default: 7 MinValue: 1 MaxValue: 3653 ConstraintDescription: 'Possible values: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 2192, 2557, 2922, 3288, and 3653.' NotificationEmailAddress: Type: String Description: 'Email address to receive notifications.' AllowedPattern: '[^\s@]+@[^\s@]+\.[^\s@]+' ConstraintDescription: 'Must be a valid email address (e.g. user@example.com).' Timeout: Type: Number Description: 'The amount of time (in seconds) that Lambda allows the function to run before stopping it.' Default: 60 MinValue: 1 MaxValue: 900 ConstraintDescription: 'Must be an integer between 1 and 900.' TopicKmsMasterKeyId: Type: String Description: 'The alias, ID, or ARN of an AWS KMS key for Amazon SNS topic encryption.' Default: 'alias/aws/sns' MinLength: 1 MaxLength: 2048 Resources: # DynamoDB ImageBuilderTable: Type: AWS::DynamoDB::Table Properties: KeySchema: - AttributeName: 'region' KeyType: HASH - AttributeName: 'name' KeyType: RANGE AttributeDefinitions: - AttributeName: 'region' AttributeType: S - AttributeName: 'name' AttributeType: S TimeToLiveSpecification: AttributeName: 'exp_date' Enabled: true BillingMode: PAY_PER_REQUEST PointInTimeRecoverySpecification: PointInTimeRecoveryEnabled: true SSESpecification: SSEEnabled: true SSEType: KMS UpdateReplacePolicy: Delete DeletionPolicy: Delete # SNS ImageBuilderTopic: Type: AWS::SNS::Topic Properties: KmsMasterKeyId: !Ref TopicKmsMasterKeyId Subscription: - Endpoint: Ref: NotificationEmailAddress Protocol: email # CloudWatch LogGroup: Type: AWS::Logs::LogGroup Metadata: cfn_nag: rules_to_suppress: - id: W84 reason: 'CloudWatch logs are encrypted by the service.' Properties: LogGroupName: !Join [ '',[ '/aws/lambda/', !Ref ImageBuilderMonitorFunction ] ] RetentionInDays: !Ref LogRetentionDays # IAM ImageBuilderMonitorRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: sts:AssumeRole Principal: Service: lambda.amazonaws.com ImageBuilderMonitorPolicy: Type: AWS::IAM::Policy Metadata: cfn_nag: rules_to_suppress: - id: W12 reason: "appstream:ListTagsForResource doesn't support resource level permissions." Properties: PolicyName: ImageBuilderMonitorPolicy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: !Join ['', ['arn:', !Ref 'AWS::Partition', ':logs:', !Ref 'AWS::Region', ':', !Ref 'AWS::AccountId', ':log-group:/aws/lambda/', !Ref 'ImageBuilderMonitorFunction', ':*']] - Effect: Allow Action: - appstream:DescribeImageBuilders - appstream:StopImageBuilder Resource: !Join [ '', [ 'arn:', Ref: 'AWS::Partition', ':appstream:*:', Ref: 'AWS::AccountId', ':image-builder/*' ] ] - Effect: Allow Action: - appstream:ListTagsForResource Resource: '*' - Effect: Allow Action: - sns:Publish Resource: !Ref ImageBuilderTopic - Effect: Allow Action: - dynamodb:DeleteItem - dynamodb:GetItem - dynamodb:PutItem - dynamodb:UpdateItem Resource: !GetAtt ImageBuilderTable.Arn Roles: - !Ref ImageBuilderMonitorRole # Lambda ImageBuilderMonitorFunction: Type: AWS::Lambda::Function Metadata: cfn_nag: rules_to_suppress: - id: W58 reason: 'The Lambda function has access to write logs.' - id: W89 reason: "The Lambda function doesn't need access to resources in a VPC." Properties: Architectures: - !Ref Architecture ReservedConcurrentExecutions: 1 Role: !GetAtt ImageBuilderMonitorRole.Arn Description: 'Monitors active image builders.' Environment: Variables: IB_TABLE_NAME: !Ref ImageBuilderTable IB_NOTIFY_HOURS: !Ref ImageBuilderActiveNotificationThreshold IB_NOTIFY_INTERVAL_HOURS: !Ref ImageBuilderActiveNotificationInterval IB_STOP_HOURS: !Ref ImageBuilderStopThreshold IB_STOP_NOTIFY: !Ref ImageBuilderStopNotifications LOG_LEVEL: !Ref LogLevel SNS_TOPIC_ARN: !Ref ImageBuilderTopic Code: ZipFile: | #!/usr/bin/python """ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 Check for image builders active longer than a threshold. """ import os import json import logging from datetime import datetime, timedelta from email.utils import parsedate_to_datetime from typing import Dict, List, Optional, Tuple import boto3 from botocore.exceptions import ClientError logger: logging.Logger = logging.getLogger() LOG_LEVEL: str = str(os.environ["LOG_LEVEL"]) logger.setLevel(LOG_LEVEL) IB_ACTIVE_STATES: Tuple = ( "PENDING", "UPDATING_AGENT", "RUNNING", "REBOOTING" ) IB_TABLE_NAME: str = os.environ["IB_TABLE_NAME"] logging.debug("IB_TABLE_NAME: %s", IB_TABLE_NAME) IB_NOTIFY_HOURS: float = float(os.environ["IB_NOTIFY_HOURS"]) if IB_NOTIFY_HOURS > 0: logging.debug("IB_NOTIFY_HOURS: %f", IB_NOTIFY_HOURS) else: logging.debug("IB_NOTIFY_HOURS: %f (disabled)", IB_NOTIFY_HOURS) IB_NOTIFY_INTERVAL_HOURS: float = float(os.environ["IB_NOTIFY_INTERVAL_HOURS"]) logging.debug("IB_NOTIFY_INTERVAL_HOURS: %f", IB_NOTIFY_INTERVAL_HOURS) IB_NOTIFY_INTERVAL_SEC: float = IB_NOTIFY_INTERVAL_HOURS * 3600 IB_STOP_HOURS: float = float(os.environ["IB_STOP_HOURS"]) if IB_STOP_HOURS > 0: logging.debug("IB_STOP_HOURS: %f", IB_STOP_HOURS) else: logging.debug("IB_STOP_HOURS: %f (disabled)", IB_STOP_HOURS) IB_STOP_NOTIFY: bool if os.environ["IB_STOP_NOTIFY"] == "No": IB_STOP_NOTIFY = False logging.debug("IB_STOP_NOTIFY: False (disabled)") elif os.environ["IB_STOP_NOTIFY"] == "Yes": IB_STOP_NOTIFY = True logging.debug("IB_STOP_NOTIFY: True (enabled)") else: IB_STOP_NOTIFY = True logging.warning( "IB_STOP_NOTIFY: unsupported value (%s), defaulting to True (enabled)", IB_STOP_NOTIFY ) SNS_TOPIC_ARN: str = os.environ["SNS_TOPIC_ARN"] logging.debug("SNS_TOPIC_ARN: %s", SNS_TOPIC_ARN) # Regional AppStream 2.0 clients are created later. session: boto3.session.Session = boto3.Session() ddb = session.resource("dynamodb") table = ddb.Table(IB_TABLE_NAME) sts = session.client("sts") sns = session.client("sns") def get_supported_as2_regions() -> List[str]: """Return regions in the current partition supported by AppStream 2.0. """ partition: str = session.get_partition_for_region(os.environ["AWS_REGION"]) return session.get_available_regions("appstream", partition) def publish_image_builder_notification( notification_type: str, image_builder: Dict, active_duration: str, tags: Dict, ): """Publish an SNS notification about an image builder. """ subject: str if notification_type == "active": subject = ( f"Image builder {image_builder['Name']} active for {active_duration}" ) elif notification_type == "stop": subject = ( f"Stopping image builder {image_builder['Name']}" ) else: logging.error("Unknown notification type: %s", notification_type) arn_split = image_builder["Arn"].split(":") response = sns.publish( TopicArn=SNS_TOPIC_ARN, Subject=subject, Message="\r\n".join(( f"AWS account: {arn_split[4]}", f"Region: {arn_split[3]}", f"Name: {image_builder['Name']}", f"Instance type: {image_builder['InstanceType']}", f"Time active: {active_duration}", f"Tags: {json.dumps(tags, indent=2)}" )) ) return response def process_previously_active_image_builder( image_builder: Dict, ddb_item: Dict, response_dt: datetime, expiration_date: int, as2 ): """ The image builder is stopped if all the following are true: * IB_STOP_HOURS > 0 (not disabled globally). * active_hours > IB_STOP_HOURS. * Skip_Stop tag is not present. A stop notification is sent if all the following are true: * The image builder will be stopped. * IB_STOP_NOTIFY is True (not disabled globally). * Skip_Stop_Notification tag is not present. An active notification is sent if all the following are true: * The image builder will not be stopped. * IB_NOTIFY_HOURS > 0 (not disabled globally). * active_hours > IB_NOTIFY_HOURS. * At least IB_NOTIFY_INTERVAL_HOURS has elapsed since the last notification. * Skip_Active_Notification tag is not present. """ active_hours: float = ( response_dt - datetime.fromisoformat(ddb_item["earliest_active"]) ).total_seconds() / 3600 logging.info( "%s: active for %f hour(s)", image_builder["Name"], active_hours ) now: datetime = datetime.now() stop: bool = False notify_stop: bool = False notify_active: bool = False # Step 1: determine tentative stop actions. if active_hours > IB_STOP_HOURS > 0: stop = True # Unless tagged (checked later) if stop and IB_STOP_NOTIFY: notify_stop = True # Unless tagged (checked later) # Step 2: determine tentative active notification. if active_hours > IB_NOTIFY_HOURS > 0: since_last_notification: timedelta = ( now - datetime.fromisoformat( ddb_item["last_active_notification"] ) ) if since_last_notification.total_seconds() < IB_NOTIFY_INTERVAL_SEC: logging.info( "%s: less than %f hour(s) since last notification", image_builder["Name"], IB_NOTIFY_INTERVAL_HOURS ) else: notify_active = True # Step 3: retrieve tags and override actions accordingly. if stop or notify_stop or notify_active: tags = as2.list_tags_for_resource( ResourceArn=image_builder["Arn"] )["Tags"] if "Skip_Stop" in tags: stop = False notify_stop = False logging.info( "%s: Skip_Stop tag present", image_builder["Name"] ) if "Skip_Stop_Notification" in tags: notify_stop = False logging.info( "%s: Skip_Stop_Notification tag present", image_builder["Name"] ) if "Skip_Active_Notification" in tags: notify_active = False logging.info( "%s: Skip_Active_Notification tag present", image_builder["Name"] ) # Step 4: take actions. if notify_stop or notify_active: # Format active hours for notification. active_duration: str if int(active_hours) == 1: active_duration = "1 hour" else: active_duration = f"{int(active_hours)} hours" notification_type: str = "active" if notify_stop: notification_type = "stop" logger.info( "%s: sending %s notification", image_builder["Name"], notification_type ) response = publish_image_builder_notification( notification_type=notification_type, image_builder=image_builder, active_duration=active_duration, tags=tags ) logger.debug(response) if notification_type == "active": table.update_item( Key={ "region": as2.meta.region_name, "name": image_builder["Name"] }, UpdateExpression=( "set last_active_notification=:l," "exp_date=:e" ), ExpressionAttributeValues={ ":l": now.isoformat(), ":e": expiration_date, } ) else: # Take no action except extending TTL. table.update_item( Key={ "region": as2.meta.region_name, "name": image_builder["Name"] }, UpdateExpression="set exp_date=:e", ExpressionAttributeValues={ ":e": expiration_date, }, ) if stop: logger.info( "%s: stopping", image_builder["Name"] ) response = as2.stop_image_builder( Name=image_builder["Name"] ) logger.debug(response) def process_newly_active_image_builder( region: str, name: str, earliest_active: datetime, expiration_date: int ): """Process a newly-active (not previously seen) image builder. """ logging.info( "%s: newly active", name ) table.put_item(Item={ "region": region, "name": name, "earliest_active": earliest_active.isoformat(), "last_active_notification": datetime.min.isoformat(), "exp_date": expiration_date, }) def process_active_image_builder( image_builder: Dict, as2, response_dt: datetime ): """Process an active image builder. """ # Calculate expiration date (TTL) one day in the future. This allows # DynamoDB to delete items for image builders that transition from active to # deleted between invocations. expiration_date: int = int(( response_dt + timedelta(days=1) ).timestamp()) ddb_response = table.get_item(Key={ "region": as2.meta.region_name, "name": image_builder["Name"] }) if "Item" in ddb_response: process_previously_active_image_builder( image_builder=image_builder, ddb_item=ddb_response["Item"], response_dt=response_dt, expiration_date=expiration_date, as2=as2 ) else: process_newly_active_image_builder( region=as2.meta.region_name, name=image_builder["Name"], earliest_active=response_dt, expiration_date=expiration_date ) def process_image_builders(region: str): """Process AppStream image builders in a given region. """ logging.info("Started processing image builders for region %s", region) as2 = session.client( service_name="appstream", region_name=region ) image_builders: List = [] try: response = as2.describe_image_builders() ib_response_dt: datetime = parsedate_to_datetime( response["ResponseMetadata"]["HTTPHeaders"]["date"] ) image_builders = response.get("ImageBuilders", []) next_token: Optional[str] = response.get("NextToken", None) while next_token is not None: response = as2.describe_image_builders(NextToken=next_token) image_builders.extend(response.get("ImageBuilders", [])) next_token = response.get("NextToken", None) logging.info("Found %i image builder(s)", len(image_builders)) except ClientError as err: logging.error(err) for builder in image_builders: if builder["State"] in IB_ACTIVE_STATES: process_active_image_builder( image_builder=builder, as2=as2, response_dt=ib_response_dt ) else: logging.info("%s: inactive", builder["Name"]) table.delete_item(Key={ "region": region, "name": builder["Name"] }) logging.info("Finished processing image builders for region %s", region) def lambda_handler(event: Dict, context: Dict): """Lambda handler. """ for region in get_supported_as2_regions(): process_image_builders(region=region) Handler: index.lambda_handler Runtime: python3.10 Timeout: !Ref Timeout ImageBuilderMonitorFunctionPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: !Ref ImageBuilderMonitorFunction Principal: events.amazonaws.com SourceArn: !GetAtt ImageBuilderMonitorRule.Arn # EventBridge ImageBuilderMonitorRule: Type: AWS::Events::Rule Properties: ScheduleExpression: rate(5 minutes) Targets: - Id: TargetFunctionV1 Arn: !GetAtt ImageBuilderMonitorFunction.Arn RetryPolicy: MaximumRetryAttempts: 0