Dynamic Infrastructure

Introduction

In addition to provisioning AWS Lambda functions, Sparta supports the creation of other CloudFormation Resources. This enables a service to move towards immutable infrastructure, where the service and its infrastructure requirements are treated as a logical unit.

For instance, consider the case where two developers are working in the same AWS account.

  • Developer 1 is working on analyzing text documents.
    • Their lambda code is triggered in response to uploading sample text documents to S3.
  • Developer 2 is working on image recognition.
    • Their lambda code is triggered in response to uploading sample images to S3.
graph LR sharedBucket[S3 Bucket] dev1Lambda[Dev1 LambdaCode] dev2Lambda[Dev2 LambdaCode] sharedBucket --> dev1Lambda sharedBucket --> dev2Lambda
This diagram is rendered with Mermaid. Please open an issue if it doesn't render properly.

Using a shared, externally provisioned S3 bucket has several impacts:

  • Adding conditionals in each lambda codebase to scope valid processing targets.
  • Ambiguity regarding which codebase handled an event.
  • Infrastructure ownership/lifespan management. When a service is decommissioned, its infrastructure requirements may be automatically decommissioned as well.
    • Eg, “Is this S3 bucket in use by any service?”.
  • Overly permissive IAM roles due to static Arns.
    • Eg, “Arn hugging”.
  • Contention updating the shared bucket’s notification configuration.

Alternatively, each developer could provision and manage disjoint topologies:

graph LR dev1S3Bucket[Dev1 S3 Bucket] dev1Lambda[Dev1 LambdaCode] dev2S3Bucket[Dev2 S3 Bucket] dev2Lambda[Dev2 LambdaCode] dev1S3Bucket --> dev1Lambda dev2S3Bucket --> dev2Lambda
This diagram is rendered with Mermaid. Please open an issue if it doesn't render properly.

Enabling each developer to create other AWS resources also means more complex topologies can be expressed. These topologies can benefit from CloudWatch monitoring (eg, per-Lambda Metrics ) without the need to add custom metrics.

graph LR dev1S3Bucket[Dev1 S3 Bucket] dev1Lambda[Dev1 LambdaCode] dev2S3Bucket[Dev2 S3 Images Bucket] dev2PNGLambda[Dev2 PNG LambdaCode] dev2JPGLambda[Dev2 JPEG LambdaCode] dev2TIFFLambda[Dev2 TIFF LambdaCode] dev2S3VideoBucket[Dev2 VideoBucket] dev2VideoLambda[Dev2 Video LambdaCode] dev1S3Bucket --> dev1Lambda dev2S3Bucket -->|SuffixFilter=*.PNG|dev2PNGLambda dev2S3Bucket -->|SuffixFilter=*.JPEG,*.JPG|dev2JPGLambda dev2S3Bucket -->|SuffixFilter=*.TIFF|dev2TIFFLambda dev2S3VideoBucket -->dev2VideoLambda
This diagram is rendered with Mermaid. Please open an issue if it doesn't render properly.

Sparta supports Dynamic Resources via TemplateDecorator functions.

Template Decorators

A template decorator is a Go function with the following signature

type TemplateDecorator func(serviceName string,
	lambdaResourceName string,
	lambdaResource gocf.LambdaFunction,
	resourceMetadata map[string]interface{},
	S3Bucket string,
	S3Key string,
	buildID string,
	cfTemplate *gocf.Template,
	context map[string]interface{},
	logger *logrus.Logger)  error {

}

Clients use go-cloudformation types for CloudFormation resources and template.AddResource to add them to the *template parameter. After a decorator is invoked, Sparta verifies that the user-supplied function has not produced entities that collide with the internally-generated ones.

Unique Resource Names

CloudFormation uses Logical IDs as resource key names.

To minimize collision likelihood, Sparta publishes CloudFormationResourceName(prefix, …parts) to generate compliant identifiers. To produce content-based hash values, callers can provide a non-empty set of values as the ...parts variadic argument. This produces stable identifiers across Sparta execution (which may affect availability during updates).

When called with only a single value (eg: CloudFormationResourceName("myResource")), Sparta will return a random resource name that is NOT stable across executions.

Example - S3 Bucket

Let’s work through an example to make things a bit more concrete. We have the following requirements:

  • Our lambda function needs a immutable-infrastructure compliant S3 bucket
  • Our lambda function should be notified when items are created or deleted from the bucket
  • Our lambda function must be able to access the contents in the bucket (not shown below)

Lambda Function

To start with, we’ll need a Sparta lambda function to expose:

func echoS3DynamicBucketEvent(event *json.RawMessage,
  context *sparta.LambdaContext,
  w http.ResponseWriter,
  logger *logrus.Logger) {

  config, _ := sparta.Discover()
  logger.WithFields(logrus.Fields{
    "RequestID":     context.AWSRequestID,
    "Event":         string(*event),
    "Configuration": config,
  }).Info("Request received")

  fmt.Fprintf(w, string(*event))
}

For brevity our demo function doesn’t access the S3 bucket objects. To support that we’ll need to discuss the sparta.Discover function in another section.

S3 Resource Name

The next thing we need is a Logical ID for our bucket:

s3BucketResourceName := sparta.CloudFormationResourceName("S3DynamicBucket", "myServiceBucket")

Sparta Integration

With these two values we’re ready to get started building up the lambda function:

lambdaFn := sparta.NewLambda(sparta.IAMRoleDefinition{}, echoS3DynamicBucketEvent, nil)

The open issue is how to publish the CloudFormation-defined S3 Arn to the compile-time application. Our lambda function needs to provide both:

  • IAMRolePrivilege values that reference the (as yet) undefined Arn.
  • S3Permission values to configure our lambda’s event triggers on the (as yet) undefined Arn.

The missing piece is gocf.Ref(), whose single argument is the Logical ID of the S3 resource we’ll be inserting in the decorator call.

Dynamic IAM Role Privilege

The IAMRolePrivilege struct references the dynamically assigned S3 Arn as follows:

lambdaFn.Permissions = append(lambdaFn.Permissions, sparta.S3Permission{
  BasePermission: sparta.BasePermission{
    SourceArn: gocf.Ref(s3BucketResourceName),
  },
  Events: []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*"},
})
lambdaFn.DependsOn = append(lambdaFn.DependsOn, s3BucketResourceName)

Dynamic S3 Permissions

The S3Permission struct also requires the dynamic Arn, to which it will append "/*" to enable object read access.

lambdaFn.RoleDefinition.Privileges = append(lambdaFn.RoleDefinition.Privileges,
  sparta.IAMRolePrivilege{
    Actions:  []string{"s3:GetObject", "s3:HeadObject"},
    Resource: spartaCF.S3AllKeysArnForBucket(gocf.Ref(s3BucketResourceName)),
  })

The spartaCF.S3AllKeysArnForBucket call is a convenience wrapper around gocf.Join to generate the concatenated, dynamic Arn expression.

S3 Resource Insertion

All that’s left to do is actually insert the S3 resource in our decorator:

lambdaFn.Decorator = func(lambdaResourceName string,
                          lambdaResource gocf.LambdaFunction,
                          template *gocf.Template,
                          logger *logrus.Logger) error {

  cfResource := template.AddResource(s3BucketResourceName, &gocf.S3Bucket{
    AccessControl: gocf.String("PublicRead"),
  })
  cfResource.DeletionPolicy = "Delete"
  return nil
}

Dependencies

In reality, we shouldn’t even attempt to create the AWS Lambda function if the S3 bucket creation fails. As application developers, we can help CloudFormation sequence infrastructure operations by stating this hard dependency on the S3 bucket via the DependsOn attribute:

lambdaFn.DependsOn = append(lambdaFn.DependsOn, s3BucketResourceName)

Code Listing

Putting everything together, our Sparta lambda function with dynamic infrastructure is listed below.

s3BucketResourceName := sparta.CloudFormationResourceName("S3DynamicBucket")

lambdaFn := sparta.NewLambda(sparta.IAMRoleDefinition{}, echoS3DynamicBucketEvent, nil)

// Our lambda function requires the S3 bucket
lambdaFn.DependsOn = append(lambdaFn.DependsOn, s3BucketResourceName)

// Add a permission s.t. the lambda function could read from the S3 bucket
lambdaFn.RoleDefinition.Privileges = append(lambdaFn.RoleDefinition.Privileges,
  sparta.IAMRolePrivilege{
    Actions:  []string{"s3:GetObject",
                       "s3:HeadObject"},
    Resource: spartaCF.S3AllKeysArnForBucket(gocf.Ref(s3BucketResourceName)),
  })

// Configure the S3 event source
lambdaFn.Permissions = append(lambdaFn.Permissions, sparta.S3Permission{
  BasePermission: sparta.BasePermission{
    SourceArn: gocf.Ref(s3BucketResourceName),
  },
  Events: []string{"s3:ObjectCreated:*",
                   "s3:ObjectRemoved:*"},
})

// Actually add the resource
lambdaFn.Decorator = func(lambdaResourceName string,
                          lambdaResource gocf.LambdaFunction,
                          template *gocf.Template,
                          logger *logrus.Logger) error {
  cfResource := template.AddResource(s3BucketResourceName, &gocf.S3Bucket{
    AccessControl: gocf.String("PublicRead"),
  })
  cfResource.DeletionPolicy = "Delete"
  return nil
}

Wrapping Up

Sparta provides an opportunity to bring infrastructure management into the application programming model. It’s still possible to use literal Arn strings, but the ability to include other infrastructure requirements brings a service closer to being self-contained and more operationally sustainable.

Notes

  • The echoS3DynamicBucketEvent function can also access the bucket Arn via sparta.Discover.
  • See the DeletionPolicy documentation regarding S3 management.
  • CloudFormation resources also publish other outputs that can be retrieved via gocf.GetAtt.
  • go-cloudformation exposes gocf.Join to create compound, dynamic expressions.
    • See the CloudWatch docs on Fn::Join for more information.