Quando falamos de Infraestrutura como Código (Infrastructure as Code, ou IaC), a principal ferramenta que nos vem a mente é o Terraform. A ferramenta criada pela HashiCorp tornou-se o padrão para a documentação e gerenciamento de infraestrutura, mas sua linguagem declarativa, a HCL (HashiCorp Configuration Language) tem algumas limitações. A principal delas é exatamente não ser uma linguagem de programação, e sim de configuração.

Para suprir essa necessidade, algumas alternativas vem surgindo, como:

  • AWS Cloud Development Kit, solução da Amazon que permite usarmos TypeScript, Python e Java para programar a infraestrutura usando as soluções do provedor de cloud;
  • Pulumi, que permite usarmos TypeScript, JavaScript, Python, Go e C# para programarmos infraestruturas usando as soluções da AWS, Microsoft Azure, Google Cloud e instalações de Kubernetes.

Neste post vou fazer uma introdução ao Pulumi, usando a linguagem Go para criar alguns exemplos de infraestrutura na AWS.

Instalação

Para fazer uso do Pulumi precisamos primeiramente instalar seu aplicativo de linha de comando. Seguindo a documentação eu instalei no meu macOS usando o comando:

brew install pulumi

No site é possível ver as opções de instalação no Windows e Linux.

Configurar acesso a conta AWS

Como vou usar neste exemplo a AWS, o próximo passo necessário é configurar as credenciais de acesso. Para isso peguei minha chave de acesso e segredo no painel da AWS e configurei as variáveis de ambiente necessárias:

export AWS_ACCESS_KEY_ID=<YOUR_ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<YOUR_SECRET_ACCESS_KEY>

Criando o projeto

Com as dependências iniciais configuradas, agora podemos criar o projeto:

mkdir post-pulumi
cd post-pulumi
pulumi new aws-go

Uma das etapas da criação exige a configuração de uma conta no site do Pulumi e para isso o navegador é aberto para que este passo seja finalizado. Eu fiz login com a minha conta no Github e após o cadastro finalizado retornei ao terminal e a criação do projeto continuou sem problemas.

O resultado da execução do comando pode ser visto neste link. É possível ver que no final do processo foram instaladas as dependências necessárias para a criação do projeto em Go.

Arquivos criados

Analisando o conteúdo do diretório podemos ver que alguns arquivos de configuração e um main.go foram criados.

Pulumi.yaml

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

Pulumi.dev.yaml

config:
  aws:region: us-east-1

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
	})
}

Ao executar

pulumi up

foi criado o bucket no S3, conforme o código indica.

E ao executar:

pulumi destroy

a estrutura é removida, ou seja, o bucket é destruído.

Primeiro exemplo - criando uma página estática no S3

Vamos agora fazer alguns exemplos um pouco mais complexos.

O primeiro passo é criar uma página estática, que vamos fazer deploy:

mkdir static

Dentro deste diretório criei o arquivo:

static/index.html

<html>
    <body>
        <h1>Hello, Pulumi!</h1>
    </body>
</html>

Alterei o main.go para refletir a nova estrutura:

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
	})
}

Para atualizar basta executar

pulumi up

E confirmar a alteração.

O trecho de código:

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

Faz com que seja gerado como saída endereço para acesso ao index.html, por exemplo:

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

Este é um exemplo bem simples mas que já demonstra o poder da ferramenta. Vamos tornar as coisas um pouco mais complexas e divertidas agora.

Segundo exemplo - site dentro de um container

Vamos criar um Dockerfile com um servidor web para hospedar nosso conteúdo estático:

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

Vamos agora criar o arquivo static/main.go, que vai ser nosso servidor Web:

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())
}

Vamos alterar o main.go para incluir a infraestrutura de um cluster ECS e tudo mais necessário para executar nosso 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)
}

Complexo? Sim, mas essa complexidade é inerente aos recursos da AWS e não do Pulumi. Teríamos uma complexidade similar se estivéssemos usando o Terraform ou o CDK.

Antes de executar o nosso código precisamos fazer o download das novas dependências:

go get github.com/pulumi/pulumi-docker
go get github.com/pulumi/pulumi-docker/sdk/v3/go/docker

Agora basta executar o comando:

pulumi up

No output da execução será gerada a url do load balancer, que faremos uso para acessar o conteúdo do nosso container em execução.

Reorganizando o código

Agora podemos começar a fazer uso das vantagens de estarmos usando uma linguagem de programação completa, como o Go. Poderíamos usar recursos da linguagem como funções, concorrência, condicionais, etc. Neste exemplo vamos organizar melhor nosso código. Para isso os passos abaixo foram realizados:

  • Criado o diretório iac
  • Criado o arquivo iac/fargate.go
  • Movemos boa parte da lógica do main.gopara o novo arquivo:
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)
}

O próximo passo foi configurar o diretório iac para ser um módulo da linguagem Go:

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

O nosso main.go agora pode ser simplificado:

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)
	})
}

Assim podemos gerenciar melhor a estrutura do código que vai manipular os recursos da AWS. Podemos reaproveitar esse código em outros projetos, usar variáveis de ambiente, escrever testes, ou o que mais nossa imaginação permitir.

Conclusão

Usar uma ferramenta como o Pulumi aumenta bastante o leque de opções que podemos usar na construção da infraestrutura de um projeto, mantendo a legibilidade, reaproveitamento de código e organização.