Enforcing a Resource Tagging Strategy on AWS with Pulumi : A Generic Way
In my previous article, I explained how one could extend Pulumi resources to enforce a tagging strategy by making a defined set of tags mandatory. This approach works great but it requires every resources to be re-defined and extended.
One of the main principles in programming is to keep the code DRY (Don’t Repeat Yourself). It avoid errors and typos due to fatigue.
Let’s modify the example in the previous article to apply this principle. We’ll use TypeScript and its fantastic type system for that.
The core idea is that we have a logical implication
If I can tag a resource then my company tags are mandatory.
What does the If I can tag a resource
part mean? Let’s translate that in a Pulumi way.
Resources are classes. The resource constructor is a function. It has always the same signature:
constructor(name: string, args?: ArgsType, opts?: pulumi.CustomResourceOptions)
ArgsType
is a placeholder for the resource argument type. For example
- For
aws.ec2.Instance
,ArgsType
isaws.ec2.InstanceArgs
- For
aws.s3.Bucket
,ArgsType
isaws.s3.BucketArgs
ArgsType
can be a lot of different things. However, if the resource can be tagged, ArgsType
has always a tags
attribute. Check aws.ec2.InstanceArgs
vs aws.ec2.SecurityGroupRule
if you’re skeptical.
The logical implication above becomes:
If I construct a resource whose ArgsType
type has a tags
attributes, then my company tags are mandatory.
Pulumi implementation
Conditional types
Before we implement this implication in our Pulumi program, I need to introduce the concept of conditional types. Conditional types are the keystone of the pattern matching mechanism.
A conditional type describes a type relationship test and selects one of two possible types, depending on the outcome of that test. It always has the following form:
T extends U ? X : Y
In human language, this conditional type reads as follows: If the type T is assignable to the type U, select the type X; otherwise, select the type Y.
Let’s create the ArgsType
matcher using conditional types.
type ArgsType<CTor extends Function> = CTor extends new(name: string, args: infer Args, opts: pulumi.CustomResourceOptions) => any ? Args : never
As you can see, ArgType
is a conditional type. Given a Pulumi constructor, it is able to deep dive into it, infer the argument type and return it as a result. I won’t explain every bit of syntax here. It’s far beyon the scope of this article. I’m just going to run it on an example to make you feel how it works. Let’s find out the ArgsType
of aws.ec2.Instance
.
First, to evaluate the extends
clause, TypeScript will try to make a unification of T
and U
. They can be represented as a graph to make things more readable.
The two functions will unify if and only if the return types and the arguments types match. Therefore, we have the following constraints:
Function 1 | Function 2 | Comments | |
---|---|---|---|
Return type | any | aws.ec2.Instance | OK, because every type is any |
Arg 1 Type | string | string | OK |
Arg 2 Type | Infer & name it Args | aws.ec2.InstanceArgs | Ok and Args = aws.ec2.InstanceArgs |
Arg 3 Type | pulumi.Custom[...]Options | pulumi.Custom[..]Options | Ok |
The extends
clause evaluates to true, therefore the then
side of the condition is the result.
ArgsType<aws.ec2.Instance> = Args = aws.ec2.InstanceArgs ArgsType<aws.ec2.Instance> = aws.ec2.InstanceArgs
Similarly:
ArgsType<aws.s3.Bucket> = aws.s3.BucketArgs ArgsType<aws.ec2.SecurityGroupRule> = aws.ec2.SecurityGroupRuleArgs
We can also use this technic to check that a type T
is taggable:
// Every interface having a `tags` property extend ITaggable
interface ITaggable {
readonly tags?: pulumi.Input<{
[key: string]: any;
}>;
}
type IsTaggable<T> = T extends ITaggable ? T : never
If we combine both matchers, we can check that a Pulumi resource is taggable.
type Taggable<T extends Function> = IsTaggable<ArgsType <T>>
For instance:
Taggable<aws.ec2.Instance> = aws.ec2.InstanceArgs // therefore aws.ec2.Instance is taggable.
Taggable<aws.ec2.SecurityGroupRule> = never // because a security group rule is not taggable.
The new resource constructor
Now, we have all the required pieces to implement the new Pulumi resource constructor.
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// Every interface having a `tags` property extend ITaggable
interface ITaggable {
readonly tags?: pulumi.Input<{
[key: string]: any;
}>;
}
interface CompanyTags {
component: string
}
// Return the type of the arguments object used to create the Pulumi resource
type ArgsType<CTor extends Function> = CTor extends new(name: string, args: infer Args, opts?: pulumi.CustomResourceOptions) => any ? Args : never;
// Assert that an object is taggable.
type IsTaggable<T> = T extends ITaggable ? T : never;
// Assert that a pulumi resource is taggable
type Taggable<T extends Function> = IsTaggable<ArgsType<T>>;
// Define an alias matching a pulumi constructor for the sake of readability.
type PulumiConstructor = { new(name: string, args: any, opts?: pulumi.CustomResourceOptions):any };
// This is where the magic happens.
// The tagged function takes a pulumi constructor (string, Args,
// pulumi.CustomResourceOptions) and returns a new pulumi
// constructor which requires the company tags to be specified
// (string, Args & CompanyTags, pulumi.CustomResourceOptions)
function Tagged<
Constructor extends PulumiConstructor, // Let Constructor a PulumiComstructor
Args extends Taggable<Constructor>, // Let Args the taggable `ArgsType` of
// Constructor or never if the ArgsType is not taggable
Resource extends InstanceType<Constructor> // Let Resource the type of the pulumi
// resource created by Constructor.
>(ResourceCtor: Constructor) {
// We define the new constructor. Its type is the same as the initial constructor's.
// The only difference is that the `ArgsType` is augmented with the `CompanyTags` type
// to make the company tags mandatory.
let newConstructor = function (name: string, args: Args & { company_tags: CompanyTags }, opts?: pulumi.CustomResourceOptions) {
// We merge the company tags with the user defined tags.
let new_tags = {...args.tags, ...args.company_tags};
// From the new tags, we generate the new resource arguments.
let new_args = { ...args, tags: new_tags };
// We build the resource using the original constructor. It is very important to use
// the orginal constructor as it is the piece of code which perform the RPC call and
// register the resource in the pulumi engine.
let result: Resource = new ResourceCtor(name, new_args, opts);
// Return the original pulumi resource.
return result;
} as unknown as {new(name: string, args: Args & { company_tags: CompanyTags }, opts?: pulumi.CustomResourceOptions):Resource };
// We assign the prototype of the original resource. To understand why this
// is necessary, please read
// https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance
newConstructor.prototype = ResourceCtor.prototype;
// We return the new constructor
return newConstructor;
}
// How to use it?
// The tagged function returns a new constructor function that requires the company
// tags to be provided.
let Instance = Tagged(aws.ec2.Instance);
// A S3 Bucket
let Bucket = Tagged(aws.s3.Bucket);
// A SecurityGroup
let SecurityGroup = Tagged(aws.ec2.SecurityGroup);
// A VPC
let Vpc = Tagged(aws.ec2.Vpc);
let instance = new Instance("instance", {
ami: "ami-aaaabbbbcccc",
subnetId: "subnet-111222333eeefff",
// Try to remove this block
company_tags: {
component: "My awesome componnent"
},
// =============
instanceType: "t2.medium"
});
let vpc = new Vpc("vpc", {
cidrBlock: "10.0.0.0/16",
// Try to remove this block
company_tags:{
component: "My other awesome component"
}
// ============
});
// Because a VPC is still a VPC or an instance is still an instance, we can
// use them everywhere a VPC or an instance is required.
export let instanceId = instance.id;
export let vpcId = vpc.id;
The Tagged
function is the core component of the system. It’s a constructor factory that takes a taggable Pulumi constructor and turns it into an other Pulumi constructor creating the same resource but making the company tags mandatory.
Once we have the Tagged
function we can use it to enforce our company tags on every taggable resource. The benefit of this function is that now, enforcing a tagging strategy is a one-liner. Because of the Typescript type system and the complex type declaration at the beginning, we keep all the compiler checks and editor helps.
Like the first implementation, we keep all the benefit of inheritance. An EC2 instance created with the help of Tagged(...)
constructor is still an EC2 instance.
If we pass a non-taggable resource to the Tagged
function, the resulting constructor’s ArgsType
will be never
making this constructor unusable.