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.ComponentResource
class into atb_pulumi.ThunderbirdComponentResource
class, which exposes the resources contained within it to higher-order code.These are organized into
tb_pulumi.ThunderbirdPulumiProject
s, 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.yaml
file 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.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['resources']
But in the case where the config file is not set up right, this results in a KeyError
that would require you to trap
the statement in a try
block. This is much less elegant, and Python’s get
function is a nice way to get a value
back instead. Consider this instead:
import sys
resources = project.get('resources')
if not resources:
pulumi.error('tb_pulumi is not configured for this stack.')
sys.exit(1)
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/1
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
name
of the resource, making use of the project’sname_prefix
to 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 will wait
for a real value before proceeding.
subnet_id = vpc.resources.get('subnets')[0]
instance_opts = resources.get('tb:ec2:SshableInstance', {}).get('my-instance')
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 supply as defined function parameters the ones whose values come from our code. We still pass in as many options from the YAML config as possible.
Also not the addition of the opts
parameter, which specifies that the instance is dependent on the VPC config. This
helps Pulumi set up its dependency tree.
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:
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.
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
.