Skip to content

Commit 69b4567

Browse files
aeghbali-mwGitHub Enterprise
authored and
GitHub Enterprise
committed
Add EBS snapshot lambda (#25)
1 parent 4dcc161 commit 69b4567

File tree

2 files changed

+295
-0
lines changed

2 files changed

+295
-0
lines changed

aws/ebs-snapshot-lambda/v1/.version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v1.0.0
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
AWSTemplateFormatVersion: '2010-09-09'
2+
Description: Creates EBS snapshot of an EC2 volume when stack is deleted.
3+
4+
Parameters:
5+
EC2InstanceId:
6+
Type: String
7+
Description: The ID of the EC2 instance.
8+
AllowedPattern: ^i-[a-zA-Z0-9]{8,17}$
9+
ConstraintDescription: Must be a valid EC2 instance ID starting with 'i-' followed by 8-17 alphanumeric characters
10+
11+
VolumeDeviceName:
12+
Type: String
13+
Description: The device name of the volume attached to the EC2 instance.
14+
AllowedPattern: ^/dev/[a-zA-Z0-9]+$
15+
ConstraintDescription: Must be a valid device name starting with '/dev/'
16+
17+
ProductId:
18+
Type: String
19+
Description: The MathWorks product ID associated with the EC2 instance.
20+
MinLength: 1
21+
MaxLength: 128
22+
AllowedPattern: ^[a-zA-Z0-9\-_]+$
23+
ConstraintDescription: Product ID must be 1-128 alphanumeric characters, hyphens or underscores
24+
25+
Tags:
26+
Type: String
27+
Description: Tags to add to the snapshot in the form of "<key1>=<value1>,<key2>=<value2>". Can be empty.
28+
29+
PreSnapshotCommand:
30+
Type: String
31+
Description: Optional SSM command to run before snapshot creation. Ensure your command can be executed in 60 seconds or less; otherwise, your command might time out, and the snapshot will be created without running the command. The command must be in the valid format '{{DocumentName}} COMMAND'. DocumentName must be either 'AWS-RunShellScript' or 'AWS-RunPowerShellScript'.
32+
Default: ""
33+
34+
CustomExecutionRoleArn:
35+
Type: String
36+
Default: ""
37+
Description: (Optional) ARN of an existing IAM role to be used by the lambda function. Make sure that the IAM role has the necessary permissions to create EBS snapshots. If you leave this parameter blank, a new IAM role will be created.
38+
AllowedPattern: ^(arn:(aws|aws-cn|aws-us-gov):iam::\d{12}:role(\/[\w-]*)*)?$
39+
40+
41+
Conditions:
42+
HasSSMCommand: !Not [!Equals [!Ref PreSnapshotCommand, ""]]
43+
CreateNewRole: !Equals [!Ref CustomExecutionRoleArn, '']
44+
45+
Resources:
46+
LambdaExecutionRole:
47+
Type: AWS::IAM::Role
48+
Condition: CreateNewRole
49+
Properties:
50+
AssumeRolePolicyDocument:
51+
Version: '2012-10-17'
52+
Statement:
53+
- Effect: Allow
54+
Principal:
55+
Service:
56+
- lambda.amazonaws.com
57+
Action:
58+
- sts:AssumeRole
59+
Path: /MW/
60+
Policies:
61+
- PolicyName: LambdaEC2Policy
62+
PolicyDocument:
63+
Version: '2012-10-17'
64+
Statement:
65+
- Sid: ReadOnlyPermissions
66+
Effect: Allow
67+
Action:
68+
- ec2:DescribeInstances
69+
- ec2:DescribeVolumes
70+
- ssm:ListCommandInvocations
71+
Resource: '*'
72+
- Sid: SnapshotPermissions
73+
Effect: Allow
74+
Action:
75+
- ec2:CreateSnapshot
76+
- ec2:CreateTags
77+
Resource:
78+
- !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:volume/*
79+
- !Sub arn:${AWS::Partition}:ec2:${AWS::Region}::snapshot/*
80+
- Sid: CWLoggingPermissions
81+
Effect: Allow
82+
Action:
83+
- logs:CreateLogGroup
84+
- logs:CreateLogStream
85+
- logs:PutLogEvents
86+
Resource:
87+
- !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*MWSnapshotCreationFunction*
88+
- !If
89+
- HasSSMCommand
90+
- Sid: SSMPermissions
91+
Effect: Allow
92+
Action:
93+
- ssm:SendCommand
94+
Resource:
95+
- !Sub arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-RunShellScript
96+
- !Sub arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-RunPowerShellScript
97+
- !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/${EC2InstanceId}
98+
- !Ref "AWS::NoValue"
99+
100+
MWSnapshotCreationFunction:
101+
Type: AWS::Lambda::Function
102+
Properties:
103+
Handler: index.handler
104+
Role: !If
105+
- CreateNewRole
106+
- !GetAtt LambdaExecutionRole.Arn
107+
- !Ref CustomExecutionRoleArn
108+
Runtime: python3.13
109+
MemorySize: 512
110+
Timeout: 300
111+
Code:
112+
ZipFile: |
113+
import boto3
114+
import cfnresponse
115+
import logging
116+
import time
117+
import re
118+
119+
logger = logging.getLogger()
120+
logger.setLevel(logging.INFO)
121+
122+
def handler(event, context):
123+
logger.info(f"Received event: {event}")
124+
125+
if event['RequestType'] != 'Delete':
126+
logger.info("Request type is not 'Delete'. Sending success response.")
127+
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
128+
return
129+
130+
try:
131+
ec2_instance_id = event['ResourceProperties']['EC2InstanceId']
132+
volume_device_name = event['ResourceProperties']['VolumeDeviceName']
133+
product_id = event['ResourceProperties']['ProductId']
134+
tags_str = event['ResourceProperties'].get('Tags', '')
135+
136+
optional_ssm_command = event['ResourceProperties'].get('PreSnapshotCommand', '')
137+
_handle_optional_ssm_command(ec2_instance_id, optional_ssm_command)
138+
139+
snapshot_id = _create_snapshot(ec2_instance_id, volume_device_name, product_id, tags_str)
140+
message = f"Snapshot created successfully. Snapshot ID: {snapshot_id}"
141+
logger.info(message)
142+
143+
cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Message': message}, "SnapshotCustomResource", False, message)
144+
except Exception as e:
145+
logger.error(f"Error occurred: {str(e)}")
146+
cfnresponse.send(event, context, cfnresponse.FAILED, {})
147+
148+
149+
def _handle_optional_ssm_command(ec2_instance_id, optional_ssm_command):
150+
if optional_ssm_command:
151+
logger.info(f"Executing SSM Command: {optional_ssm_command} on Instance ID: {ec2_instance_id}")
152+
document_name, command = extract_document_and_command(optional_ssm_command)
153+
ssm = boto3.client('ssm')
154+
response = ssm.send_command(
155+
InstanceIds=[ec2_instance_id],
156+
TimeoutSeconds=60,
157+
DocumentName=document_name,
158+
Parameters={
159+
'commands': [command]
160+
},
161+
Comment=f'Run script before EBS snapshot for EC2 {ec2_instance_id}.'
162+
)
163+
command_id = response['Command']['CommandId']
164+
start_time = time.time()
165+
while (time.time() - start_time) < 60:
166+
logger.info("Waiting for SSM Command execution ...")
167+
time.sleep(5)
168+
invocation_result = ssm.list_command_invocations(
169+
CommandId=command_id,
170+
InstanceId=ec2_instance_id,
171+
Details=True
172+
)
173+
174+
if invocation_result['CommandInvocations']:
175+
invocation = invocation_result['CommandInvocations'][0]
176+
status = invocation['Status']
177+
command_output = invocation.get('CommandPlugins', [{}])[0].get('Output', None)
178+
logger.info(f"SSM command status: {status}")
179+
logger.info(f"SSM command output: {command_output}")
180+
181+
if status in ['Success', 'Failed', 'TimedOut', 'Cancelled']:
182+
logging.info(f"SSM command execution completed with status: {status}")
183+
break
184+
185+
def _create_snapshot(ec2_instance_id, volume_device_name, product_id, tags_str):
186+
ec2 = boto3.client('ec2')
187+
188+
logger.info(f"Fetching EC2 details for Instance ID: {ec2_instance_id}")
189+
response = ec2.describe_instances(InstanceIds=[ec2_instance_id])
190+
instances = response.get('Reservations', [])
191+
if not instances:
192+
logger.info("No instances found for the given EC2 Instance ID.")
193+
raise Exception("Instance not found.")
194+
195+
volumes = instances[0]['Instances'][0]['BlockDeviceMappings']
196+
volume_id = None
197+
for volume in volumes:
198+
if volume['DeviceName'] == volume_device_name:
199+
logger.info(f"Found volume with device name: {volume_device_name}")
200+
volume_id = volume['Ebs']['VolumeId']
201+
break
202+
203+
if not volume_id:
204+
logger.info(f"Volume not found for device name: {volume_device_name}")
205+
raise Exception("Volume not found.")
206+
207+
logging.info("Preparing snapshot tags ...")
208+
tags = [{'Key': 'mw-ProductId', 'Value': product_id}]
209+
210+
if tags_str:
211+
existing_keys = {tag['Key'] for tag in tags} # Set of existing keys to check for duplicates
212+
additional_tags = [
213+
{'Key': k, 'Value': v}
214+
for k, v in (tag.split('=') for tag in tags_str.split(','))
215+
if k not in existing_keys
216+
]
217+
tags.extend(additional_tags)
218+
219+
logger.info(f"Creating snapshot for Volume ID: {volume_id} with tags: {tags}")
220+
221+
snapshot = ec2.create_snapshot(
222+
VolumeId=volume_id,
223+
Description= f"This snapshot was created by MathWorks IaC when you deleted your stack for the product: '{product_id}'. Check the tags to learn more about the deleted stack.",
224+
TagSpecifications=[{
225+
'ResourceType': 'snapshot',
226+
'Tags': tags
227+
}]
228+
)
229+
230+
snapshot_id = snapshot['SnapshotId']
231+
232+
logging.info(f"Snapshot creation completed successfully. Snapshot ID: {snapshot_id}")
233+
return snapshot_id
234+
235+
def extract_document_and_command(input_string):
236+
pattern = r"\{\{(.*?)\}\}(.*)"
237+
match = re.match(pattern, input_string)
238+
239+
if match:
240+
document_name = match.group(1)
241+
command = match.group(2).strip()
242+
return document_name, command
243+
else:
244+
raise Exception("Document name or command couldn't be fetched successfully. Make sure 'PreSnapshotCommand' is in valid form of '{{DocumentName}}COMMAND'.")
245+
246+
SnapshotCustomResource:
247+
Type: Custom::SnapshotResource
248+
Properties:
249+
ServiceToken: !GetAtt MWSnapshotCreationFunction.Arn
250+
ServiceTimeout: 300
251+
EC2InstanceId: !Ref EC2InstanceId
252+
VolumeDeviceName: !Ref VolumeDeviceName
253+
Tags: !Ref Tags
254+
PreSnapshotCommand: !Ref PreSnapshotCommand
255+
ProductId: !Ref ProductId
256+
257+
Metadata:
258+
AWS::CloudFormation::Interface:
259+
ParameterGroups:
260+
- Label:
261+
default: "Instance Configuration"
262+
Parameters:
263+
- EC2InstanceId
264+
- VolumeDeviceName
265+
- ProductId
266+
267+
- Label:
268+
default: "Snapshot Settings"
269+
Parameters:
270+
- Tags
271+
- PreSnapshotCommand
272+
273+
- Label:
274+
default: "Advanced Configuration"
275+
Parameters:
276+
- CustomExecutionRoleArn
277+
278+
ParameterLabels:
279+
EC2InstanceId:
280+
default: "EC2 Instance ID"
281+
VolumeDeviceName:
282+
default: "Volume Device Name"
283+
ProductId:
284+
default: "MathWorks Product ID"
285+
Tags:
286+
default: "Snapshot Tags"
287+
PreSnapshotCommand:
288+
default: "Pre-snapshot SSM Command"
289+
CustomExecutionRoleArn:
290+
default: "Lambda Execution Role ARN"
291+
Outputs:
292+
SnapshotFunctionArn:
293+
Description: The ARN of the snapshot creation Lambda function.
294+
Value: !GetAtt MWSnapshotCreationFunction.Arn

0 commit comments

Comments
 (0)