DEV Community

Elton Minetto for AWS Community Builders

Posted on

Infrastructure as Code on AWS using Go and Pulumi

When we talk about Infrastructure as Code or IaC, the first tool that comes to mind is Terraform. Terraform, created by HashiCorp, has become the standard for documentation and infrastructure management, but its declarative language, HCL (HashiCorp Configuration Language), has some limitations. The main limitation is not being a programming language but a configuration one.

Some alternatives have been emerging to fulfill these needs, such as:

  • AWS Cloud Development Kit, Amazon's solution that allows us to use TypeScript, Python, and Java to program the infrastructure using the cloud provider's solutions;

  • Pulumi, which allows us to use TypeScript, JavaScript, Python, Go, and C# to program infrastructures using solutions from AWS, Microsoft Azure, Google Cloud, and Kubernetes installations.
    I will introduce Pulumi, using the Go language to create some infrastructure examples on AWS.

Installation

To make use of Pulumi, we first need to install its command-line application. Following the documentation, I installed it on my macOS using the command:

brew install pulumi
Enter fullscreen mode Exit fullscreen mode

On the website, you can see how to install it on Windows and Linux.

Configure AWS Account Access

Since I will use AWS in this example, the next necessary step is to configure the credentials. For that, I got my access key and secret from the AWS dashboard and set the required environment variables:

export AWS_ACCESS_KEY_ID=<YOUR_ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<YOUR_SECRET_ACCESS_KEY>
Enter fullscreen mode Exit fullscreen mode

Creating the project

With the initial dependencies configured, we can now create the project:

mkdir post-pulumi
cd post-pulumi
pulumi new aws-go
Enter fullscreen mode Exit fullscreen mode

One of the creation steps requires setting up an account on the Pulumi website. For that, the command-line application opens the browser for this step to be completed. So I logged in with my Github account, completed the registration, returned to the terminal, and continued the project creation without any problems.

You can see the result of running the command can at this link. In addition, at the end of the process, it installs all the necessary dependencies for creating the project in Go.

Files created

Looking at the directory contents, we can see that some configuration files and a main.go were created.

Pulumi.yaml

name: post-pulumi
runtime: go
description: A minimal AWS Go Pulumi program

Enter fullscreen mode Exit fullscreen mode

Pulumi.dev.yaml

config:
  aws:region: us-east-1
Enter fullscreen mode Exit fullscreen mode

main.go

package main

import (
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/s3"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        // Create an AWS resource (S3 Bucket)
        bucket, err := s3.NewBucket(ctx, "my-bucket", nil)
        if err != nil {
            return err
        }

        // Export the name of the bucket
        ctx.Export("bucketName", bucket.ID())
        return nil
    })
}
Enter fullscreen mode Exit fullscreen mode

When running

pulumi up
Enter fullscreen mode Exit fullscreen mode

The bucket was created in S3, as the code indicates.

And the command:

pulumi destroy
Enter fullscreen mode Exit fullscreen mode

Destroy all the resources, in this case, the S3 bucket.

First example - creating a static page in S3

Now let's do some more complex examples.

The first step is to create a static page, which we are going to deploy:

mkdir static
Enter fullscreen mode Exit fullscreen mode

Inside this directory, I created the file:

static/index.html

<html>
    <body>
        <h1>Hello, Pulumi!</h1>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

I changed main.go to reflect the new structure:

package main

import (
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/s3"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        // Create an AWS resource (S3 Bucket)
        bucket, err := s3.NewBucket(ctx, "my-bucket", &s3.BucketArgs{
            Website: s3.BucketWebsiteArgs{
                IndexDocument: pulumi.String("index.html"),
            },
        })
        if err != nil {
            return err
        }

        // Export the name of the bucket
        ctx.Export("bucketName", bucket.ID())

        _, err = s3.NewBucketObject(ctx, "index.html", &s3.BucketObjectArgs{
            Acl:         pulumi.String("public-read"),
            ContentType: pulumi.String("text/html"),
            Bucket:      bucket.ID(),
            Source:      pulumi.NewFileAsset("static/index.html"),
        })
        if err != nil {
            return err
        }
        ctx.Export("bucketEndpoint", pulumi.Sprintf("http://%s", bucket.WebsiteEndpoint))
        return nil
    })
}

Enter fullscreen mode Exit fullscreen mode

To update run:

pulumi up
Enter fullscreen mode Exit fullscreen mode

And confirm the change.

The code snippet:

ctx.Export("bucketEndpoint", pulumi.Sprintf("http://%s", bucket.WebsiteEndpoint))
Enter fullscreen mode Exit fullscreen mode

Generate as output the address to access index.html:

Outputs:
  + bucketEndpoint: "http://my-bucket-357877e.s3-website-us-east-1.amazonaws.com"
Enter fullscreen mode Exit fullscreen mode

The case above is a straightforward example, but it already demonstrates the power of the tool. So let's make things a little more complex and fun now.

Second example - a site inside a container

Let's create a Dockerfile with a web server to host our static content:

static/Dockerfile

FROM golang

ADD . /go/src/foo

WORKDIR /go/src/foo
RUN go build -o /go/bin/main

ENTRYPOINT /go/bin/main

EXPOSE 80
Enter fullscreen mode Exit fullscreen mode

Let's now create the static/main.go file, which will be our web server:

package main

import (
    "log"
    "net/http"
)

func main() {
    r := http.NewServeMux()
    fileServer := http.FileServer(http.Dir("./"))
    r.Handle("/", http.StripPrefix("/", fileServer))
    s := &http.Server{
        Addr:    ":80",
        Handler: r,
    }
    log.Fatal(s.ListenAndServe())
}
Enter fullscreen mode Exit fullscreen mode

Let's change main.go to include the infrastructure of an ECS cluster and everything else needed to run our container:

package main

import (
    "encoding/base64"
    "fmt"
    "strings"

    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/ec2"
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/ecr"
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/ecs"
    elb "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/elasticloadbalancingv2"
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/iam"
    "github.com/pulumi/pulumi-docker/sdk/v3/go/docker"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        // Read back the default VPC and public subnets, which we will use.
        t := true
        vpc, err := ec2.LookupVpc(ctx, &ec2.LookupVpcArgs{Default: &t})
        if err != nil {
            return err
        }
        subnet, err := ec2.GetSubnetIds(ctx, &ec2.GetSubnetIdsArgs{VpcId: vpc.Id})
        if err != nil {
            return err
        }

        // Create a SecurityGroup that permits HTTP ingress and unrestricted egress.
        webSg, err := ec2.NewSecurityGroup(ctx, "web-sg", &ec2.SecurityGroupArgs{
            VpcId: pulumi.String(vpc.Id),
            Egress: ec2.SecurityGroupEgressArray{
                ec2.SecurityGroupEgressArgs{
                    Protocol:   pulumi.String("-1"),
                    FromPort:   pulumi.Int(0),
                    ToPort:     pulumi.Int(0),
                    CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
                },
            },
            Ingress: ec2.SecurityGroupIngressArray{
                ec2.SecurityGroupIngressArgs{
                    Protocol:   pulumi.String("tcp"),
                    FromPort:   pulumi.Int(80),
                    ToPort:     pulumi.Int(80),
                    CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
                },
            },
        })
        if err != nil {
            return err
        }

        // Create an ECS cluster to run a container-based service.
        cluster, err := ecs.NewCluster(ctx, "app-cluster", nil)
        if err != nil {
            return err
        }

        // Create an IAM role that can be used by our service's task.
        taskExecRole, err := iam.NewRole(ctx, "task-exec-role", &iam.RoleArgs{
            AssumeRolePolicy: pulumi.String(`{
    "Version": "2008-10-17",
    "Statement": [{
        "Sid": "",
        "Effect": "Allow",
        "Principal": {
            "Service": "ecs-tasks.amazonaws.com"
        },
        "Action": "sts:AssumeRole"
    }]
}`),
        })
        if err != nil {
            return err
        }
        _, err = iam.NewRolePolicyAttachment(ctx, "task-exec-policy", &iam.RolePolicyAttachmentArgs{
            Role:      taskExecRole.Name,
            PolicyArn: pulumi.String("arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"),
        })
        if err != nil {
            return err
        }

        // Create a load balancer to listen for HTTP traffic on port 80.
        webLb, err := elb.NewLoadBalancer(ctx, "web-lb", &elb.LoadBalancerArgs{
            Subnets:        toPulumiStringArray(subnet.Ids),
            SecurityGroups: pulumi.StringArray{webSg.ID().ToStringOutput()},
        })
        if err != nil {
            return err
        }
        webTg, err := elb.NewTargetGroup(ctx, "web-tg", &elb.TargetGroupArgs{
            Port:       pulumi.Int(80),
            Protocol:   pulumi.String("HTTP"),
            TargetType: pulumi.String("ip"),
            VpcId:      pulumi.String(vpc.Id),
        })
        if err != nil {
            return err
        }
        webListener, err := elb.NewListener(ctx, "web-listener", &elb.ListenerArgs{
            LoadBalancerArn: webLb.Arn,
            Port:            pulumi.Int(80),
            DefaultActions: elb.ListenerDefaultActionArray{
                elb.ListenerDefaultActionArgs{
                    Type:           pulumi.String("forward"),
                    TargetGroupArn: webTg.Arn,
                },
            },
        })
        if err != nil {
            return err
        }

        //create a new ECR repository
        repo, err := ecr.NewRepository(ctx, "foo", &ecr.RepositoryArgs{})
        if err != nil {
            return err
        }

        repoCreds := repo.RegistryId.ApplyT(func(rid string) ([]string, error) {
            creds, err := ecr.GetCredentials(ctx, &ecr.GetCredentialsArgs{
                RegistryId: rid,
            })
            if err != nil {
                return nil, err
            }
            data, err := base64.StdEncoding.DecodeString(creds.AuthorizationToken)
            if err != nil {
                fmt.Println("error:", err)
                return nil, err
            }

            return strings.Split(string(data), ":"), nil
        }).(pulumi.StringArrayOutput)
        repoUser := repoCreds.Index(pulumi.Int(0))
        repoPass := repoCreds.Index(pulumi.Int(1))

        //build the image
        image, err := docker.NewImage(ctx, "my-image", &docker.ImageArgs{
            Build: docker.DockerBuildArgs{
                Context: pulumi.String("./static"),
            },
            ImageName: repo.RepositoryUrl,
            Registry: docker.ImageRegistryArgs{
                Server:   repo.RepositoryUrl,
                Username: repoUser,
                Password: repoPass,
            },
        })
        if err != nil {
            return err
        }

        containerDef := image.ImageName.ApplyT(func(name string) (string, error) {
            fmtstr := `[{
                "name": "my-app",
                "image": %q,
                "portMappings": [{
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp"
                }]
            }]`
            return fmt.Sprintf(fmtstr, name), nil
        }).(pulumi.StringOutput)

        // Spin up a load balanced service running NGINX.
        appTask, err := ecs.NewTaskDefinition(ctx, "app-task", &ecs.TaskDefinitionArgs{
            Family:                  pulumi.String("fargate-task-definition"),
            Cpu:                     pulumi.String("256"),
            Memory:                  pulumi.String("512"),
            NetworkMode:             pulumi.String("awsvpc"),
            RequiresCompatibilities: pulumi.StringArray{pulumi.String("FARGATE")},
            ExecutionRoleArn:        taskExecRole.Arn,
            ContainerDefinitions:    containerDef,
        })
        if err != nil {
            return err
        }
        _, err = ecs.NewService(ctx, "app-svc", &ecs.ServiceArgs{
            Cluster:        cluster.Arn,
            DesiredCount:   pulumi.Int(5),
            LaunchType:     pulumi.String("FARGATE"),
            TaskDefinition: appTask.Arn,
            NetworkConfiguration: &ecs.ServiceNetworkConfigurationArgs{
                AssignPublicIp: pulumi.Bool(true),
                Subnets:        toPulumiStringArray(subnet.Ids),
                SecurityGroups: pulumi.StringArray{webSg.ID().ToStringOutput()},
            },
            LoadBalancers: ecs.ServiceLoadBalancerArray{
                ecs.ServiceLoadBalancerArgs{
                    TargetGroupArn: webTg.Arn,
                    ContainerName:  pulumi.String("my-app"),
                    ContainerPort:  pulumi.Int(80),
                },
            },
        }, pulumi.DependsOn([]pulumi.Resource{webListener}))
        if err != nil {
            return err
        }
        // Export the resulting web address.
        ctx.Export("url", webLb.DnsName)
        return nil
    })
}

func toPulumiStringArray(a []string) pulumi.StringArrayInput {
    var res []pulumi.StringInput
    for _, s := range a {
        res = append(res, pulumi.String(s))
    }
    return pulumi.StringArray(res)
}

Enter fullscreen mode Exit fullscreen mode

Complex? Yes, but this complexity is inherent to AWS features and not Pulumi. We would have similar complexity if we were using Terraform or CDK.

Before running our code, we need to download the new dependencies:

go get github.com/pulumi/pulumi-docker
go get github.com/pulumi/pulumi-docker/sdk/v3/go/docker
Enter fullscreen mode Exit fullscreen mode

Now just run the command:

pulumi up
Enter fullscreen mode Exit fullscreen mode

The execution output will generate the URL of the load balancer, which we will use to access the contents of our container in execution.

Reorganizing the code

Now we can start making use of the advantages of a complete programming language like Go. For example, we could use language features like functions, concurrency, conditionals, etc. In this example, we are going to organize our code better. For this, I created the iac directory and the iac/fargate.go file. After that, I moved most of the logic from main.go to the new file:

package iac

import (
    "encoding/base64"
    "fmt"
    "strings"

    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/ec2"
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/ecr"
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/ecs"
    elb "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/elasticloadbalancingv2"
    "github.com/pulumi/pulumi-aws/sdk/v4/go/aws/iam"
    "github.com/pulumi/pulumi-docker/sdk/v3/go/docker"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func FargateRun(ctx *pulumi.Context) error {

    // Read back the default VPC and public subnets, which we will use.
    t := true
    vpc, err := ec2.LookupVpc(ctx, &ec2.LookupVpcArgs{Default: &t})
    if err != nil {
        return err
    }
    subnet, err := ec2.GetSubnetIds(ctx, &ec2.GetSubnetIdsArgs{VpcId: vpc.Id})
    if err != nil {
        return err
    }

    // Create a SecurityGroup that permits HTTP ingress and unrestricted egress.
    webSg, err := ec2.NewSecurityGroup(ctx, "web-sg", &ec2.SecurityGroupArgs{
        VpcId: pulumi.String(vpc.Id),
        Egress: ec2.SecurityGroupEgressArray{
            ec2.SecurityGroupEgressArgs{
                Protocol:   pulumi.String("-1"),
                FromPort:   pulumi.Int(0),
                ToPort:     pulumi.Int(0),
                CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
            },
        },
        Ingress: ec2.SecurityGroupIngressArray{
            ec2.SecurityGroupIngressArgs{
                Protocol:   pulumi.String("tcp"),
                FromPort:   pulumi.Int(80),
                ToPort:     pulumi.Int(80),
                CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
            },
        },
    })
    if err != nil {
        return err
    }

    // Create an ECS cluster to run a container-based service.
    cluster, err := ecs.NewCluster(ctx, "app-cluster", nil)
    if err != nil {
        return err
    }

    // Create an IAM role that can be used by our service's task.
    taskExecRole, err := iam.NewRole(ctx, "task-exec-role", &iam.RoleArgs{
        AssumeRolePolicy: pulumi.String(`{
    "Version": "2008-10-17",
    "Statement": [{
        "Sid": "",
        "Effect": "Allow",
        "Principal": {
            "Service": "ecs-tasks.amazonaws.com"
        },
        "Action": "sts:AssumeRole"
    }]
}`),
    })
    if err != nil {
        return err
    }
    _, err = iam.NewRolePolicyAttachment(ctx, "task-exec-policy", &iam.RolePolicyAttachmentArgs{
        Role:      taskExecRole.Name,
        PolicyArn: pulumi.String("arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"),
    })
    if err != nil {
        return err
    }

    // Create a load balancer to listen for HTTP traffic on port 80.
    webLb, err := elb.NewLoadBalancer(ctx, "web-lb", &elb.LoadBalancerArgs{
        Subnets:        toPulumiStringArray(subnet.Ids),
        SecurityGroups: pulumi.StringArray{webSg.ID().ToStringOutput()},
    })
    if err != nil {
        return err
    }
    webTg, err := elb.NewTargetGroup(ctx, "web-tg", &elb.TargetGroupArgs{
        Port:       pulumi.Int(80),
        Protocol:   pulumi.String("HTTP"),
        TargetType: pulumi.String("ip"),
        VpcId:      pulumi.String(vpc.Id),
    })
    if err != nil {
        return err
    }
    webListener, err := elb.NewListener(ctx, "web-listener", &elb.ListenerArgs{
        LoadBalancerArn: webLb.Arn,
        Port:            pulumi.Int(80),
        DefaultActions: elb.ListenerDefaultActionArray{
            elb.ListenerDefaultActionArgs{
                Type:           pulumi.String("forward"),
                TargetGroupArn: webTg.Arn,
            },
        },
    })
    if err != nil {
        return err
    }

    repo, err := ecr.NewRepository(ctx, "foo", &ecr.RepositoryArgs{})
    if err != nil {
        return err
    }

    repoCreds := repo.RegistryId.ApplyT(func(rid string) ([]string, error) {
        creds, err := ecr.GetCredentials(ctx, &ecr.GetCredentialsArgs{
            RegistryId: rid,
        })
        if err != nil {
            return nil, err
        }
        data, err := base64.StdEncoding.DecodeString(creds.AuthorizationToken)
        if err != nil {
            fmt.Println("error:", err)
            return nil, err
        }

        return strings.Split(string(data), ":"), nil
    }).(pulumi.StringArrayOutput)
    repoUser := repoCreds.Index(pulumi.Int(0))
    repoPass := repoCreds.Index(pulumi.Int(1))

    image, err := docker.NewImage(ctx, "my-image", &docker.ImageArgs{
        Build: docker.DockerBuildArgs{
            Context: pulumi.String("./static"),
        },
        ImageName: repo.RepositoryUrl,
        Registry: docker.ImageRegistryArgs{
            Server:   repo.RepositoryUrl,
            Username: repoUser,
            Password: repoPass,
        },
    })
    if err != nil {
        return err
    }

    containerDef := image.ImageName.ApplyT(func(name string) (string, error) {
        fmtstr := `[{
                "name": "my-app",
                "image": %q,
                "portMappings": [{
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp"
                }]
            }]`
        return fmt.Sprintf(fmtstr, name), nil
    }).(pulumi.StringOutput)

    // Spin up a load balanced service running NGINX.
    appTask, err := ecs.NewTaskDefinition(ctx, "app-task", &ecs.TaskDefinitionArgs{
        Family:                  pulumi.String("fargate-task-definition"),
        Cpu:                     pulumi.String("256"),
        Memory:                  pulumi.String("512"),
        NetworkMode:             pulumi.String("awsvpc"),
        RequiresCompatibilities: pulumi.StringArray{pulumi.String("FARGATE")},
        ExecutionRoleArn:        taskExecRole.Arn,
        ContainerDefinitions:    containerDef,
    })
    if err != nil {
        return err
    }
    _, err = ecs.NewService(ctx, "app-svc", &ecs.ServiceArgs{
        Cluster:        cluster.Arn,
        DesiredCount:   pulumi.Int(5),
        LaunchType:     pulumi.String("FARGATE"),
        TaskDefinition: appTask.Arn,
        NetworkConfiguration: &ecs.ServiceNetworkConfigurationArgs{
            AssignPublicIp: pulumi.Bool(true),
            Subnets:        toPulumiStringArray(subnet.Ids),
            SecurityGroups: pulumi.StringArray{webSg.ID().ToStringOutput()},
        },
        LoadBalancers: ecs.ServiceLoadBalancerArray{
            ecs.ServiceLoadBalancerArgs{
                TargetGroupArn: webTg.Arn,
                ContainerName:  pulumi.String("my-app"),
                ContainerPort:  pulumi.Int(80),
            },
        },
    }, pulumi.DependsOn([]pulumi.Resource{webListener}))
    if err != nil {
        return err
    }
    // Export the resulting web address.
    ctx.Export("url", webLb.DnsName)
    return nil
}

func toPulumiStringArray(a []string) pulumi.StringArrayInput {
    var res []pulumi.StringInput
    for _, s := range a {
        res = append(res, pulumi.String(s))
    }
    return pulumi.StringArray(res)
}

Enter fullscreen mode Exit fullscreen mode

The next step was to configure the iac directory to be a Go language module:

cd iac
go mod init github.com/eminetto/post-pulumi/iac
cd ..
go mod edit -replace github.com/eminetto/post-pulumi/iac=./iac
go mod tidy
Enter fullscreen mode Exit fullscreen mode

Our main.go can now be simplified:

package main

import (
    "github.com/eminetto/post-pulumi/iac"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        return iac.FargateRun(ctx)
    })
}

Enter fullscreen mode Exit fullscreen mode

That way, we can better manage the structure of the code that will handle AWS resources. We can reuse this code in other projects, use environment variables, write tests, or whatever else our imagination allows.

Conclusion

Using a tool like Pulumi significantly increases the range of options that we can use in building a project's infrastructure while maintaining readability, code reuse and organization.

Oldest comments (4)

Collapse
 
xvbnm48 profile image
M Fariz Wisnu prananda

thanks for sharing

Collapse
 
megaproaktiv profile image
Gernot Glawe

Nice post, thanks.
At the beginning you miss to mention that the AWS CDK as well as the terraform CDK also support GO!
See some examples
go-on-aws.com/infrastructure-as-go...

Collapse
 
eminetto profile image
Elton Minetto

Nice! I will test and write another post :)
Thanks!

Collapse
 
megaproaktiv profile image
Gernot Glawe

As a gopher and cdk user I wrote an intro myself: go-on-aws.com/infrastructure-as-go...