Dans CloudFormation, vous allez définir vos différentes ressources qui correspondent à des types proposées par AWS, comme les EC2, les S3, les RDS, etc. La Custom Resource est un type de ressource qui permet de rentrer des paramètres en entrée, de pointer sur une ressource AWS comme SNS ou Lambda (dans notre cas nous utiliserons Lambda), puis de récupérer une valeur en retour. Les Custom Resources vous permettent donc d'écrire une logique de mise en service personnalisée dans les modèles que CloudFormation exécute chaque fois que vous créez, mettez à jour ou supprimez des stacks.
La Custom Resource est donc un outil très pratique qui permet d’étendre les fonctionnalités de CloudFormation afin d’agir sur des composants AWS qui ne sont pas couverts par l'outil. Plus généralement, les Custom Ressources servent aussi à rajouter des traitements complémentaires dans le code déclaratif de l'infrastructure.
Nous allons voir les différentes étapes de mise en place du pattern des Custom Resources à travers 2 exemples d'applications simples et utiles :
Récupérer des credentials stockés dans Secrets Manager afin de les utiliser pour créer nos bases de données.
Faire un ensemble de fonctions StringUtils qui permet de faire des traitements, comme mettre le texte en majuscules, minuscules ou faire des remplacements (celles-ci n’étant pas intégrées de base dans CloudFormation).
Si besoin, le code est disponible ici et l’arborescence va au final ressembler à ça :
├── build
│ └── lambdas
│ └── cfn_custom_resources.zip
├── cfn
│ ├── create-lambda-functions.yml
│ ├── create-s3.yml
│ ├── custom-resources-usage.yml
│ └── params.json
├── creds.json
├── deploy-lambda.sh
└── lambdas
└── cfn_custom_resources
├── cfn_resource.py
├── credentials.py
└── str_utils.py
On pousse les credentials via l’aws cli, comme ceci, l’objectif étant que ces variables ne soient à aucun moment visibles au niveau de CloudFormation :
cat <<EOF > creds.json
{
"username": "admin",
"password": "password"
}
EOF
aws secretsmanager create-secret --name "test/creds" --secret-string file://creds.json
Nous allons avoir besoin de stocker le code Python sur S3, afin de créer nos fonctions lambda depuis CloudFormation :
mkdir cfn
cat <<EOF > cfn/params.json
[
{
"ParameterKey": "MyBucket",
"ParameterValue": "CHANGEME"
}
]
EOF
Créons un template CloudFormation “cfn/create-s3.yml”
---
AWSTemplateFormatVersion: 2010-09-09
Description: Cfn exercise - S3 bucket
Parameters:
MyBucket:
Type: String
Resources:
S3Bucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Ref 'MyBucket'
Outputs:
S3DomainName:
Value: !GetAtt 'S3Bucket.DomainName'
Puis, lançons la création de la stack :
aws cloudformation create-stack --stack-name cfn-exercise-s3 --template-body file://./cfn/create-s3.yml --parameters file://./cfn/params.json
Nous allons utiliser un wrapper fournit par Ryan Brown, qui permet d’exécuter une fonction Lambda Python, interfacée avec une Custom Resource CloudFormation.
Le wrapper va permettre d’interfacer le format en entrée et en sortie qui est attendu par CloudFormation pour communiquer avec les fonctions Lambda, d’après les préconisations d’AWS, voici l’échantillon de données envoyé à la Lambda depuis CloudFormation (qui renseigne sur le type de requête, l’id de la stack, les properties, qui seront les paramètres qu’on envoie, etc.) :
{
"RequestType" : "Create",
"ResponseURL" : "http://pre-signed-S3-url-for-response",
"StackId" : "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid",
"RequestId" : "unique id for this create request",
"ResourceType" : "Custom::TestResource",
"LogicalResourceId" : "MyTestResource",
"ResourceProperties" : {
"Name" : "Value",
"List" : [ "1", "2", "3" ]
}
}
Créons un fichier lambdas/cfn_custom_resources/cfn_resource.py
contenant le code du wrapper.
Ajoutons la fonction Lambda lambdas/cfn_custom_resources/credentials.py
import boto3
import base64
import json
from botocore.exceptions import ClientError
import cfn_resource
handler = cfn_resource.Resource()
@handler.create
@handler.update
def get_credentials(event, context):
props = event['ResourceProperties']
secret_manager_key = props['SecretManagerKey']
try:
json_value = json.loads(get_secret_value(secret_manager_key))
return {
'Status': 'SUCCESS',
'PhysicalResourceId': "%s/credentials" % secret_manager_key,
'Data': {
'Username': json_value['username'],
'Password': json_value['password']
}
}
except ValueError:
return {
'Status': 'FAILED',
'Reason': 'Can''t find or retrieve secret from key %s ' % secret_manager_key
}
@handler.delete
def on_delete(event, context):
return {
'Status': cfn_resource.SUCCESS
}
def get_secret_value(secret_manager_key):
client = boto3.client(service_name='secretsmanager')
try:
get_secret_value_response = client.get_secret_value(SecretId=secret_manager_key)
except ClientError as e:
raise e
else:
if 'SecretString' in get_secret_value_response:
return get_secret_value_response['SecretString']
else:
return base64.b64decode(get_secret_value_response['SecretBinary'])
Puis la fonction lambdas/cfn_custom_resources/str_utils.py
import boto3
import base64
import json
from botocore.exceptions import ClientError
import cfn_resource
handler = cfn_resource.Resource()
@handler.create
@handler.update
def strutils(event, context):
props = event['ResourceProperties']
string = props['String']
func = props['Function']
if 'Args' in props:
args = props['Args']
else:
args = []
physical_id = "%s/str/%s/func" % (string, func)
if (args):
args_str = '-'.join(args)
physical_id = "%s/%s/args" % (physical_id, args_str)
try:
if (func == 'upper'):
res = string.upper()
elif (func == 'lower'):
res = string.lower()
elif (func == 'replace'):
res = string.replace(args[0], args[1])
return {
'Status': 'SUCCESS',
'PhysicalResourceId': physical_id,
'Data': {
'Res': res
}
}
except ValueError:
return {
'Status': 'FAILED',
'Reason': 'Error during the treatment of the string %s with the function %s (%s) ' % (string, func, args)
}
@handler.delete
def on_delete(event, context):
return {
'Status': cfn_resource.SUCCESS
}
Notez que vous avez à disposition les handlers, qui permettent de lancer des fonctions différentes selon le type de request qui est envoyé par CloudFormation : CREATE, UPDATE ou DELETE.
Pour envoyer les fonctions sur S3 (pensez à mettre à jour le nom du bucket) :
export MY_BUCKET=CHANGEME
mkdir -p build/lambdas
zip build/lambdas/cfn_custom_resources.zip -r lambdas/cfn_custom_resources/* -j
chmod 744 build/lambdas/cfn_custom_resources.zip
aws s3 cp build/lambdas/cfn_custom_resources.zip s3://${MY_BUCKET}/lambdas/
On peut maintenant créer le template CF pour les fonctions Lambda :
---
AWSTemplateFormatVersion: 2010-09-09
Description: Cfn exercise - custom resources
Parameters:
MyBucket:
Type: String
Resources:
SecretManagerCredentials:
Type: AWS::Lambda::Function
Properties:
Code:
S3Bucket: !Ref 'MyBucket'
S3Key: lambdas/cfn_custom_resources.zip
Handler: credentials.handler
MemorySize: 128
Role: !Sub '${LambdaRole.Arn}'
Runtime: python3.6
Timeout: 60
Description: |
This lambda brings cloud formation custom resource
about credentials
StrUtils:
Type: AWS::Lambda::Function
Properties:
Code:
S3Bucket: !Ref 'MyBucket'
S3Key: lambdas/cfn_custom_resources.zip
Handler: str_utils.handler
MemorySize: 128
Role: !Sub '${LambdaRole.Arn}'
Runtime: python3.6
Timeout: 60
Description: |
This lambda brings cloud formation custom resource
about string utils
LambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AdministratorAccess
Outputs:
SecretManagerCredentialsFunction:
Value: !GetAtt 'SecretManagerCredentials.Arn'
Export:
Name: 'cfn-exercise-lambda-SecretManagerCredentials'
StrUtilsFunction:
Value: !GetAtt 'StrUtils.Arn'
Export:
Name: 'cfn-exercise-lambda-StrUtils'
Puis créons la stack :
aws cloudformation create-stack --stack-name cfn-exercise-lambda-functions --template-body file://./cfn/create-lambda-functions.yml --parameters file://./cfn/params.json --capabilities CAPABILITY_NAMED_IAM
Il est possible de tester la fonction Lambda directement en créant un Test Event via la console AWS :
Créons le templatecfn/custom-resources-usage.yml
, qui a pour but de tester l’utilisation de nos Custom Resources :
---
AWSTemplateFormatVersion: 2010-09-09
Description: Cfn exercise - custom resources usage
Parameters:
String:
Type: String
Default: Toto
Resources:
SecretManagerCredentials:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken:
Fn::ImportValue: 'cfn-exercise-lambda-SecretManagerCredentials'
SecretManagerKey: 'test/creds'
StrToLower:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken:
Fn::ImportValue: 'cfn-exercise-lambda-StrUtils'
String: !Ref 'String'
Function: 'lower'
StrToUpper:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken:
Fn::ImportValue: 'cfn-exercise-lambda-StrUtils'
String: !Ref 'String'
Function: 'upper'
StrReplace:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken:
Fn::ImportValue: 'cfn-exercise-lambda-StrUtils'
String: !Ref 'String'
Function: 'replace'
Args:
- 'Toto'
- 'Titi'
Outputs:
# Here are the values we will focus on :
SecretUsername:
Value: !Sub '${SecretManagerCredentials.Username}'
SecretPassword:
Value: !Sub '${SecretManagerCredentials.Password}'
StrLower:
Value: !Sub '${StrToLower.Res}'
StrUpper:
Value: !Sub '${StrToUpper.Res}'
StrReplace:
Value: !Sub '${StrReplace.Res}'
Puis lançons la création de la stack :
aws cloudformation create-stack --stack-name cfn-exercise-custom-resources-usage --template-body file://./cfn/custom-resources-usage.yml --timeout-in-minutes 5
Maintenant, vous devriez voir les outputs générés par les Custom Resources :
Nous récupérons donc bien les secrets que nous pourrons utiliser dans nos stacks, ainsi que les variables qui ont été traitées avec nos fonctions.
Lorsque vous faites appel à des Custom Resources dans vos scripts, il y a une adhérence forte au niveau de CloudFormation. Vous ne pourrez pas supprimer vos fonctions Lambda tant qu’elles seront consommées. il faudra donc versionner le nom des ressources afin de pouvoir libérer les anciennes et effectuer un roulement.
Par exemple, au niveau de la création des lambdas :
Resources:
SecretManagerValueV2:
Type: AWS::Lambda::Function
SecretManagerValueV3:
Type: AWS::Lambda::Function
Outputs:
SecretManagerValueFunctionV2:
Value: !GetAtt 'SecretManagerValueV2.Arn'
Export:
Name: 'SecretManagerValueV2'
SecretManagerValueFunctionV3:
Value: !GetAtt 'SecretManagerValueV3.Arn'
Export:
Name: 'SecretManagerValueV3'
Puis au niveau des Custom Resources :
Resources:
RedshiftCluster:
Type: AWS::Redshift::Cluster
Properties:
MasterUsername: !Sub '${RedshiftSecretManagerValueV3.UserName}'
MasterUserPassword: !Sub '${RedshiftSecretManagerValueV3.Password}'
RedshiftSecretManagerValueV2:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken:
Fn::ImportValue: 'lambda-SecretManagerValueV2'
SecretManagerKey: !Sub '${Env}/${App}/redshift'
RedshiftSecretManagerValueV3:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken:
Fn::ImportValue: 'lambda-SecretManagerValueV3'
SecretManagerKey: !Sub '${Env}/${App}/redshift'
En appliquant les versions on peut mettre à jour la Custom Resource consommée par la ressource RedshiftCluster
, pour passer de la V2 à la V3, puis lorsque la V2 est libérée, on peut supprimer la Custom Resource V2, puis la Lambda V2.
Les Custom Resources vont vous permettre d’ajouter des fonctionnalités à CloudFormation, sans avoir à passer par un système de pre-processing, ou d’attendre que AWS les mette en place.
Vous pouvez :
C'est finalement assez simple une fois qu’on a la technique et c’est une fonctionnalité qui permet d’étendre clairement les fonctionnalités de CloudFormation, faites-en bon usage et soyez créatifs !