Desktop and Application Streaming

How to automate Amazon AppStream 2.0 image deployment

In a previous blog, we looked at how to schedule managed image updates for Amazon AppStream 2.0 using AWS serverless services. In this blog, we describe how to automate the deployment of AppStream 2.0 images to existing fleets. By creating the automated deployment workflow, we can schedule the update of the fleets during off hours, or at a scheduled time. This automation reduces the maintenance overhead, and end-user impact to updates.

Overview

You will configure an image deployment workflow that updates fleets with an image at a defined maintenance window. Every time an AppStream 2.0 resource is tagged, Amazon EventBridge starts an AWS Lambda function. This function checks the key-value and, if needed, manages the image update process. Administrators are notified of pending image releases using Amazon Simple Email Service. The process uses an Amazon Step Function state machine to deploy the image and any scaling actions during the next defined maintenance window.

When the maintenance window is reached, the state machine runs the Lambda function again. This applies the image to the fleets it is tagged with. The fleets can be scaled down and returned to the state machine to wait by using a tag. The state machine retries the Lambda function until the fleets reach zero available instances, and then it returns the scaling targets values. Administrators are notified the image deployment is complete.

 

Diagram describing the required services for the solution. AWS CloudTrail, Amazon EventBridge, AWS Lambda, Amazon SES, Amazon AppStream 2.0, and AWS Step Functions.

Walkthrough

In this walk-through, you perform the following tasks:

  1. Create an IAM Policy.
  2. Create an IAM Role.
  3. Create an AWS Lambda function.
  4. Create an AWS Step Functions state machine.
  5. Create Amazon EventBridge rule.

Prerequisites

Make sure you meet the following requirements before getting started:

Step 1. Create the IAM Policy

In this step, we create an IAM Policy, and attach it to an IAM Role that the Lambda function can assume.

  1. Navigate to the IAM console.
  2. In the navigation pane, choose Policies.
  3. Choose Create policy.
  4. Choose the JSON tab.
  5. Copy and paste the following JSON policy.
  6. When you’re done, choose Review policy.
  7. Enter a name of your choosing.
  8. Choose Create policy.

IAM Policy document example:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents",
                "logs:CreateLogGroup",
                "ses:SendEmail",
                "appstream:UpdateFleet",
                "appstream:UntagResource",
                "application-autoscaling:RegisterScalableTarget",
                "application-autoscaling:DescribeScalableTargets",
                "appstream:DescribeImages",
                "appstream:DescribeFleets",
                "appstream:TagResource",
                "appstream:ListTagsForResource"
            ],
            "Resource": "*"
        }
    ]
}

Step 2. Create an IAM Role

Now that the IAM Policy has been created, create the IAM Role for Lambda to assume, and attach the policy you created in step 1.8.

  1. Open the IAM console.
  2. In the navigation pane, choose Roles.
  3. Choose Create role.
  4. For Select type of trusted entity, keep AWS service selected.
  5. Choose Lambda, and then choose Next: Permissions.
  6. In the filter policies search box, type name of the policy created in the previous step. When the policy appears in the list, select the check box next to the policy name.
  7. Choose Next: Tags. Although you can specify a tag for the policy, a tag is not required.
  8. Choose Next: Review.
  9. Enter a name for your Role to help you identify it.
  10. Choose Create role.

Step 3. Create a Lambda function

In this step, we create our Lambda function that verifies the tagging, apply the image, and scale the fleet in, and back out.

  1. Open the Lambda console
  2. Choose Create function.
  3. Enter a meaningful name in Function name.
  4. Select Python 3.8 as the Runtime.
  5. Expand the permissions section, select Use an existing role, and from the list select the role created in step 2.
  6. Choose Create function.
  7. Under the Function code section, replace the placeholder text with the following code.
  8. Replace the following values in the code with your own:
    • Replace <verified_ses_email_address> with your verified email address in Amazon SES.
    • Replace <address_to_notify> with the email address you would like to notify of image deployments.
    • Replace <numerical-day-of-the-week> with the numerical day of the week, starting with Monday as 0. Remember that the time is in UTC.
    • Replace <start-time> with string time to start the image change in the format hh:mm:ss. Remember that the time is in UTC.
    • The value for <State-Machine-Arn> is not known yet. You update this value in a later step.
  9. Choose Save.
  10. Make a note of the Lambda ARN for the next step.
import boto3
import botocore
import logging
from datetime import date, timedelta

email_from = '<verified_ses_email_address>'
email_to = ['<address_to_notify>'] #Multiple addresses can be entered as a comma separated list.

maint_day = <numerical-day-of-the-week> #specify numerical day of the week, starting with Monday as 0. Remember the time is in UTC.
maint_time = "<start-time>" #Specify a string time to start the image change in the format hh:mm:ss. Remember the time is in UTC.

StateMachineArn = '<State-Machine-Arn>'

logger = logging.getLogger()
logger.setLevel(logging.INFO)

appstream = boto3.client('appstream')
ses = boto3.client('ses')
aa = boto3.client('application-autoscaling')
stepfn = boto3.client('stepfunctions')

def send_email(title, message):
    try:
        logger.info('Sending summary email to: ' + str(email_to))
        ses.send_email(
            Source=email_from,
            Destination={'ToAddresses': email_to},
            Message={
                'Subject': {
                    'Data': title,
                    'Charset': 'UTF-8'
                },
                'Body': {
                    'Html': {
                        'Data': message,
                        'Charset': 'UTF-8'
                    }
                }
            }
        )
    except botocore.exceptions.ClientError as error:
        logger.error('There was a fault with emailing updates. Exception: ' + error)

def scale_up(fleet_list):
    email_body = ''
    still_pending = []
    logger.info('Fleet scale up triggered')
    for fleet_name in fleet_list:
        fleet = appstream.describe_fleets(Names=[fleet_name]).get('Fleets')[0]
        if fleet.get('ComputeCapacityStatus').get('Available') == 0:
            tags = (appstream.list_tags_for_resource(ResourceArn=fleet.get('Arn'))).get('Tags')
            try:
                aa.register_scalable_target(ServiceNamespace='appstream', ResourceId='fleet/' + fleet.get('Name'), ScalableDimension='appstream:fleet:DesiredCapacity', MinCapacity= int(tags.get('Min')), MaxCapacity= int(tags.get('Max')))
                email_body = email_body + 'Scalable target for fleet: ' + fleet_name + ' set to Min ' + tags.get('Min') + ' and Max ' + tags.get('Max') + '.<br>'
                appstream.untag_resource(ResourceArn=fleet.get('Arn'), TagKeys=['Min'])
                appstream.untag_resource(ResourceArn=fleet.get('Arn'), TagKeys=['Max'])
                appstream.untag_resource(ResourceArn=fleet.get('Arn'), TagKeys=['Status'])
            except botocore.exceptions.ClientError as error:
                logger.error('There was a fault scaling Fleet ' + fleet_name + '. Exception: ' + error)
                email_body = email_body + 'There was a fault scaling Fleet ' + fleet_name + '. Exception: ' + error + '<br>'                    
        else:
           still_pending.append(fleet_name)
    if email_body:
        send_email(
            'Summary of AppStream 2.0 Image Scaling ' + date.today().strftime("%b-%d-%Y"),
            '<html><head></head><body>The following AppStream 2.0 images were pending deployment today. The status of each deployment is as follows:<p>' + email_body + '</p>Fleets still pending: ' + str(still_pending) + '</body></html>'
            )
    if still_pending:
        return still_pending
    else:
        return 0

def deploy_images(imageArn):
    scale_pending = []
    email_body = ''
    logger.info('Image update triggered')
    image = appstream.describe_images(Arns=[imageArn]).get('Images')[0]
    tags = (appstream.list_tags_for_resource(ResourceArn=image.get('Arn'))).get('Tags')
    if tags.get('FleetName') and image.get('State') == 'AVAILABLE':
        if tags.get('Status').split('/')[0] == 'Pending':
            for raw_fleet_name in tags.get('FleetName').split('/'):
                fleet_name = raw_fleet_name.strip()
                try:
                    fleet = appstream.describe_fleets(Names=[fleet_name]).get('Fleets')[0]
                    if fleet.get('ImageArn') == image.get('Arn'):
                        logger.warning('Fleet ' + fleet_name + ' image is the same as assigned.')
                        logger.warning('Current fleet image: ' + fleet.get('ImageName'))
                        email_body = email_body + 'Fleet ' + fleet_name + ' image is the same as assigned. Current fleet image: ' + fleet.get('ImageName') + '<br>'
                    else:
                        logger.info('Fleet ' + fleet_name + ' needs updating...')
                        logger.info('Assigning ' + image.get('Name') + ' to fleet ' + fleet_name)
                        appstream.update_fleet(ImageArn=image.get('Arn'), Name=fleet_name)
                        email_body = email_body + 'Assigned ' + image.get('Name') + ' to fleet ' + fleet_name + '<br>'
                        if tags.get('ScaleImmediately') == 'True' and fleet.get('State') == 'RUNNING':
                            logger.info('Scaling Fleet ' + fleet_name + ' to 0.')
                            targets = aa.describe_scalable_targets(ServiceNamespace='appstream', ResourceIds=['fleet/' + fleet_name], ScalableDimension='appstream:fleet:DesiredCapacity').get('ScalableTargets')
                            if len(targets) > 0:
                                appstream.tag_resource(ResourceArn=fleet.get('Arn'), Tags={'Min': str(targets[0].get('MinCapacity'))})
                                appstream.tag_resource(ResourceArn=fleet.get('Arn'), Tags={'Max': str(targets[0].get('MaxCapacity'))})
                                appstream.tag_resource(ResourceArn=fleet.get('Arn'), Tags={'Status': 'Pending'})
                                aa.register_scalable_target(ServiceNamespace='appstream', ResourceId='fleet/' + fleet.get('Name'), ScalableDimension='appstream:fleet:DesiredCapacity', MinCapacity=0, MaxCapacity=0)
                                logger.info('Scalable target for fleet: ' + fleet_name + ' set to Min 0 and Max 0. Pending scale down.')
                                email_body = email_body + '► Scalable target for fleet: ' + fleet_name + ' set to Min 0 and Max 0. Pending scale down.<br>'
                                scale_pending.append(fleet.get('Name'))
                                appstream.untag_resource(ResourceArn=image.get('Arn'), TagKeys=['ScaleImmediately'])
                            else:
                                logger.warning('Scalable target not found for fleet: ' + fleet_name + '. No scaling action taken.')
                                email_body = email_body + '► There was an error getting the scaling target for fleet ' + fleet_name + '. No scaling action taken for this fleet.'
                    logger.info('Removing tag "FleetName" : "' + tags.get('FleetName') + '" from image: ' + image.get('Name'))
                    appstream.untag_resource(ResourceArn=image.get('Arn'), TagKeys=['FleetName'])
                    logger.info('Removing tag "Status" : "' + tags.get('FleetName') + '" from image: ' + image.get('Name'))
                    appstream.untag_resource(ResourceArn=image.get('Arn'), TagKeys=['Status'])
                except botocore.exceptions.ClientError as error:
                    logger.error('There was a fault with Fleet ' + tags.get('FleetName') + '. Exception: ' + error)
                    email_body = email_body + 'There was a fault with Fleet ' + tags.get('FleetName') + '. Exception: ' + error + '<br>'
        else:
            logger.warning('Image name ' + image.get('Name') + ' was not applied because tag Status was not set to Pending.')
            email_body = email_body + 'Image name ' + image.get('Name') + ' was not applied because tag Status was not set to Pending.' + '<br>'
    if email_body:
        send_email(
            'Summary of AppStream 2.0 Image Deployments ' + date.today().strftime("%b-%d-%Y"),
            '<html><head></head><body>The following AppStream 2.0 images were pending deployment today. The status of each deployment is as follows:<p>' + email_body + '</p></body></html>'
            )
    logger.info('Image update process is complete!')
    if scale_pending:
        return scale_pending
    else:
        return 0
    
def taggedImage(event):
    email_body = ''
    resourceArn = event['detail']['requestParameters']['resourceArn']
    resource = (resourceArn.split(':')[5])
    resourceType = resource.split('/')[0]
    resourceName = resource.split('/')[1]
    logger.info('Tag added to Resource ' + resource)
    if resourceType == 'image':
        logger.info('Tag was added to an AppStream 2.0 image.')
        if event['detail']['requestParameters']['tags'].get('FleetName'):
            logger.info('FleetName tag was found.')
            for raw_fleet_name in event['detail']['requestParameters']['tags'].get('FleetName').split('/'):
                fleet_name = raw_fleet_name.strip()
                try:
                    fleet = appstream.describe_fleets(Names=[fleet_name]).get('Fleets')
                    logger.info('Fleet ' + fleet_name + ' found.')
                except:
                    logger.error('No fleet ' + fleet_name + ' found. Cancelling deployment.')
                    return 0

            email_body = 'Image: ' + resourceName + ' is pending deployment to Fleet(s): ' + event['detail']['requestParameters']['tags'].get('FleetName') + '<br>'
            today = date.today()
            maint_window_day = today + timedelta( (maint_day-today.weekday()) % 7 )
            maint_window = str(maint_window_day) + 'T' + maint_time + 'Z'
            appstream.tag_resource(ResourceArn=resourceArn, Tags={'Status': 'Pending/' + maint_window})
            stepfn.start_execution(
                stateMachineArn=StateMachineArn,
                input='{"expirydate":"' + maint_window + '","ImageUpdates":"' + resourceArn + '"}'
                )
        else:
            logger.info('Tag added was not "FleetName", no action needed.')
    else:
        logger.info('Tag was added to a non-image AppStream 2.0 resource, no action needed.')
    if email_body:
        send_email(
            'Pending AppStream 2.0 Deployment of Image "' + resourceName + '" on ' + maint_window_day.strftime("%b-%d-%Y"),
            '<html><head></head><body>The following AppStream 2.0 image is pending deployment on ' +maint_window_day.strftime("%b-%d-%Y") + ':<p>' + email_body + '</p><p>To stop the deployment of this image, connect to the AppStream 2.0 AWS Management Console and remove the tag "FleetName" from the image.</p></body></html>'
            )

def lambda_handler(event, context):
    logger.info('Starting execution...')
    if event.get('PendingScaling'):
        response = scale_up(event.get('PendingScaling'))
        return response
    elif event.get('ImageUpdates'):
        response = deploy_images(event.get('ImageUpdates'))
        return response
    else:
        reponse = taggedImage(event)

Step 4. Create state machine

In this step, you create a state machine that coordinates the multiple executions of our Lambda function.

  1. Open the AWS Step Functions console.
  2. Do one of the following:
    • If you haven’t created any state machines functions, a Getting Started page displays. Choose Getting Started, and then choose State Machines.
    • If you have created a state machines function, in the upper right corner of the State machines page, choose Create state machine.
  3. On the Define State Machine page, keep Author from code snippet selected.
  4. In the Type section, keep Standard selected.
  5. In the State machine definition section, delete the placeholder code and paste the code that follows in the edit window.
  6. Replace <Lambda_Arn> in two places with the ARN recorded in the previous step.
  7. Choose Next.
  8. For Name, enter a meaningful name for identification later.
  9. Keep the rest of the options as default.
  10. Choose Create state machine.
  11. Note the state machine ARN for the next step.
{
  "StartAt": "WaitForDeployment",
  "States": {
    "WaitForDeployment": {
      "Type": "Wait",
      "TimestampPath": "$.expirydate",
      "Next": "Deployment"
    },
    "Deployment": {
      "Type": "Task",
      "Resource": "<Lambda_Arn>",
      "ResultPath": "$.PendingScaling",
      "Next": "PendingScaling?"
    },
    "PendingScaling?": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.PendingScaling",
          "NumericEquals": 0,
          "Next": "End"
        }
      ],
      "Default": "WaitScaling"
    },
    "WaitScaling": {
      "Type": "Wait",
      "Seconds": 300,
      "Next": "ModifyScaling"
    },
    "ModifyScaling": {
      "Type": "Task",
      "Resource": "<Lambda_Arn>",
      "ResultPath": "$.PendingScaling",
      "Next": "DoubleCheck"
    },
    "DoubleCheck": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.PendingScaling",
          "NumericEquals": 0,
          "Next": "End"
        }
      ],
      "Default": "WaitScaling"
    },
    "End": {
      "Type": "Succeed"
    }
  }
}

Step 5. Modify the AWS Lambda function

In this step, you come back to the Lambda function created in step 2 to update the state machine ARN.

  1. Open the Lambda console
  2. Select the function created in step 2.
  3. Replace <State-Machine-Arn> with the ARN of the state machine noted in the previous step.
  4. Choose Save.

Step 6. Create Amazon EventBridge rule

In this step, you create an EventBridge rule to trigger a Lambda function. The rule triggers each time there is a CloudTrail event for tagging an AppStream 2.0 resource.

  1. Open the EventBridge console.
  2. Choose Create rule.
  3. Enter a Name, and optionally a Description.
  4. Select Event pattern, and then Custom pattern.
  5. Enter JSON that follows into the Event pattern box.
  6. Choose Save next to the Event pattern box.
  7. Under Select targets, select Lambda function, then select the function you created in the step 2.
  8. Choose Create.
{
  "source": [
    "aws.appstream"
  ],
  "detail": {
    "eventSource": [
      "appstream.amazonaws.com"
    ],
    "eventName": [
      "TagResource"
    ]
  }
}

Clean up

To avoid incurring future charges, remove the resources that you created. Delete the EventBridge rule, Lambda function, state machine, IAM Policy and IAM Role.

Conclusion

You have configured an image deployment workflow that updates fleets with an image at a defined maintenance window. The Amazon EventBridge rule triggers the AWS Lambda function each time an AppStream 2.0 resource is tagged. If the tag key is FleetName, and the fleet listed in the value exists in the account, the step functions state machine is started. At the defined maintenance window time, the state machine triggers the Lambda function to apply the image to the fleets. If the tag ScaleImmediately is set to True and the fleet is running, the scaling targets are set to zero. Finally, the state machine retries the Lambda function until the fleets reach zero available instances, and then sets the scaling targets back. The ScaleImmediately function is useful in environments where fleets are configured to use buffer of pre-created instances for new user sessions. When an updated image is applied, these buffer instances get the latest image when they are recreated.