Patterns of Use¶
The patterns contained in this module support our use cases for services hosted by Thunderbird Pro Services. To summarize a bit from the Getting Started page:
We extend the
pulumi.ComponentResourceclass into atb_pulumi.ThunderbirdComponentResourceclass, which exposes the resources contained within it to higher-order code.These are organized into
tb_pulumi.ThunderbirdPulumiProjects, which connect YAML configuration files to Pulumi code files and provide programmatic access to all components within it.
This page explains these conventions in clearer detail and describes the common code patterns that follow from adhering to them.
Patterns for Managing Projects¶
One primary goal of this project is to reduce most infrastructural changes to YAML file tweaks once the initial setup is
done, requiring no code inspection or debugging for most common changes. The
tb_pulumi.ThunderbirdPulumiProject class brings those configs, information about the cloud environment, and
Pulumi project and stack data into a single context. That context is then made available to any
tb_pulumi.ThunderbirdComponentResource created within that project.
Given that you have…
created a Pulumi stack with a
$STACK_NAME,properly written a
config.$STACK_NAME.yamlfile alongside your tb_pulumi code, andconfigured your AWS client,
…the following code will produce a ThunderbirdPulumiProject with all context availablle for the currently selected stack:
import tb_pulumi
project = tb_pulumi.ThunderbirdPulumiProject()
For example, you can now get the resources mapping from your configuration like so:
resources = project.config.get('resources')
At this point, if you have not defined a resources section in your config file, resources will be None. But
if you have a good config, it will be a dict where the top-level keys are Pulumi type strings.
You could, of course, do this as well:
resources = project.config['resources']
But in the case where the config file is not set up right, this results in a KeyError. This is fine, and you should
raise an exception in this case anyway. We find the .get() method is more elegant.
Consider this approach instead:
resources = project.config.get('resources')
if not resources:
raise ValueError(f'tb_pulumi is not configured for stack {project.stack}.')
Also consider the case where you need access to a nested option. The
# This prints a big stack trace ending in a KeyError
child_config = resources['parent_config']['child_config']
# If you want to emit a custom error
try:
child_config = resources['parent_config']['child_config']
except KeyError as ex:
pulumi.error('No child config')
# You can either hard sys.exit
import sys
sys.exit(1)
# Or re-raise the exception
raise ex
# But in this form, child_config gets None, allowing us to gracefully handle the failure
# with little code and extra output.
child_config = resources.get('parent_config', {}).get('child_config')
if not child_config:
raise ValueError('No child config')
Resource Patterns¶
Define a resource with no dependencies¶
The simplest resources stand alone, depending on no other resources, only a valid configuration. Even simpler is when
you must create only one. Consider this YAML config and Python definition of a MultiCidrVpc:
---
resources:
tb:network:MultiCidrVpc:
vpc:
cidr_block: 10.0.0.0/16
subnets:
eu-central-1a:
- 10.0.0.0/17
eu-central-1b:
- 10.0.128.0/17
vpc_opts = resources.get('tb:network:MultiCidrVpc', {}).get('vpc')
vpc = tb_pulumi.network.MultiCidrVpc(
name=f'{project.name_prefix}-vpc',
project=project,
**vpc_opts,
)
This provides only the bare minimum of code-based information:
The
nameof the resource, making use of the project’sname_prefixto create a unique identifier.The project we have defined, so this resource and the other resources it contains can be traversed.
After that, it simply expands the vpc_opts (which has been ripped straight from the config file) into function
parameters. In this way, any changes you make to your YAML will be read into the VPC resource.
Access a resource from within a ThunderbirdComponentResource¶
A ThunderbirdPulumiProject and a ThunderbirdComponentResource each have a resources member which is a
dict of all components defined within it. At both levels, these can be Pulumi Outputs, Resources, ComponentResources,
ThunderbirdComponentResources, or a collection of any of these things. Documentation for each
ThunderbirdComponentResource describes what resources it contains. In a project, the keys in this dict are named after
whatever names you provide for the ThunderbirdComponentResources. In a ThunderbirdComponentResource, they’re named
according to what that resource is labeled in its tb_pulumi.ThunderbirdPulumiProject.finish() call. All of the
various resources created by our classes are fully documented in the tb_pulumi page.
One common need is to define a MultiCidrVpc and then feed one of the subnet IDs to an EC2 instance. If we have
defined the vpc resource as in the sample in the previous section, we can access the subnet IDs from the variable
itself. tb_pulumi.network.MultiCidrVpc documentation shows that the aws.ec2.Subnet resources are
available through the subnets resource. Here are some things you can do with that:
# Get a list of all subnet resources
subnets = vpc.resources.get('subnets')
# Get a list of all subnet IDs
subnet_ids = [subnet.id for subnet in vpc.resources.get('subnets')]
# Get the first subnet ID
subnet_id = vpc.resources.get('subnets')[0]
Remember that this value will be a pulumi.Output , not a real subnet ID, not until Pulumi has applied the resource.
For the most part, this is okay. You could pass that value into some other resource as an Output, and Pulumi would wait
for a real value before proceeding.
subnet_id = vpc.resources.get('subnets')[0]
instance_opts = resources.get('tb:ec2:SshableInstance', {}).get('my-instance')
if not instance_opts:
raise ValueError('my-instance not configured')
instance = tb_pulumi.ec2.SshableInstance(
name='my-instance',
project=project,
subnet_id=subnet_id,
**instance_opts,
opts=pulumi.ResourceOptions(depends_on=[vpc]),
)
Note that in this pattern, we only hardcode parameters whose values are generated elsewhere in the code. As many options as possible are passed in directly from the YAML file.
Also note the addition of the opts parameter, which specifies that the instance is dependent on the VPC resources.
This helps Pulumi set up its dependency tree and prevent problems where you try to build resources that need access to
IDs that don’t exist yet.
Important
This also showcases an important concept. Pulumi’s internal dependency tree is built around Resource objects. You
are defining a Resource which depends on another Resource. This relationship in Pulumi causes it to wait for
the first Resource to be fully applied before trying to apply the dependent one.
A Pulumi ComponentResource is a collection of Resource s, but it is actually an extension of the Resource
class. So Pulumi understands this as a type of resource upon which some other resource can be dependent.
A ThunderbirdComponentResource is an extension of the ComponentResource class, meaning that even our custom
resource collection type can be used as a collective dependency. In the above example, the entire MultiCidrVpc
and every resource it contains will be applied before the SshableInstance ever gets underway.
If you need to wait on that value so you can form it as text, you must write an apply lambda:
import json
# ... project setup, etc ...
subnet_id = vpc.resources.get('subnets')[0]
json_text = subnet_id.apply(
lambda subnet_id:
json.dumps({'subnet_id': subnet_id}))
Defining multiple resources of the same type¶
So far, we have defined singular resources based on singular definitions in the YAML config. Suppose we have a case where we might want to build more resources of the same type without adjusting code. In this case, we might want some YAML that looks like this:
---
resources:
tb:network:SecurityGroupWithRules:
backend-database:
rules:
ingress:
- description: Let traffic into the DB from our IP range
cidr_blocks:
- 10.0.0.0/8
protocol: tcp
from_port: 5432
to_port: 5432
egress:
- description: Let the DB talk out
protocol: tcp
from_port: 0
to_port: 65535
cidr_blocks:
- 0.0.0.0/0
backend-api-lb:
rules:
ingress:
- description: Let traffic into the API service from anywhere
cidr_blocks:
- 0.0.0.0/0
protocol: tcp
from_port: 443
to_port: 443
egress:
- description: Let the LB talk out
protocol: tcp
from_port: 0
to_port: 65535
cidr_blocks:
We do not have to explicitly define both of these security groups. We can feed the data in via dict comprehension:
sgs = {
sg_name: tb_pulumi.network.SecurityGroupWithRules(
name=f'{project.name_prefix}-sg-{sg_name}',
project=project,
vpc_id=vpc.resources.get('vpc').id,
opts=pulumi.ResourceOptions(depends_on=[vpc]),
**sg_config,
)
for sg_name, sg_config in resources['tb:network:SecurityGroupWithRules'].items()
}
Handling Secrets¶
Applications often need to operate on values such as database passwords that are considered secrets. You never want to store these values in plaintext, since that is a security risk, and they should always be protected by policies preventing unauthorized access.
To some extent, this problem is partially solved by Pulumi itself, which allows you to store secret values directly in its configuration using hashes only decryptable with a secret passphrase.
To set a Pulumi secret value, make sure you have the right encryption passphrase exported and run a pulumi config
statement like so:
export PULUMI_CONFIG_PASSPHRASE='a-super-secret-passphrase'
pulumi config set --secret my-password 'P@$sw0rd'
This will add an item to your Pulumi.$STACK_NAME.yaml file in which this secret is listed in encrypted form. This is
considered secure because the data cannot be decrypted without the secret passphrase, which you should always keep
secret and secure.
But many AWS configurations will require that secret values come out of their Secrets Manager product. ECS Task
Definitions, for example, take in Secrets Manager ARNs to feed secret data into environment variables. To help bridge
the gap between Pulumi and AWS, we have the tb_pulumi.secrets.PulumiSecretsManager class. Feed this a list
of secret_names which match Pulumi secret names. This module will create AWS secrets matching those Pulumi secrets.
For example, if we’ve run the above pulumi config command, we could add a section to our YAML config that looks like
this:
---
resources:
# ...
tb:secrets:PulumiSecretsManager:
secrets:
secret_names:
- my-password
And later, we could add the following code to our tb_pulumi program:
psm_opts = resources.get('tb:secrets:PulumiSecretsManager', {}).get('secrets')
psm = tb_pulumi.secrets.PulumiSecretsManager(
name='my-secrets',
project=project,
**psm_opts,
)
This would ultimately create a series of Secrets Manager entries named after the listed secrets. Using this pattern makes sure that your secret data stays secret the whole way through to the cloud provider.
Designing Autoscaling Fargate Clusters¶
The tb_pulumi.fargate.AutoscalingFargateCluster class provides a one-stop pattern for deploying Fargate
services. It improves upon the tb_pulumi.fargate.FargateClusterWithLogging class in a variety of ways:
Supports multiple services per cluster
Allows full control over load balancing
Internally builds security groups connecting load balancers and containers
Note
The FargateClusterWithLogging will become deprecated in a future version and eventually removed in favor of the
AutoscalingFargateCluster class.
The class documentation is thorough, but does little to guide you through the design of your cluster and the configuration that leads to that design working. Therefore, we have this bit of documentation, which helps step you through the process of setting up a cluster.
Before writing out any configuration, consider what services you will need to expose. Come up with a YAML-friendly name
for each of them. You will eventually name some configurations after these terms, and you will need to reference them
elsewhere before you define those services, so it is best to have a clear idea of this before diving in. In our example,
we will define one exposed API service (svc_exposed_api) and one unexposed background worker service
(svc_background_worker).
Note
You probably want your cluster running on private network space. At any rate, you will need to provide a list of valid
subnets on which to run your tasks. We recommend setting up a tb_pulumi.network.MultiCidrVpc or a
tb_pulumi.network.MultiTierVpc and using the subnet resources it creates as inputs to your autoscaling
Fargate cluster.
That accomplished, begin with some YAML, following our conventions:
---
resources:
# ...
tb:fargate:AutoscalingFargateCluster:
my_cluster:
# ...
The cluster itself is little more than an organizational unit for services. Typically, you can let it run with defaults, specifying only a name:
# ...
my_cluster:
cluster: {} # You can specify other cluster settings here if necessary
cluster_name: my-cluster # How this appears in the ECS clusters console
Now work out your task definitions. For each service you intend to run (whether it will be exposed through a load balancer or not), add an entry to your config:
# ...
cluster_name: my-cluster
task_definitions:
svc_background_worker:
container_definitions:
- name: ctr_background_worker
# ... other container/task definition options
svc_exposed_api:
# ... task definition
What you put in the various service entries is documented by AWS.
Note
Task definitions can contain multiple container definitions. Each of those has a name. You can name them anything you like, but they must be unique in the cluster. You will need to reference these names later in the service configurations.
Now define your targets. A target is a service exposed through a network port on one of your containers. Each target’s name will be referred to later by load balancer configurations. The values for each target are the inputs to a TargetGroup Pulumi resource.
# ...
targets:
tgt_exposed_api:
name: exposed-api-stage
port: 1234
protocol: HTTP
target_type: ip
ip_address_type: ipv4
health_check:
port: 1234
path: /health
protocol: HTTP
Note
Not every task in a service necessarily exposes a port. The background worker in our example only ever pulls jobs from another source, never even opening a network port. For these services, you should write task definitions and so on, but not targets, nor load balancers, nor load balancer security groups.
Now define your load balancers. You will need a load balancer for any service you wish to expose. Load balancers do not
have to be public facing (set internal: yes). The term you provide as the key for each load balancer config here
will be referenced later in other parts of the config. The configuration options are inputs to aws.lb.LoadBalancers. Remember that a load balancer’s name
attribute can be no longer than 32 characters, a limit imposed by AWS’s API.
# ...
load_balancers:
lb_exposed_api:
enable_cross_zone_load_balancing: yes
internal: yes
ip_address_type: ipv4
load_balancer_type: application
name: exposed-api-stage # 32 characters max
Next, design the security groups for your containers. In this config, we refer to our own
tb_pulumi.network.SecurityGroupWithRules pattern, and we organize around the names of services and load
balancers. The reason for including the load balancer names here is that the code can determine the correct set of
security group rules to allow traffic from each service’s load balancer to its corresponding targets. If a service does
not expose a port through a load balancer, use the string “none” to define a security group without making this network
association.
# ...
container_security_groups:
svc_background_worker:
none:
description: SG for background worker
rules:
egress:
- from_port: 0
to_port: 65535
protocol: tcp
description: Allow all egress
cidr_blocks:
- 0.0.0.0/0
ingress: [] # The worker doesn't allow inbound traffic
svc_exposed_api:
lb_exposed_api:
description: SG for API worker
rules:
egress:
- from_port: 0
to_port: 65535
protocol: tcp
description: Allow all egress
cidr_blocks:
- 0.0.0.0/0
ingress:
- from_port: 1234
to_port: 1234
protocol: tcp
description: Allow API traffic from internal network only
# The source for this rule will be added programmatically, referring to the load balalncer's SG
Now we do the same thing for our load balancer security groups. These are listed by the name of the load balancer they apply to.
# ...
load_balancer_security_groups:
lb_exposed_api:
rules:
egress:
# ... Rule to allow egress
ingress:
- from_port: 443
to_port: 443
protocol: tcp
description: Allow TLS traffic from local network only
cidr_blocks:
- 10.0.0.0/8
With our load balancers set up, we can now describe each one’s listeners. A listener is a port the load balancer holds open. It is associated with some rules defining which of its targets to route traffic to. This class automatically sets up port-based forwarding rules between the load balancer and target you set. Use those terms when setting up listeners.
# ...
listeners:
lb_exposed_api: # The load balancer to attach the listener to
tgt_exposed_api: # The target to route this traffic to
certificate_arn: arn:aws:acm:etc:etc:etc # Certificate to terminate SSL with
port: 443
protocol: HTTPS
Finally, we can define services. These are scalable workloads which tie together all of the elements we’ve just set up. If your service doesn’t expose a port, you can leave off the various routing options.
# ...
services:
svc_background_worker:
assign_public_ip: yes # This is usually required, if only to access a container image repository
service: # Inputs to an ECS Service resource go in here
desired_count: 1
target: null # There is no target
svc_exposed_api:
assign_public_ip: yes
container_name: ctr_background_worker
container_port: 1234
load_balancer: lb_exposed_api
service:
desired_count: 1 # The autoscaler will take over managing this value later
target: tgt_exposed_api
We also have to define any special resources that these services need access to in order to launch and run. This typically involves access to ECR registries (for pulling container images), secret values stored in AWS Secrets Manager, and runtime parameters stored in AWS Systems Manager (SSM). For these, provide lists of ARNs or ARN patterns under the names of the services that need access to them, or provide empty objects where no access is needed.
# ...
registries:
svc_background_worker:
- arn:aws:ecr:eu-central-1:123456789098:repository/bgworker/*
svc_exposed_api:
- arn:aws:ecr:eu-central-1:123456789098:repository/api/*
secrets:
svc_background_worker:
- arn:aws:secretsmanager:eu-central-1:123456789098:secret:bgworker/stage/*
svc_exposed_api:
- arn:aws:secretsmanager:eu-central-1:123456789098:secret:bgworker/stage/*
ssm_params: {}
The last thing to write up is our autoscaling demand. Each service gets a configuration for a
tb_pulumi.autoscale.EcsServiceAutoscaler.
# ...
autoscalers:
svc_background_worker:
min_capacity: 1
max_capacity: 2
svc_exposed_api:
min_capacity: 2
max_capacity: 4
And that’s it! A pulumi up should bring up this cluster.
Acting on Fully Applied Pulumi Stacks¶
One limitation of raw Pulumi code is that the programmatic visibility into resources you have defined stops at the individual resource level. ComponentResources give you no ability to inspect those components. As shown in previous examples, the ThunderbirdComponentResource restores that ability.
As we have also shown, the outputs generated by these Resources and ComponentResources must be applied before you can access their actual values. This can create some complexity where, for example, you may define a ThunderbirdComponentResource that contains other ThunderbirdComponentResources. To access the resources of the inner nested ThunderbirdComponentResource, you have to do a nested apply lambda. Your code gets very confusing at this point, and hard to follow and debug.
Furthermore, if you need to create something like CloudWatch alarms for your resources, this leaves you defining those alarms individually. If you add a new resource you want to monitor, you would have to write that code, or develop some other module to set the monitors up.
Wouldn’t it be great if you could develop Pulumi code that simply acts on all resources in a project with full context about those resources? Then you could write any kind of stack-aware meta-tool you like and act on the entire stack at once.
Fortunately, tb_pulumi solves this problem as well. There is an abstract class called
tb_pulumi.ProjectResourceGroup which can be extended, its tb_pulumi.ProjectResourceGroup.ready()
function implemented to act in just such a fully resolved state. This function (with a little help from
tb_pulumi.ThunderbirdPulumiProject.flatten()) recursively detects all outputs in a stack and resolves them,
calling that ready function only when everything is completely resolved and available.
For more information on developing ProjectResourceGroups, see Development. Currently, we have two specific implementations.
This first pertains to monitoring. That is described fully on the Monitoring Resources page. The other is related
to granting access to your AWS resources with tb_pulumi.iam.StackAccessPolicies.