Testing Infrastructure as Code with Pulumi. Part 1

Good day, friends. As we prepare for the start of the new cohort for the course DevOps Practices and Tools we're excited to share a new translation with you. Let's go.

Testing Infrastructure as Code with Pulumi. Part 1

Using Pulumi and general-purpose programming languages for Infrastructure as Code offers many advantages: possessing skills and knowledge, eliminating boilerplate code through abstraction, and familiar tools such as IDEs and linters. All these software engineering tools not only make us more productive but also improve code quality. Therefore, it’s quite natural that using general-purpose programming languages allows the implementation of another important software development practice — testing.

In this article, we will explore how Pulumi helps test our infrastructure as code.

Testing Infrastructure as Code with Pulumi. Part 1

Why test infrastructure?

Before delving into details, it is worth asking the question: 'Why test infrastructure at all?' There are many reasons for this, and here are some of them:

  • Unit testing individual functions or logic segments of your program
  • Verifying the desired state of infrastructure against certain constraints.
  • Identifying common errors, such as lack of encryption for storage buckets or unsecured, open access to virtual machines from the Internet.
  • Checking the execution of infrastructure provisioning.
  • Performing runtime testing of application logic running within your 'programmed' infrastructure to verify functionality after provisioning.
  • As we can see, there is a wide range of options for testing infrastructure. In Pulumi, there are mechanisms for testing at every point in this spectrum. Let’s get started and see how it works.

Unit Testing

Pulumi programs are created in general-purpose programming languages like JavaScript, Python, TypeScript, or Go. Therefore, they can leverage the full power of these languages, including their tooling and libraries, such as testing frameworks. Pulumi is multi-cloud, meaning it can utilize any cloud providers for testing.

(In this article, despite the multilingual and multi-cloud nature, we use JavaScript and Mocha, focusing on AWS. Python can also be used unittest, the testing framework Go or any other favorite testing framework. And, of course, Pulumi works excellently with Azure, Google Cloud, Kubernetes.)

As we have seen, there are several reasons why you might need to test your infrastructure code. One of them is usual unit testing. Since your code may contain functions — for example, to calculate CIDR, dynamically compute names, tags, etc. — you will likely want to test them. This is similar to writing regular unit tests for applications in your favorite programming language.
If we complicate things a bit, we can check how your program allocates resources. To illustrate, let’s suppose we need to create a simple EC2 server and we want to ensure the following:

  • Instances have a tag Name.
  • Instances must not use inline script userData — we must use AMI (image).
  • There should be no SSH open to the Internet.

This example is inspired by my example aws-js-webserver:

index.js:

"use strict";
 
let aws = require("@pulumi/aws");
 
let group = new aws.ec2.SecurityGroup("web-secgrp", {
    ingress: [
        { protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] },
        { protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
    ],
});
 
let userData =
`#!/bin/bash
echo "Hello, World!" > index.html
nohup python -m SimpleHTTPServer 80 &`;
 
let server = new aws.ec2.Instance("web-server-www", {
    instanceType: "t2.micro",
    securityGroups: [ group.name ], // reference the group object above
    ami: "ami-c55673a0"             // AMI for us-east-2 (Ohio),
    userData: userData              // start a simple web server
});
 
exports.group = group;
exports.server = server;
exports.publicIp = server.publicIp;
exports.publicHostName = server.publicDns;

This is a basic Pulumi program: it simply allocates an EC2 security group and an instance. However, it should be noted that we are breaking all three rules stated above. Let's write tests!

Writing tests

The overall structure of our tests will look like regular Mocha tests:

ec2tests.js

test.js:
let assert = require("assert");
let mocha = require("mocha");
let pulumi = require("@pulumi/pulumi");
let infra = require("./index");
 
describe("Infrastructure", function() {
    let server = infra.server;
    describe("#server", function() {
        // TODO(check 1): There should be a Name tag.
        // TODO(check 2): There should be no inline userData script.
    });
    let group = infra.group;
    describe("#group", function() {
        // TODO(check 3): There should be no SSH open to the Internet.
    });
});

Now let’s write our first test: ensuring that instances have a tag Name. To verify this, we simply get the EC2 instance object and check the corresponding property. tags:

 // check 1: Должен быть тэг Name.
        it("must have a name tag", function(done) {
            pulumi.all([server.urn, server.tags]).apply(([urn, tags]) => {
                if (!tags || !tags["Name"]) {
                    done(new Error(`Missing a name tag on server ${urn}`));
                } else {
                    done();
                }
            });
        });

It looks like a regular test, but with several features worth noting:

  • Since we check the state of the resource before deployment, our tests always run in 'plan' (or 'preview') mode. As such, there are many properties whose values simply won’t be retrieved or will be undefined. This includes all output properties calculated by your cloud provider. For our tests, this is fine—we are only verifying the input data. We will revisit this issue later when it comes to integration tests.
  • Since all properties of Pulumi resources are 'outputs', and many of them are computed asynchronously, we need to use the apply method to access their values. This is very similar to promises and a function. then .
  • Because we use several properties to show the resource's URN in the error message, we must use the function pulumi.all, to combine them.
  • Finally, because these values are computed asynchronously, we need to use Mocha's built-in asynchronous capability with a callback done or returning a promise.

Once everything is set up, we'll have access to input data as simple JavaScript values. The property tags is a map (associative array), so we simply ensure that it is (1) not false, and (2) there is a key for Name. It's quite straightforward, and now we can check anything!

Now let's write our second check. This one is even simpler:

 // check 2: Не должно быть inline-скрипта userData.
        it("must not use userData (use an AMI instead)", function(done) {
            pulumi.all([server.urn, server.userData]).apply(([urn, userData]) => {
                if (userData) {
                    done(new Error(`Illegal use of userData on server ${urn}`));
                } else {
                    done();
                }
            });
        });

And finally, let's write the third test. This will be a bit more challenging as we are looking for ingress rules associated with a security group, which can have many, and CIDR ranges in these rules, which can also be numerous. But we managed to do it:

    // check 3: Не должно быть SSH, открытого в Интернет.
        it("must not open port 22 (SSH) to the Internet", function(done) {
            pulumi.all([ group.urn, group.ingress ]).apply(([ urn, ingress ]) => {
                if (ingress.find(rule =>
                        rule.fromPort == 22 && rule.cidrBlocks.find(block =>
                            block === "0.0.0.0/0"))) {
                    done(new Error(`Illegal SSH port 22 open to the Internet (CIDR 0.0.0.0/0) on group ${urn}`));
                } else {
                    done();
                }
            });
        });

That's it. Now let's run the tests!

Running Tests

Generally, tests can be run in the usual way using your chosen testing framework. However, there is one peculiarity of Pulumi worth paying attention to.
Typically, Pulumi programs are run using the pulumi CLI (Command Line Interface), which sets up the language runtime, manages Pulumi engine launches to log resource operations, and incorporate them into a plan, among other tasks. However, there is one issue. When running under the control of your testing framework, there will be no connection between the CLI and the Pulumi engine.

To work around this issue, we simply need to specify the following:

  • The project name, which is contained in the environment variable PULUMI_NODEJS_PROJECT (or, more generally, PULUMI__PROJECT for other languages).
    The stack name, specified in the environment variable PULUMI_NODEJS_STACK (or, more generally, PULUMI__STACK).
    Your stack configuration variables. They can be obtained using the environment variable PULUMI_CONFIG and are formatted as a JSON map with key/value pairs.

    The program will issue warnings indicating that there is no connection to the CLI/engine during execution. This is important because, in fact, your program will not deploy anything and it can be a surprise if that’s not what you intended to do! To tell Pulumi that this is exactly what you need, you can set PULUMI_TEST_MODE downward API support (simultaneously with this in true.

    Imagine we need to specify the project name in my-ws, the stack name dev, and the AWS region us-west-2. The command line to run Mocha tests would look like this:

    $ PULUMI_TEST_MODE=true 
        PULUMI_NODEJS_STACK="my-ws" 
        PULUMI_NODEJS_PROJECT="dev" 
        PULUMI_CONFIG='{ "aws:region": "us-west-2" }' 
        mocha tests.js

    Running this, as expected, will show us that we have three failed tests!

    Infrastructure
        #server
          1) must have a name tag
     	 2) must not use userData (use an AMI instead)
        #group
          3) must not open port 22 (SSH) to the Internet
    
      0 passing (17ms)
      3 failing
     
     1) Infrastructure
           #server
             must have a name tag:
         Error: Missing a name tag on server
            urn:pulumi:my-ws::my-dev::aws:ec2/instance:Instance::web-server-www
    
     2) Infrastructure
           #server
             must not use userData (use an AMI instead):
         Error: Illegal use of userData on server
            urn:pulumi:my-ws::my-dev::aws:ec2/instance:Instance::web-server-www
    
     3) Infrastructure
           #group
             must not open port 22 (SSH) to the Internet:
         Error: Illegal SSH port 22 open to the Internet (CIDR 0.0.0.0/0) on group

    Let’s fix our program:

    "use strict";
     
    let aws = require("@pulumi/aws");
     
    let group = new aws.ec2.SecurityGroup("web-secgrp", {
        ingress: [
            { protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
        ],
    });
     
    let server = new aws.ec2.Instance("web-server-www", {
        tags: { "Name": "web-server-www" },
        instanceType: "t2.micro",
        securityGroups: [ group.name ], // reference the group object above
        ami: "ami-c55673a0"             // AMI for us-east-2 (Ohio),
    });
     
    exports.group = group;
    exports.server = server;
    exports.publicIp = server.publicIp;
    exports.publicHostName = server.publicDns;
    

    And then we will rerun the tests:

    Infrastructure
        #server
          ✓ must have a name tag
          ✓ must not use userData (use an AMI instead)
        #group
          ✓ must not open port 22 (SSH) to the Internet
     
     
     3 passing (16ms)

    Everything went successfully… Hooray! ✓✓✓

    That's all for today; we'll talk about deployment testing in the second part of the translation 😉

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers 🔥 Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster