How to Deploy Grafto Using Pulumi: A Quick Guide

Grafto is a small 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. But should, hopefully, illustrate the benefits of having your infrastructure as code in code you actually read and write every day.


This content originally appeared on HackerNoon and was authored by mbv

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.

Wall Of Code

  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": "noreply@mortenvistisen.com",
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}

Improvements

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!


This content originally appeared on HackerNoon and was authored by mbv


Print Share Comment Cite Upload Translate Updates
APA

mbv | Sciencx (2024-07-27T17:45:12+00:00) How to Deploy Grafto Using Pulumi: A Quick Guide. Retrieved from https://www.scien.cx/2024/07/27/how-to-deploy-grafto-using-pulumi-a-quick-guide/

MLA
" » How to Deploy Grafto Using Pulumi: A Quick Guide." mbv | Sciencx - Saturday July 27, 2024, https://www.scien.cx/2024/07/27/how-to-deploy-grafto-using-pulumi-a-quick-guide/
HARVARD
mbv | Sciencx Saturday July 27, 2024 » How to Deploy Grafto Using Pulumi: A Quick Guide., viewed ,<https://www.scien.cx/2024/07/27/how-to-deploy-grafto-using-pulumi-a-quick-guide/>
VANCOUVER
mbv | Sciencx - » How to Deploy Grafto Using Pulumi: A Quick Guide. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2024/07/27/how-to-deploy-grafto-using-pulumi-a-quick-guide/
CHICAGO
" » How to Deploy Grafto Using Pulumi: A Quick Guide." mbv | Sciencx - Accessed . https://www.scien.cx/2024/07/27/how-to-deploy-grafto-using-pulumi-a-quick-guide/
IEEE
" » How to Deploy Grafto Using Pulumi: A Quick Guide." mbv | Sciencx [Online]. Available: https://www.scien.cx/2024/07/27/how-to-deploy-grafto-using-pulumi-a-quick-guide/. [Accessed: ]
rf:citation
» How to Deploy Grafto Using Pulumi: A Quick Guide | mbv | Sciencx | https://www.scien.cx/2024/07/27/how-to-deploy-grafto-using-pulumi-a-quick-guide/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.