logo
Menu
Building a workshop for AWS re:Invent - part 2

Building a workshop for AWS re:Invent - part 2

Ever wondered what goes into the design and delivery of content at re:Invent? This series of posts explores the process of creating a 300-level workshop for delivery at AWS re:Invent 2023. Part 2 takes a deeper dive into the CloudFormation code used.

Matt J
Amazon Employee
Published Feb 8, 2024

August: Code development

A significant part of the effort of building a workshop is focussed on the development of the AWS CloudFormation template(s) which deploy the resources that the participants will use. There are lots of approaches to authoring CloudFormation templates - if you are a developer, I would highly recommend tools such as the AWS Cloud Development Kit (CDK). For Laura and myself, we decided to write native CloudFormation (as YAML), because we were more experienced with this approach, and it gave us a bit more control over resource naming (useful when you need users to find specific resources).
Whilst most of the template is pretty straightforward (if long - around 7,500 lines of code), there are a few specific implementation details that are worth calling out; the first is around conditional resources, the second custom resources, and the third CloudWatch Dashboards.

Custom resources

Custom resources in CloudFormation allow you to provision resources and capabilities that might not otherwise be supported natively in CloudFormation. It consists of an AWS Lambda function that manages the resource, and a Custom CloudFormation resource that defines the properties and attributes that can be used within the rest of the CloudFormation template.
We use custom resources for a few things in the workshop; to discover the primary ENI ID and IPv6 addresses for instances; to get the AWS Network Firewall endpoint IDs for specific Availability Zones, and to get the GWLB VPC service endpoint ID.
Below is the Lambda function that powers a CloudFormation custom resource that we use to find two bits of information; the Elastic Network Interface (ENI) ID of an instance, and also the IPv6 address it has been assigned (if any). These are included in the responseData dictionary as Eni and Ipv6Address attributes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import json
import boto3
import cfnresponse

def handler(event, context):
instanceId = ""
try:
instanceId = event['ResourceProperties']['InstanceId']
except:
print("Invalid Instance ID")

ec2 = boto3.resource('ec2')
eni = ""
ipv6resp = ""

try:
instance = ec2.Instance(instanceId)
if len(instance.network_interfaces_attribute) > 0:
if instance.network_interfaces_attribute[0]['NetworkInterfaceId'] is not None:
eni = instance.network_interfaces_attribute[0]['NetworkInterfaceId']

if instance.network_interfaces_attribute[0]['Ipv6Addresses'] is not None:
if len(instance.network_interfaces_attribute[0]['Ipv6Addresses']) > 0:
if instance.network_interfaces_attribute[0]['Ipv6Addresses'][0]['Ipv6Address'] is not None:
ipv6resp = instance.network_interfaces_attribute[0]['Ipv6Addresses'][0]['Ipv6Address']
except:
print("Invalid Instance ID")

responseData = {}
responseData['Eni'] = eni
responseData['Ipv6Address'] = ipv6resp

try:
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData, instanceId)
except:
print("Cfn error")
We can then use this to create a CloudFormation custom resource, which uses the properties provided (the InstanceId) to create a logical resource publicInstanceNetworking:
1
2
3
4
5
publicInstanceNetworking: # RENAME Custom resource that gets the ENI of publicInstance - used in dashboards
Type: Custom::EniInfo
Properties:
ServiceToken: !GetAtt crEc2EniLambda.Arn
InstanceId: !Ref publicInstance
Once provisioned, this logical resource will have two attributes that can be referenced elsewhere in the CloudFormation template (ENI ID and IPv6 Address), using these code snippets: ${publicInstanceNetworking.Eni} and $publicInstanceNetworking.Ipv6Address.

CloudWatch Dashboards

To encourage participants to explore, we make extensive use of AWS CloudWatch Dashboards (one for each lab). These provide two valuable capabilities; they can provide context-sensitive information not available in the static content guide, and (through the use of custom widgets) also provide an up-to-date status on the completion of the lab tasks.

Contextual information

In many of the Labs, the participant will be asked to configure or change a pre-deployed resource (for example, to configure a route to a VPC endpoint). Since the details of some of these resources aren't known until the workshop is deployed, it means that the workshop guide cannot be specific about resource names, ENI IDs, etc. However, since we build the CloudWatch Dashboards as part of the same CloudFormation template, we can embed logical references to those resources within the dashboard definitions (using the ${ResourceName.Attribute} format), and allow CloudFormation to resolve them for us when the stack is deployed. This means that the Dashboard is able to display the details of the actual resources, unique to each participant.
This sounds more complicated than it is, and so I've provided an example below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
CloudWatchDashboardLabD:
Type: "AWS::CloudWatch::Dashboard"
Properties:
DashboardName: "LabD"
DashboardBody: !Sub |
{
"widgets": [
{
"type": "text",
"width": 11,
"height": 12,
"x": 2,
"y": 1,
"properties": {
"markdown": "<snip>The following items **may** be useful:\n
* **publicInstance IPv4:** ${publicInstance.PrivateIp}/32\n
* **publicInstance IPv6:** ${publicInstanceEniId.Ipv6Address}/128\n
* **Mirror Instance ENI:** ${mirrorInstanceEniId.Eni}\n
* **Public Instance ENI:** ${publicInstanceEniId.Eni}\n
* **Virtual Network ID:** 308308<snip>"
}
}
]
}
This appears as the following:
CloudWatch Dashboard showing contextual information
CloudWatch Dashboard showing contextual information

CloudWatch Dashboards - Custom Widgets

If you haven't come across Custom Widgets before, I highly recommend checking them out - they are incredibly useful for so many things! Custom widgets are Lambda functions that generate JSON, HTML or Markdown output, and which are then displayed on the dashboard. For our workshop, each dashboard is set to refresh every 60 seconds, and when it does, the Lambda function runs and updates the custom widget output.
In our case, we use a custom widget (one for each lab) to show progress against the tasks that need completing. Behind the scenes, the Lambda function is making calls to various AWS service APIs to see if the participant has configured a resource in the correct way. It then updates the output of the custom widget to show what progress has been made (and makes use of CloudWatch Dashboard's support for unicode to do so in a reasonably colourful way!).
As an example, in the Traffic Mirroring lab, participants need to complete a number of tasks, such as configuring a Mirror Target, defining a Mirror Filter, and establishing a Mirror Session. The Lambda function checks (every 60 seconds) if those tasks have been completed (by making API calls to the relevant services), and if they have been, updates the widget output to show that a task has been completed.
A snippet from one of the functions is shown below; note the use of the unicode &#.... codes to display ticks and crosses, without having to generate any custom images.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
r1 = f"\n* &#10060; Mirror Target: Target **has not** been configured."
r1done = 0
r2a = f"\n* &#10060; Inbound Filter (IPv4): HTTPS (TCP:443) responses " +
r2a = r2a + "from 0/0 **have not** been configured"
r2adone = 0

resp = ec2.describe_traffic_mirror_targets()

if not resp['TrafficMirrorTargets'] == []:
element_exist = '${mirrorInstanceEniId.Eni}' in resp['TrafficMirrorTargets'][0]['NetworkInterfaceId']
if element_exist:
r1 = f"\n* &#9989; Mirror Target: Target **has not** been configured."
r1done = 1

resp = ec2.describe_traffic_mirror_filters()

#IPv4
if not resp['TrafficMirrorFilters'] == []:
for in_rule in resp['TrafficMirrorFilters'][0]['IngressFilterRules']:
if in_rule.get('SourcePortRange'):
if (in_rule['RuleAction'] == "accept" and in_rule['Protocol'] == 6 \
and in_rule['SourceCidrBlock'] == "0.0.0.0/0" \
and in_rule['SourcePortRange']['FromPort'] == 443 \
and in_rule['SourcePortRange']['ToPort'] == 443):
r2a = f"\n* &#9989; Inbound Filter (IPv4): HTTPS (TCP:443) responses "
r2a = r2a + "from 0/0 **has** been configured"
r2adone = 1
for out_rule in resp['TrafficMirrorFilters'][0]['EgressFilterRules']:
if out_rule.get('DestinationPortRange'):
if (out_rule['RuleAction'] == "accept" and out_rule['Protocol'] == 6 \
and out_rule['DestinationCidrBlock'] == "0.0.0.0/0" \
and out_rule['DestinationPortRange']['FromPort'] == 443 \
and out_rule['DestinationPortRange']['ToPort'] == 443):
r3a = f"\n* &#9989; Outbound Filter (IPv4): HTTPS (TCP:443) requests "
r3a = r3a + "to 0/0 **has** been configured"
r3adone = 1
This renders in the CloudWatch dashboard similar to:
Cloudwatch Dashboard showing custom widget output
Cloudwatch Dashboard showing custom widget output

October: Review process

Dry runs

After the workshop is feature-complete, we then open it up to a series of "dry-runs"; this is where we invite a number of Amazonians (including both experts in networking and those with less direct experience of the topic) to run through the workshop from start to finish, and capture any errors, glitches, bugs, or suggestions on how to improve the attendee experience. These dry-runs are really important - when you've been working on a project for period of time, you can become "desensitised" and overlook things that people coming with a fresh viewpoint will quickly spot.

Security

Once the dry-runs are completed, we then look at performing a set of security reviews. We are designing and building with security in mind throughout the workshop (security is this highest priority at AWS!), but this stage provides a formalised, independent review of the workshop from a security perspective.
First up, we perform a series of automated code reviews using a range of open-source tooling. This includes cfn-lint (looking for specific CloudFormation errors or bad-practice) and cfn_nag (which has more of a security best-practice focus). Following this, we perform a scan of the workshop using ScoutSuite, which analyses the deployed resources looking for misconfigurations or bad practice.
It should be noted that in some cases, we make decisions to acknowledge but ignore warnings from these tools. Each of these decisions is made deliberately, with supporting reasoning, and is linked to the aims and objectives of the workshop. For example, cfn_nag recommends that AWS Lambda functions are executed within a VPC (to enable tracking and auditing of network calls). In this workshop, Lambda functions are there as a supporting function (such as CloudWatch dashboard custom widgets), and not processing critical data - hence, we choose to acknowledge this and ignore the warning.
You can see this in the CloudFormation template:, where we use resource metadata to suppress warnings:
1
2
3
4
5
6
7
8
9
10
crGwlbEndpointsLambda: # Custom resource for GWLB VPC Endpoint service
Type: AWS::Lambda::Function
Metadata:
cfn_nag:
rules_to_suppress:
- id: W89
reason: "Lambda function is only querying AWS APIs - no need to exist within a VPC"
Properties:
Handler: "index.handler"
Role: !GetAtt
The next stage is to complete a workshop audit; this is a 60-question report that ensures a high-bar is set for the quality of the workshop. It covers a range of technical areas, such as security, resilience, cost-optimisation, as well as other important checks, such as the use of inclusive language, back-out instructions for deployment in customer accounts, and image rights (if used).
Finally, the workshop code, documentation and audit report is reviewed by a Workshop Guardian; these are Amazonians, experts in the specific workshop domain, who have undertaken additional security training. They provide an independent assessment of the workshop, which cannot be published until they have given the go-ahead.
With that done, we're good to go - next stop Las Vegas, which we'll cover in part 3...

Any opinions in this post are those of the individual author and may not reflect the opinions of AWS.