AWS CloudFormation : Utiliser les Custom Resources

AWS CloudFormation : Utiliser les Custom Resources

AWS met à disposition les Custom Resources qui permettent de créer ses propres ressources dans CloudFormation.

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 :

  1. Récupérer des credentials stockés dans Secrets Manager afin de les utiliser pour créer nos bases de données.

  2. 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

Création des credentials dans Secrets Manager

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

Création du bucket S3

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

Création des fonctions Lambda

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 :

Capture-d-e-cran-2018-10-11-a--12.59.02

Création des Custom Resources

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 :

Capture-d-e-cran-2018-10-11-a--18.06.38

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.

Pour aller plus loin

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 :

  • Manipuler n’importe quelle ressource sur AWS, soit parce qu’elle n’est pas intégrée, soit parce que vous voulez la personnaliser.
  • Créer des fonctions (exécutées via Lambda), pour manipuler les paramètres dans vos templates CloudFormation, ou en générer des nouveaux (créer une clé unique ou un mot de passe par exemple).

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 !