Operationalizing the AlienVault Sensor CloudFormation Template - Part 2

This is part 2 in a series of articles. To follow along via code, visit the Github repository.

In the last article, I showed how we can improve the operational capabilities of the AlienVault sensor deployment in AWS, simply by adding some automation and formatting changes to the generic CloudFormation template supplied to customers. Let's further improve the YAML template to make it more readable and less code-heavy by using the newest features of CloudFormation.

I mentioned previously that I had a hunch the AlienVault Sensor's CloudFormation template was made years ago, and this article further validates that theory. Let's start. We are going to make the following changes:

  1. Use intrinsic shortcuts
  2. Use string interpolation
  3. Use security group rule descriptions
  4. Use CloudFormation exports

Intrinsic Shortcuts are your Friend

The old way of declaring intrinsic functions is to use the Fn:: syntax, but that both creates added code and it requires arrays to be declared. However, now that the template is in YAML format, the CloudFormation team has introduced shortcuts, turning things like Fn::Equals into !Equals and Ref: into !Ref.

We can turn things like this:

Conditions:
  trafficMirroringEnabled:
    Fn::Equals:
    - 'Yes'
    - Ref: TrafficMonitoring
  publicIPEnabled:
    Fn::Equals:
    - 'Yes'
    - Ref: PublicIP

into a much more concise and readable:

Conditions:
  trafficMirroringEnabled: !Equals ['Yes', !Ref TrafficMirroring]
  publicIPEnabled: !Equals ['Yes', !Ref PublicIP]

Notice how multiple lines can be collapsed into one. Yes, there's a bit of a cheat here, since YAML can have arrays collapsed into a single line, so we're not buying much here, but the !Equals declaration reads a lot better than the previous version.

Some shortcuts immediately improve readability. When Fn::GetAtt is converted into !GetAtt, we get to use the dot-notation which looks a lot like code. So this:

GroupId:
  Fn::GetAtt:
  - USMLogServicesSG
  - GroupId

turns into this:

GroupId: !GetAtt USMLogServicesSG.GroupId

However, there are a few caveats with shortcuts. You can't mix two shortcuts together, so there are times when the Fn:: syntax is needed. Such as:

UserData:
  Fn::Base64:
    !Join
      - ''
      - - "{"
        - '"nodeName"     :"'
        - !Ref NodeName
        - "\","
        - '"environment"  :"prod",'
        - '"av_profile"   :"sensor",'
        - '"av_resources" :"wyns"'
        - "}"

You are correct if you are thinking this looks awful. It is okay to leave things alone. The good thing is that the next section will improve this particular scenario. The end result of using shortcuts is the template is more readable and it is now 426 lines long (a 38% reduction from the JSON template).

String Interpolation over Concatenation

One of the best intrinsic functions to be introduced is the !Sub or Fn::Sub function, because it provides string interpolation. This is such a phas change in template design that you can instantly tell the difference between old templates that use Fn::Join or !Join for string concatenation, and new templates that use string interpolation.

Performing string concatenation is fine enough, but it results in code that is very unreadable to humans. A CloudFormation template, just like any programming language, is meant to be read by humans first and foremost, then compiled into something run by machines (regardless of whether we see the compiled output). Adding string interpolation allows humans to read the string as it will look.

So we can take something like this:

- Fn::Join:
  - ''
  - - http://
    - !GetAtt USMInstance.PublicIp

and make it pretty like this:

- !Sub "http://${USMInstance.PublicIp}"

Much better, right? And what's more, resources being referenced by GetAtt are just variables anyway, so they can be reduced to their basic object dot-notation syntax without calling the GetAtt intrinsic function.

But there's another neat thing about !Sub that I like to use in templates, and that's for improving the look at feel of EC2 UserData blocks. These sections are just code blocks, and you will see them in various places, chief of which is UserData for EC2 instances, but also in Lambda functions too, as inline function code. If we add in the use of the YAML special character | – which provides multi-line strings that preserve line breaks – we get some awesome readability improvements.

We can turn this unholy mess:

UserData:
  Fn::Base64:
    !Join
      - ''
      - - "{"
        - '"nodeName"     :"'
        - !Ref NodeName
        - "\","
        - '"environment"  :"prod",'
        - '"av_profile"   :"sensor",'
        - '"av_resources" :"wyns"'
        - "}"

into this beauty that reads like WYSIWYG:

UserData:
  Fn::Base64: !Sub |
    {
      "nodeName"     : "${NodeName}",
      "environment"  : "prod",
      "av_profile"   : "sensor",
      "av_resources" : "wyns"
    }

Now this is great. What the original template designer didn't realize, and this boils my blood, is that the resulting JSON that they're producing with the !Join string concatenation method looks awful, which is just sloppy for a vendor to show to customers. This is what it looks like when rendered using their Join syntax (you'd see this on the EC2 console if you look at the instance's user data):

{"nodeName"     :"foobar","environment"   :"prod","av_profile"  :"sensor","av_resources" :"wyns"}

This is sloppy work. I'm certain the template design didn't expect output like that, otherwise why would they go to the trouble of indenting the JSON values? But in the end, it's just a mess.

So by using string interpolation, we have further improved readability and reduced the file size to 414 lines (a 40% reduction from the JSON template).

Security Group Rules Now Allow Descriptions

A very recent improvement from the CloudFormation team is the welcome addition of descriptions to ingress and egress rules on security groups. I don't fault the template designers for this, and I can see how they worked around the lack of descriptions on the ingress rules. Since JSON doesn't allow comments, they created separate AWS::EC2::SecurityGroupIngress resources so that the logical resource ID can act as the descriptive element. That's not needed anymore.

We can take this:

USMTrafficInterfaceSG:
  Condition: trafficMirroringEnabled
  Type: AWS::EC2::SecurityGroup
  Properties:
    VpcId: !Ref VpcId
    GroupDescription: >
      Enable USM Traffic Mirror Connectivity on your USM Sensor Traffic
      Network Interface.
SGAllowVXLanTrafficSG:
  Condition: trafficMirroringEnabled
  DependsOn: USMTrafficInterfaceSG
  Type: AWS::EC2::SecurityGroupIngress
  Properties:
    GroupId: !GetAtt USMTrafficInterfaceSG.GroupId
    IpProtocol: udp
    FromPort: '4789'
    ToPort: '4789'
    SourceSecurityGroupId: !GetAtt USMEnableTrafficMirroringSG.GroupId
SGAllowHTTPHealthCheckSG:
  Condition: trafficMirroringEnabled
  DependsOn: USMTrafficInterfaceSG
  Type: AWS::EC2::SecurityGroupIngress
  Properties:
    GroupId: !GetAtt USMTrafficInterfaceSG.GroupId
    IpProtocol: tcp
    FromPort: '80'
    ToPort: '80'
    SourceSecurityGroupId: !GetAtt USMEnableTrafficMirroringSG.GroupId

and collapse into a single security group resource:

USMTrafficInterfaceSG:
  Condition: trafficMirroringEnabled
  Type: AWS::EC2::SecurityGroup
  Properties:
    VpcId: !Ref VpcId
    GroupDescription: >
      Enable USM Traffic Mirror Connectivity on your USM Sensor Traffic
      Network Interface.
    SecurityGroupIngress:
      - Description: SGAllowVXLanTrafficSG
        IpProtocol: udp
        FromPort: '4789'
        ToPort: '4789'
        SourceSecurityGroupId: !GetAtt USMEnableTrafficMirroringSG.GroupId
      - Description: SGAllowHTTPHealthCheckSG:
        IpProtocol: tcp
        FromPort: '80'
        ToPort: '80'
        SourceSecurityGroupId: !GetAtt USMEnableTrafficMirroringSG.GroupId

I've kept the Description attribute the same as the original logical resource ID for display purposes, but I would make this more explicit if I knew exactly what it was doing. This change has a lot of benefits, it:

  • saves a comment
  • reduces the number of reference lookups needed
  • removes the explicit dependency chains; and
  • moves the ingress rules into the same block as the security group, rather than requiring the reader to jump around and keep a mental model of the infrastructure (always ripe for error).

Of course, this only works because – and I checked – the ingress rules are only referenced by a single security group resource. If an ingress rule was shared with multiple resources, that would be a different optimization and I'd be okay with leaving it as-is. However, in this case our change resulted in a massively improved reading level for each security group's ruleset, plus reduced the file size to 383 lines (a 44% reduction from the JSON template).

Exports Help with Extensibility

Now we're going to make a change that increases the file size, but provides a lot of extensibility for future integrations. CloudFormation introduced the concept of exports years ago, which allows one to dynamically look up - without knowing the stack name - any resource created by a CloudFormation stack. While these exports won't be used by the AlienVault stack, I like to export them in the event someone wants to use the values in another stack via the !ImportValue intrinsic function.

Each output will now get an export name that is unique to the AWS region. I like to construct these names with : delimiters so that there is namespacing available. The Outputs section then becomes:

Outputs:
  URL:
    Value:
      Fn::If:
      - publicIPEnabled
      - !Sub "http://${USMInstance.PublicIp}"
      - !Sub "http://console.aws.amazon.com/ec2/home?region=${AWS::Region}#Instances:search=${USMInstance}"
    Description: >
      Visit this page to perform the initial configuration of your USM
      Anywhere Sensor.
    Export:
      Name: "alienvault:endpoint:web:url"
  CLIUser:
    Description: Default Command Line Interface User.
    Value: sysadmin
    Export:
      Name: "alienvault:endpoint:ssh:username"
  CLIUserKey:
    Description: Default Command Line Interface User SSH key.
    Value: !Ref KeyName
    Export:
  Name: "alienvault:endpoint:ssh:keypair"
InstanceZone:
  Description: Availability Zone where the instance is deployed.
  Value: !GetAtt USMInstance.AvailabilityZone
  Export:
    Name: "alienvault:ec2:instance:az"

In the future, if someone wants to look at all the stack exports for this project, they can issue this call:

aws cloudformation list-exports --query 'Exports[?starts_with(Name, `alienvault:`]'

For an extra bit of flair, I typically create a parameter called ProjectName and set it to a default of alienvault here. This would make the exports more sustainable by, for instance, changing the name from alienvault:ec2:instance:az" to !Sub "${ProjectName}:ec2:instance:az".

This change provides the present version of ourselves with little benefit, but immense benefits for our future selves. It increases the file size to 391 lines (but still a reduction of 43% over the JSON template).

Stay tuned for the next article where I discuss how a review of a the template's infrastructure can be performed from the perspective of putting this into business-critical or "Production" use. This review will help us determine the next steps to take in our refactoring and operationalization activity.