Prompted by client work where I had to consolidate their infrastructure on AWS, I was left with a question if I should use an IaC tool, and if yes, which one? I wrote a bit about that decision process here. In a continuous attempt to throw the real world at my solutions and decisions, I thought it would be interesting, to see how one could go about hosting Grafto, using Pulumi on AWS. Grafto is a small starter template project of mine, that is containerized, so utilizing services such as ECS and Fargate becomes a breeze.
This is a rather naive approach that doesn't utilize a lot of Pulumi's strengths, like allowing us to use design patterns when building out infrastructure. But should, hopefully, illustrate the benefits of having your infrastructure as code in code you actually read and write every day.
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ec2"
8 "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ecs"
9 "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/iam"
10 "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/lb"
11 "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/rds"
12 "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
13)
14
15func main() {
16 pulumi.Run(func(ctx *pulumi.Context) error {
17 availabilityZones := []string{"us-east-1a", "us-east-1b"}
18
19 // VPC
20 vpc, err := ec2.NewVpc(ctx, "grafto-vpc", &ec2.VpcArgs{
21 CidrBlock: pulumi.String("10.0.0.0/16"),
22 EnableDnsHostnames: pulumi.Bool(true),
23 EnableDnsSupport: pulumi.Bool(true),
24 })
25 if err != nil {
26 return err
27 }
28
29 startingSubnetCidrRange := "10.0.0.0/20"
30
31 // SUBNETS
32 subnets := make(map[string][]*ec2.Subnet, len(availabilityZones))
33 for i, az := range availabilityZones {
34 var cidrRangePublic string
35 var cidrRangePrivate string
36 if i == 0 {
37 cidrRangePublic = startingSubnetCidrRange
38 cidrRangePrivate = fmt.Sprintf("10.0.%v.0/20", 16)
39 } else {
40 cidrRangePublic = fmt.Sprintf("10.0.%v.0/20", 16*(i+1))
41 cidrRangePrivate = fmt.Sprintf("10.0.%v.0/20", 16*(i+2))
42 }
43
44 publicSubnet, err := ec2.NewSubnet(
45 ctx,
46 fmt.Sprintf("grafto-%s-subnet-%v", "public", i+1),
47 &ec2.SubnetArgs{
48 VpcId: vpc.ID(),
49 CidrBlock: pulumi.String(cidrRangePublic),
50 AvailabilityZone: pulumi.String(az),
51 },
52 )
53 if err != nil {
54 return err
55 }
56
57 subnets["public"] = append(subnets["public"], publicSubnet)
58
59 privateSubnet, err := ec2.NewSubnet(
60 ctx,
61 fmt.Sprintf("grafto-%s-subnet-%v", "private", i+1),
62 &ec2.SubnetArgs{
63 VpcId: vpc.ID(),
64 CidrBlock: pulumi.String(cidrRangePrivate),
65 AvailabilityZone: pulumi.String(az),
66 },
67 )
68 if err != nil {
69 return err
70 }
71
72 subnets["private"] = append(subnets["private"], privateSubnet)
73 }
74
75 // INTERNET GATEWAY
76 internetGateway, err := ec2.NewInternetGateway(
77 ctx,
78 "grafto-internet-gateway",
79 &ec2.InternetGatewayArgs{
80 VpcId: vpc.ID(),
81 },
82 )
83 if err != nil {
84 return err
85 }
86
87 publicRouteTable, err := ec2.NewRouteTable(
88 ctx,
89 "grafto-public-route-table",
90 &ec2.RouteTableArgs{
91 VpcId: vpc.ID(),
92 },
93 )
94 if err != nil {
95 return err
96 }
97
98 _, err = ec2.NewRoute(ctx, "grafto-public-route", &ec2.RouteArgs{
99 DestinationCidrBlock: pulumi.String("0.0.0.0/0"),
100 GatewayId: internetGateway.ID(),
101 RouteTableId: publicRouteTable.ID(),
102 })
103 if err != nil {
104 return err
105 }
106
107 _, err = ec2.NewRouteTableAssociation(
108 ctx,
109 "grafto-public-route-ass-1",
110 &ec2.RouteTableAssociationArgs{
111 RouteTableId: publicRouteTable.ID(),
112 SubnetId: subnets["public"][0].ID(),
113 },
114 )
115 if err != nil {
116 return err
117 }
118
119 _, err = ec2.NewRouteTableAssociation(
120 ctx,
121 "grafto-public-route-ass-2",
122 &ec2.RouteTableAssociationArgs{
123 RouteTableId: publicRouteTable.ID(),
124 SubnetId: subnets["public"][1].ID(),
125 },
126 )
127 if err != nil {
128 return err
129 }
130
131 // NATGATEWAY
132 elasticIP, err := ec2.NewEip(ctx, "grafto-elastic-ip", &ec2.EipArgs{})
133 if err != nil {
134 return err
135 }
136
137 natGateway, err := ec2.NewNatGateway(ctx, "grafto-nat-gateway", &ec2.NatGatewayArgs{
138 AllocationId: elasticIP.ID(),
139 SubnetId: subnets["public"][0].ID(),
140 })
141 if err != nil {
142 return err
143 }
144
145 privateRouteTable, err := ec2.NewRouteTable(
146 ctx,
147 "grafto-private-route-table",
148 &ec2.RouteTableArgs{
149 VpcId: vpc.ID(),
150 },
151 )
152 if err != nil {
153 return err
154 }
155
156 _, err = ec2.NewRoute(ctx, "grafto-private-route", &ec2.RouteArgs{
157 DestinationCidrBlock: pulumi.String("0.0.0.0/0"),
158 NatGatewayId: natGateway.ID(),
159 RouteTableId: privateRouteTable.ID(),
160 })
161 if err != nil {
162 return err
163 }
164
165 _, err = ec2.NewRouteTableAssociation(
166 ctx,
167 "grafto-private-route-ass-1",
168 &ec2.RouteTableAssociationArgs{
169 RouteTableId: privateRouteTable.ID(),
170 SubnetId: subnets["private"][0].ID(),
171 },
172 )
173 if err != nil {
174 return err
175 }
176
177 _, err = ec2.NewRouteTableAssociation(
178 ctx,
179 "grafto-private-route-ass-2",
180 &ec2.RouteTableAssociationArgs{
181 RouteTableId: privateRouteTable.ID(),
182 SubnetId: subnets["private"][1].ID(),
183 },
184 )
185 if err != nil {
186 return err
187 }
188
189 // SECURITY GROUP
190 applicationLoadBalancer, err := ec2.NewSecurityGroup(
191 ctx,
192 "grafto-alb-sg",
193 &ec2.SecurityGroupArgs{
194 VpcId: vpc.ID(),
195 Ingress: ec2.SecurityGroupIngressArray{
196 &ec2.SecurityGroupIngressArgs{
197 CidrBlocks: pulumi.StringArray{
198 pulumi.String("0.0.0.0/0"),
199 },
200 FromPort: pulumi.Int(80),
201 ToPort: pulumi.Int(80),
202 Protocol: pulumi.String("tcp"),
203 },
204 },
205 Egress: ec2.SecurityGroupEgressArray{
206 &ec2.SecurityGroupEgressArgs{
207 CidrBlocks: pulumi.StringArray{
208 pulumi.String("0.0.0.0/0"),
209 },
210 FromPort: pulumi.Int(0),
211 ToPort: pulumi.Int(0),
212 Protocol: pulumi.String("-1"),
213 },
214 },
215 },
216 )
217 if err != nil {
218 return err
219 }
220
221 ecsSG, err := ec2.NewSecurityGroup(
222 ctx,
223 "grafto-ecs-sg",
224 &ec2.SecurityGroupArgs{
225 VpcId: vpc.ID(),
226 Ingress: ec2.SecurityGroupIngressArray{
227 &ec2.SecurityGroupIngressArgs{
228 CidrBlocks: pulumi.StringArray{
229 pulumi.String("0.0.0.0/0"),
230 },
231 FromPort: pulumi.Int(0),
232 ToPort: pulumi.Int(0),
233 Protocol: pulumi.String("-1"),
234 },
235 },
236 Egress: ec2.SecurityGroupEgressArray{
237 &ec2.SecurityGroupEgressArgs{
238 CidrBlocks: pulumi.StringArray{
239 pulumi.String("0.0.0.0/0"),
240 },
241 FromPort: pulumi.Int(0),
242 ToPort: pulumi.Int(0),
243 Protocol: pulumi.String("-1"),
244 },
245 },
246 },
247 )
248 if err != nil {
249 return err
250 }
251
252 rdsSGG, err := ec2.NewSecurityGroup(
253 ctx,
254 "grafto-rds-sgg",
255 &ec2.SecurityGroupArgs{
256 VpcId: vpc.ID(),
257 Ingress: ec2.SecurityGroupIngressArray{
258 &ec2.SecurityGroupIngressArgs{
259 CidrBlocks: pulumi.StringArray{
260 pulumi.String("0.0.0.0/0"),
261 },
262 FromPort: pulumi.Int(0),
263 ToPort: pulumi.Int(0),
264 Protocol: pulumi.String("-1"),
265 },
266 },
267 Egress: ec2.SecurityGroupEgressArray{
268 &ec2.SecurityGroupEgressArgs{
269 CidrBlocks: pulumi.StringArray{
270 pulumi.String("0.0.0.0/0"),
271 },
272 FromPort: pulumi.Int(0),
273 ToPort: pulumi.Int(0),
274 Protocol: pulumi.String("-1"),
275 },
276 },
277 },
278 )
279 if err != nil {
280 return err
281 }
282
283 rdsSg, err := rds.NewSubnetGroup(ctx, "grafto-rds-sg", &rds.SubnetGroupArgs{
284 SubnetIds: pulumi.StringArray{
285 subnets["private"][0].ID(),
286 subnets["private"][1].ID(),
287 },
288 })
289 if err != nil {
290 return err
291 }
292
293 database, err := rds.NewInstance(ctx, "grafto-rds-psql", &rds.InstanceArgs{
294 AllocatedStorage: pulumi.Int(10),
295 DbName: pulumi.String("grafto"),
296 Password: pulumi.String("password"),
297 Username: pulumi.String("grafto"),
298 Engine: pulumi.String("postgres"),
299 EngineVersion: pulumi.String("16.3"),
300 InstanceClass: pulumi.String("db.t3.micro"),
301 ParameterGroupName: pulumi.String("default.postgres16"),
302 DbSubnetGroupName: rdsSg.Name,
303 VpcSecurityGroupIds: pulumi.StringArray{
304 rdsSGG.ID(),
305 },
306 SkipFinalSnapshot: pulumi.Bool(true),
307 PubliclyAccessible: pulumi.Bool(false),
308 })
309 if err != nil {
310 return err
311 }
312
313 loadBalancer, err := lb.NewLoadBalancer(ctx, "grafto-load-balancer", &lb.LoadBalancerArgs{
314 Internal: pulumi.Bool(false),
315 LoadBalancerType: pulumi.String("application"),
316 SecurityGroups: pulumi.StringArray{
317 applicationLoadBalancer.ID(),
318 },
319 Subnets: pulumi.StringArray{
320 subnets["public"][0].ID(),
321 subnets["public"][1].ID(),
322 },
323 EnableDeletionProtection: pulumi.Bool(false),
324 })
325 if err != nil {
326 return err
327 }
328 ctx.Export("url", pulumi.Sprintf("http://%s", loadBalancer.DnsName))
329
330 targetGroup, err := lb.NewTargetGroup(ctx, "grafto-alb-target-group", &lb.TargetGroupArgs{
331 HealthCheck: &lb.TargetGroupHealthCheckArgs{
332 Path: pulumi.String("/api/health"),
333 Protocol: pulumi.String("HTTP"),
334 },
335 Name: pulumi.String("grafto-app-tg"),
336 Port: pulumi.Int(80),
337 Protocol: pulumi.String("HTTP"),
338 TargetType: pulumi.String("ip"),
339 VpcId: vpc.ID(),
340 })
341 if err != nil {
342 return err
343 }
344
345 _, err = lb.NewListener(ctx, "grafto-alb-listener", &lb.ListenerArgs{
346 DefaultActions: lb.ListenerDefaultActionArray{
347 lb.ListenerDefaultActionArgs{
348 TargetGroupArn: targetGroup.Arn,
349 Type: pulumi.String("forward"),
350 },
351 },
352 LoadBalancerArn: loadBalancer.Arn,
353 Port: pulumi.Int(80),
354 Protocol: pulumi.String("HTTP"),
355 })
356 if err != nil {
357 return err
358 }
359
360 // IAM RELATED STUFF
361 _, err = iam.NewServiceLinkedRole(
362 ctx,
363 "elastic-container-service",
364 &iam.ServiceLinkedRoleArgs{
365 AwsServiceName: pulumi.String("ecs.amazonaws.com"),
366 Description: pulumi.String("Role to enable Amazon ECS to manage your cluster."),
367 },
368 )
369 if err != nil {
370 return err
371 }
372
373 _, err = iam.NewServiceLinkedRole(ctx, "rds", &iam.ServiceLinkedRoleArgs{
374 AwsServiceName: pulumi.String("rds.amazonaws.com"),
375 Description: pulumi.String("Role to enable Amazon RDS to manage your cluster."),
376 })
377 if err != nil {
378 return err
379 }
380
381 _, err = iam.NewServiceLinkedRole(ctx, "elastic-load-balancer", &iam.ServiceLinkedRoleArgs{
382 AwsServiceName: pulumi.String("elasticloadbalancing.amazonaws.com"),
383 Description: pulumi.String("Allows ELB to call AWS services on your behalf"),
384 })
385 if err != nil {
386 return err
387 }
388
389 _, err = iam.NewServiceLinkedRole(
390 ctx,
391 "application-autoscaling",
392 &iam.ServiceLinkedRoleArgs{
393 AwsServiceName: pulumi.String("ecs.application-autoscaling.amazonaws.com"),
394 Description: pulumi.String(
395 "Allows application autoscaling to call AWS services on your behalf",
396 ),
397 },
398 )
399 if err != nil {
400 return err
401 }
402
403 roleJson, err := json.Marshal(map[string]interface{}{
404 "Version": "2012-10-17",
405 "Statement": []map[string]interface{}{
406 {
407 "Action": []string{
408 "sts:AssumeRole",
409 },
410 "Principal": map[string]string{"Service": "ecs-tasks.amazonaws.com"},
411 "Effect": "Allow",
412 },
413 },
414 })
415 if err != nil {
416 return err
417 }
418 role, err := iam.NewRole(ctx, "grafto-iam-role", &iam.RoleArgs{
419 Name: pulumi.String("grafto-iam-role"),
420 AssumeRolePolicy: pulumi.String(string(roleJson)),
421 })
422 if err != nil {
423 return err
424 }
425
426 rolePolicyJson, err := json.Marshal(map[string]interface{}{
427 "Version": "2012-10-17",
428 "Statement": []map[string]interface{}{
429 {
430 "Action": []string{
431 "ecr:*",
432 },
433 "Effect": "Allow",
434 "Resource": "*",
435 },
436 },
437 })
438 if err != nil {
439 return err
440 }
441 _, err = iam.NewRolePolicy(ctx, "grafto-iam-role-policy", &iam.RolePolicyArgs{
442 Name: pulumi.String("grafto-iam-role"),
443 Role: role.Name,
444 Policy: pulumi.String(string(rolePolicyJson)),
445 })
446 if err != nil {
447 return err
448 }
449
450 // ELASTIC CONTAINER SERVICE
451 cluster, err := ecs.NewCluster(ctx, "grafto-ecs-cluster", &ecs.ClusterArgs{
452 Name: pulumi.String("grafto"),
453 })
454 if err != nil {
455 return err
456 }
457
458 taskContainerDefinition := pulumi.JSONMarshal([]map[string]interface{}{
459 {
460 "name": "grafto-task",
461 "image": "docker.io/mbvofdocker/grafto:pulumi-blog",
462 "portMappings": []map[string]interface{}{
463 {
464 "containerPort": 8080,
465 "hostPort": 8080,
466 "protocol": "HTTP",
467 },
468 },
469 "essential": true,
470 "command": []string{"./app"},
471 "environment": []map[string]interface{}{
472 {
473 "name": "ENVIRONMENT",
474 "value": "production",
475 },
476 {
477 "name": "SERVER_HOST",
478 "value": "0.0.0.0",
479 },
480 {
481 "name": "SERVER_PORT",
482 "value": "8080",
483 },
484 {
485 "name": "DEFAULT_SENDER_SIGNATURE",
486 "value": "[email protected]",
487 },
488 {
489 "name": "POSTMARK_API_TOKEN",
490 "value": "insert-valid-token-here",
491 },
492 {
493 "name": "DB_KIND",
494 "value": "postgres",
495 },
496 {
497 "name": "DB_PORT",
498 "value": "5432",
499 },
500 {
501 "name": "DB_HOST",
502 "value": database.Address.ApplyT(
503 func(addr string) string {
504 return addr
505 },
506 ).(pulumi.StringOutput),
507 },
508 {
509 "name": "DB_NAME",
510 "value": database.DbName.ApplyT(
511 func(name string) string {
512 return name
513 },
514 ).(pulumi.StringOutput),
515 },
516 {
517 "name": "DB_USER",
518 "value": database.Username.ApplyT(
519 func(name string) string {
520 return name
521 },
522 ).(pulumi.StringOutput),
523 },
524 {
525 "name": "DB_PASSWORD",
526 "value": database.Password.ApplyT(
527 func(pass *string) string {
528 return *pass
529 },
530 ).(pulumi.StringOutput),
531 },
532 {
533 "name": "DB_SSL_MODE",
534 "value": "require",
535 },
536 {
537 "name": "PASSWORD_PEPPER",
538 "value": "lotsandlotsofrandomcharshere",
539 },
540 {
541 "name": "PROJECT_NAME",
542 "value": "Pulumi Grafto BLog Post",
543 },
544 {
545 "name": "APP_HOST",
546 "value": loadBalancer.DnsName.ApplyT(func(url string) string {
547 return url
548 }),
549 },
550 {
551 "name": "APP_SCHEME",
552 "value": "http",
553 },
554 {
555 "name": "CSRF_TOKEN",
556 "value": "lotsandlotsofrandomcharshere",
557 },
558 {
559 "name": "SESSION_KEY",
560 "value": "lotsandlotsofrandomcharshere",
561 },
562 {
563 "name": "SESSION_ENCRYPTION_KEY",
564 "value": "lotsandlotsofrandomcharshere",
565 },
566 {
567 "name": "TOKEN_SIGNING_KEY",
568 "value": "lotsandlotsofrandomcharshere",
569 },
570 },
571 },
572 })
573 taskDefinition, err := ecs.NewTaskDefinition(ctx, "grafto-task", &ecs.TaskDefinitionArgs{
574 ContainerDefinitions: taskContainerDefinition,
575 Cpu: pulumi.String("256"),
576 ExecutionRoleArn: role.Arn,
577 Family: pulumi.String("grafto"),
578 Memory: pulumi.String("512"),
579 NetworkMode: pulumi.String("awsvpc"),
580 TaskRoleArn: role.Arn,
581 })
582 if err != nil {
583 return err
584 }
585
586 _, err = ecs.NewService(ctx, "grafto-service", &ecs.ServiceArgs{
587 Cluster: cluster.Arn,
588 DeploymentMaximumPercent: pulumi.IntPtr(200),
589 DeploymentMinimumHealthyPercent: pulumi.IntPtr(50),
590 DesiredCount: pulumi.IntPtr(1),
591 ForceNewDeployment: pulumi.Bool(true),
592 LoadBalancers: ecs.ServiceLoadBalancerArray{
593 &ecs.ServiceLoadBalancerArgs{
594 TargetGroupArn: targetGroup.Arn,
595 ContainerName: pulumi.String("grafto-task"),
596 ContainerPort: pulumi.Int(8080),
597 },
598 },
599 NetworkConfiguration: ecs.ServiceNetworkConfigurationArgs{
600 Subnets: pulumi.StringArray{
601 subnets["private"][0].ID(),
602 subnets["private"][1].ID(),
603 },
604 SecurityGroups: pulumi.StringArray{
605 ecsSG.ID(),
606 },
607 },
608 Name: pulumi.String("grafto-ecs-service"),
609 LaunchType: pulumi.String("FARGATE"),
610 PlatformVersion: pulumi.String("1.4.0"),
611 TaskDefinition: taskDefinition.Arn,
612 })
613 if err != nil {
614 return err
615 }
616
617 return nil
618 })
619}
An obvious improvement to the above would be to enable HTTPS; if you check the load balancer's security group, you can see that we allow ingress traffic on port 80. This is the only entry point since our Fargate tasks are all in private networks, so adding a certificate would limit it to port 443 which could go a long way.
Take a look at the calculations of the CIDR ranges. If we add too many availability zones, this will fail which is something to handle as well. Could be a simple check on how many AZs are required and limit it to a certain level but should still be fixed.
It would also be beneficial to store the environmental variables somewhere like AWS's parameter store, and not directly in the code.
You'll probably also have noticed multiple opportunities for re-using code, through setup functions or, my personal favorite in this case, builders.
Builders can simplify the code a lot, especially if the number of tasks you've in your ECS service increases. In a future article, we'll improve upon this so we can easily expand upon our infrastructure.
An interesting comparison would be to do the same for Terraform and see how much they differ, and if the effort in making the infrastructure code reusable with different design patterns makes sense in the end.
But for now, that's all. Happy hacking!