Enforcing a Resource Tagging Strategy on AWS with Pulumi : A Generic Way

Temps de lecture : 6 minutes

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.InstanceArgsType is aws.ec2.InstanceArgs
  • For aws.s3.BucketArgsType is aws.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 1Function 2Comments
Return typeanyaws.ec2.InstanceOK, because every type is any
Arg 1 TypestringstringOK
Arg 2 TypeInfer & name it Argsaws.ec2.InstanceArgsOk and Args = aws.ec2.InstanceArgs
Arg 3 Typepulumi.Custom[...]Optionspulumi.Custom[..]OptionsOk

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.

Commentaires :

A lire également sur le sujet :