From f4618b5d6e10164e2f15e5bc3ebc83999ce581ba Mon Sep 17 00:00:00 2001 From: polkx <82140068+polkx@users.noreply.github.com> Date: Tue, 23 Apr 2024 14:48:26 +0200 Subject: [PATCH] Add Wordpress Fargate RDS example (#475) * Add Wordpress Fargate RDS example --- examples/aws-webserver/Pulumi.yaml | 2 +- .../aws-wordpress-fargate-rds/Backend.scala | 55 +++++++ .../aws-wordpress-fargate-rds/Frontent.scala | 152 ++++++++++++++++++ examples/aws-wordpress-fargate-rds/Main.scala | 58 +++++++ .../aws-wordpress-fargate-rds/Network.scala | 146 +++++++++++++++++ .../aws-wordpress-fargate-rds/Pulumi.yaml | 3 + examples/aws-wordpress-fargate-rds/README.md | 78 +++++++++ .../aws-wordpress-fargate-rds/project.scala | 6 + 8 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 examples/aws-wordpress-fargate-rds/Backend.scala create mode 100644 examples/aws-wordpress-fargate-rds/Frontent.scala create mode 100644 examples/aws-wordpress-fargate-rds/Main.scala create mode 100644 examples/aws-wordpress-fargate-rds/Network.scala create mode 100644 examples/aws-wordpress-fargate-rds/Pulumi.yaml create mode 100644 examples/aws-wordpress-fargate-rds/README.md create mode 100644 examples/aws-wordpress-fargate-rds/project.scala diff --git a/examples/aws-webserver/Pulumi.yaml b/examples/aws-webserver/Pulumi.yaml index c76bc4ad..488af586 100644 --- a/examples/aws-webserver/Pulumi.yaml +++ b/examples/aws-webserver/Pulumi.yaml @@ -1,4 +1,4 @@ -name: aws-java-webserver +name: aws-webserver runtime: scala description: Basic example of an AWS web server accessible over HTTP template: diff --git a/examples/aws-wordpress-fargate-rds/Backend.scala b/examples/aws-wordpress-fargate-rds/Backend.scala new file mode 100644 index 00000000..15219df5 --- /dev/null +++ b/examples/aws-wordpress-fargate-rds/Backend.scala @@ -0,0 +1,55 @@ +import besom.* +import besom.api.aws + +case class DbArgs( + dbName: Input[String], + dbUser: Input[String], + dbPassword: Input[String], + subnetIds: Input[List[String]], + securityGroupIds: Input[List[String]] +) + +case class Db( + dbAddress: Output[String], + dbName: Output[String], + dbUser: Output[String], + dbPassword: Output[String] +)(using ComponentBase) + extends ComponentResource + derives RegistersOutputs + +object Db: + extension (c: Output[Db]) + def dbAddress: Output[String] = c.flatMap(_.dbAddress) + def dbName: Output[String] = c.flatMap(_.dbName) + def dbUser: Output[String] = c.flatMap(_.dbUser) + def dbPassword: Output[String] = c.flatMap(_.dbPassword) + + def apply(using Context)(name: NonEmptyString, args: DbArgs, options: ComponentResourceOptions = ComponentResourceOptions()): Output[Db] = + component(name, "custom:resource:DB", options) { + + val rdsSubnetGroup = aws.rds.SubnetGroup( + name = s"$name-sng", + aws.rds.SubnetGroupArgs(subnetIds = args.subnetIds) + ) + + val db = aws.rds.Instance( + name = s"$name-rds", + aws.rds.InstanceArgs( + dbName = args.dbName, + username = args.dbUser, + password = args.dbPassword, + vpcSecurityGroupIds = args.securityGroupIds, + dbSubnetGroupName = rdsSubnetGroup.name, + allocatedStorage = 20, + engine = "mysql", + engineVersion = "5.7", + instanceClass = aws.rds.enums.InstanceType.T3_Micro, + storageType = aws.rds.enums.StorageType.GP2, + skipFinalSnapshot = true, + publiclyAccessible = false + ) + ) + + Db(dbAddress = db.address, dbName = db.dbName, dbUser = db.username, dbPassword = db.password.map(_.get)) + } diff --git a/examples/aws-wordpress-fargate-rds/Frontent.scala b/examples/aws-wordpress-fargate-rds/Frontent.scala new file mode 100644 index 00000000..c8864bfd --- /dev/null +++ b/examples/aws-wordpress-fargate-rds/Frontent.scala @@ -0,0 +1,152 @@ +import besom.* +import besom.api.{aws, awsx} +import besom.json.* + +case class WebServiceArgs( + dbHost: Input[String], + dbName: Input[String], + dbUser: Input[String], + dbPassword: Input[String], + dbPort: Input[String], + vpcId: Input[String], + subnetIds: Input[List[String]], + securityGroupIds: Input[List[String]] +) + +case class WebService( + serviceId: Output[ResourceId], + dnsName: Output[String], + clusterName: Output[String] +)(using ComponentBase) + extends ComponentResource + derives RegistersOutputs + +object WebService: + extension (c: Output[WebService]) + def dnsName: Output[String] = c.flatMap(_.dnsName) + def clusterName: Output[String] = c.flatMap(_.clusterName) + + def apply(using + Context + )(name: NonEmptyString, args: WebServiceArgs, options: ComponentResourceOptions = ComponentResourceOptions()): Output[WebService] = + component(name, "custom:resource:WebService", options) { + val cluster = aws.ecs.Cluster(s"$name-ecs") + + val alb = aws.lb.LoadBalancer( + name = s"$name-alb", + aws.lb.LoadBalancerArgs( + securityGroups = args.securityGroupIds, + subnets = args.subnetIds + ) + ) + + val atg = aws.lb.TargetGroup( + name = s"$name-tg", + aws.lb.TargetGroupArgs( + port = 80, + protocol = "HTTP", + targetType = "ip", + vpcId = args.vpcId, + healthCheck = aws.lb.inputs.TargetGroupHealthCheckArgs( + healthyThreshold = 2, + interval = 5, + timeout = 4, + protocol = "HTTP", + matcher = "200-399" + ) + ) + ) + + val wl = aws.lb.Listener( + name = s"$name-listener", + aws.lb.ListenerArgs( + loadBalancerArn = alb.arn, + port = 80, + defaultActions = List( + aws.lb.inputs.ListenerDefaultActionArgs( + `type` = "forward", + targetGroupArn = atg.arn + ) + ) + ) + ) + val role = aws.iam.Role( + name = s"$name-task-role", + aws.iam.RoleArgs( + assumeRolePolicy = json"""{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + }, + "Action": "sts:AssumeRole" + }] + }""".map(_.prettyPrint) + ) + ) + + val rpa = aws.iam.RolePolicyAttachment( + name = s"$name-task-policy", + aws.iam.RolePolicyAttachmentArgs( + role = role.name, + policyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" + ) + ) + + val task = awsx.ecs.FargateTaskDefinition( + name = s"$name-app-task", + awsx.ecs.FargateTaskDefinitionArgs( + container = awsx.ecs.inputs.TaskDefinitionContainerDefinitionArgs( + name = s"$name-app-container", + image = "wordpress", + cpu = 256, + memory = 512, + environment = List( + envValue(name = "WORDPRESS_DB_HOST", value = p"${args.dbHost}:${args.dbPort}"), + envValue(name = "WORDPRESS_DB_NAME", value = args.dbName), + envValue(name = "WORDPRESS_DB_USER", value = args.dbUser), + envValue(name = "WORDPRESS_DB_PASSWORD", value = args.dbPassword) + ), + portMappings = List( + awsx.ecs.inputs.TaskDefinitionPortMappingArgs( + containerPort = 80, + hostPort = 80, + protocol = "tcp" + ) + ) + ) + ) + ) + + val service = awsx.ecs.FargateService( + name = s"$name-app-svc", + awsx.ecs.FargateServiceArgs( + networkConfiguration = aws.ecs.inputs.ServiceNetworkConfigurationArgs( + assignPublicIp = true, + subnets = args.subnetIds, + securityGroups = args.securityGroupIds + ), + loadBalancers = List( + aws.ecs.inputs.ServiceLoadBalancerArgs( + containerName = s"$name-app-container", + containerPort = 80, + targetGroupArn = atg.arn + ) + ), + cluster = cluster.arn, + taskDefinition = task.taskDefinition.arn, + desiredCount = 1 + ), + opts = opts(dependsOn = List(wl, rpa)) + ) + + WebService(serviceId = service.id, dnsName = alb.dnsName, clusterName = cluster.name) + } +end WebService + +private def envValue(name: String, value: Input[String])(using + Context +): awsx.ecs.inputs.TaskDefinitionKeyValuePairArgs = + awsx.ecs.inputs.TaskDefinitionKeyValuePairArgs(name = name, value = value) diff --git a/examples/aws-wordpress-fargate-rds/Main.scala b/examples/aws-wordpress-fargate-rds/Main.scala new file mode 100644 index 00000000..d33de3f0 --- /dev/null +++ b/examples/aws-wordpress-fargate-rds/Main.scala @@ -0,0 +1,58 @@ +import besom.* +import besom.api.random + +@main def main = Pulumi.run { + val serviceName = "wp-fargate-rds" + val dbName = config.getString("dbName").getOrElse("wordpress") + val dbUser = config.getString("dbUser").getOrElse("admin") + + val dbPassword = config + .getString("dbPassword") + .getOrElse( + random + .RandomPassword( + "dbPassword", + random.RandomPasswordArgs( + length = 16, + special = true, + overrideSpecial = "_%" + ) + ) + .result + ) + + val vpc = AwsVpc(s"$serviceName-net") + + val db = Db( + name = s"$serviceName-db", + DbArgs( + dbName = dbName, + dbUser = dbUser, + dbPassword = dbPassword, + subnetIds = vpc.subnetIds, + securityGroupIds = vpc.rdsSecurityGroupIds + ) + ) + + val fe = WebService( + s"$serviceName-fe", + WebServiceArgs( + dbHost = db.dbAddress, + dbPort = "3306", + dbName = db.dbName, + dbUser = db.dbUser, + dbPassword = db.dbPassword, + vpcId = vpc.vpcId, + subnetIds = vpc.subnetIds, + securityGroupIds = vpc.feSecurityGroupIds + ) + ) + + Stack.exports( + webServiceUrl = p"http://${fe.dnsName}", + ecsClusterName = fe.clusterName, + databaseEndpoint = db.dbAddress, + databaseUserName = db.dbUser, + databasePassword = db.dbPassword + ) +} diff --git a/examples/aws-wordpress-fargate-rds/Network.scala b/examples/aws-wordpress-fargate-rds/Network.scala new file mode 100644 index 00000000..7f5c4bf9 --- /dev/null +++ b/examples/aws-wordpress-fargate-rds/Network.scala @@ -0,0 +1,146 @@ +import besom.* +import besom.api.aws + +case class AwsVpcArgs( + cidrBlock: Option[String] = None, + instanceTenancy: Option[String] = None, + enableDnsHostnames: Option[Boolean] = None, + enableDnsSupport: Option[Boolean] = None +) + +case class AwsVpc( + vpcId: Output[String], + subnetIds: Output[List[String]], + rdsSecurityGroupIds: Output[List[String]], + feSecurityGroupIds: Output[List[String]] +)(using ComponentBase) + extends ComponentResource + derives RegistersOutputs + +object AwsVpc: + extension (c: Output[AwsVpc]) + def vpcId: Output[String] = c.flatMap(_.vpcId) + def subnetIds: Output[List[String]] = c.flatMap(_.subnetIds) + def rdsSecurityGroupIds: Output[List[String]] = c.flatMap(_.rdsSecurityGroupIds) + def feSecurityGroupIds: Output[List[String]] = c.flatMap(_.feSecurityGroupIds) + + def apply(using + Context + )(name: NonEmptyString, args: AwsVpcArgs = AwsVpcArgs(), options: ComponentResourceOptions = ComponentResourceOptions()): Output[AwsVpc] = + component(name, "custom:resource:VPC", options) { + val vpc = aws.ec2.Vpc( + name = s"$name-vpc", + aws.ec2.VpcArgs( + cidrBlock = args.cidrBlock.getOrElse("10.100.0.0/16"), + instanceTenancy = args.instanceTenancy.getOrElse("default"), + enableDnsHostnames = args.enableDnsHostnames.getOrElse(true), + enableDnsSupport = args.enableDnsSupport.getOrElse(true) + ) + ) + + val igw = aws.ec2.InternetGateway( + name = s"$name-igw", + aws.ec2.InternetGatewayArgs(vpcId = vpc.id) + ) + + val routeTable = aws.ec2.RouteTable( + name = s"$name-rt", + aws.ec2.RouteTableArgs( + vpcId = vpc.id, + routes = List( + aws.ec2.inputs.RouteTableRouteArgs(cidrBlock = "0.0.0.0/0", gatewayId = igw.id) + ) + ) + ) + + val allZones = aws.getAvailabilityZones(aws.GetAvailabilityZonesArgs(state = "available")) + val subnets = Output.sequence( + (1 to 2) + .map(i => + val az = allZones.zoneIds.map(_.apply(i)) + val vpcSubnet = aws.ec2.Subnet( + name = s"$name-subnet-$i", + aws.ec2.SubnetArgs( + assignIpv6AddressOnCreation = false, + vpcId = vpc.id, + mapPublicIpOnLaunch = true, + cidrBlock = s"10.100.$i.0/24", + availabilityZoneId = az + ) + ) + val routeTableAssociation = aws.ec2.RouteTableAssociation( + name = s"vpc-route-table-assoc-$i", + aws.ec2.RouteTableAssociationArgs( + routeTableId = routeTable.id, + subnetId = vpcSubnet.id + ) + ) + routeTableAssociation.flatMap(_ => vpcSubnet.id) + ) + .toList + ) + val rdsSecurityGroup = aws.ec2.SecurityGroup( + name = s"$name-rds-sg", + aws.ec2.SecurityGroupArgs( + vpcId = vpc.id, + description = "Allow client access", + ingress = List( + aws.ec2.inputs.SecurityGroupIngressArgs( + cidrBlocks = List("0.0.0.0/0"), + fromPort = 3306, + toPort = 3306, + protocol = "tcp", + description = "Allow RDS access" + ) + ), + egress = List( + aws.ec2.inputs.SecurityGroupEgressArgs( + protocol = "-1", + fromPort = 0, + toPort = 0, + cidrBlocks = List("0.0.0.0/0") + ) + ) + ) + ) + + val feSecurityGroup = aws.ec2.SecurityGroup( + name = s"$name-fe-sg", + aws.ec2.SecurityGroupArgs( + vpcId = vpc.id, + description = "Allows all HTTP(s) traffic.", + ingress = List( + aws.ec2.inputs.SecurityGroupIngressArgs( + cidrBlocks = List("0.0.0.0/0"), + fromPort = 443, + toPort = 443, + protocol = "tcp", + description = "Allow https" + ), + aws.ec2.inputs.SecurityGroupIngressArgs( + cidrBlocks = List("0.0.0.0/0"), + fromPort = 80, + toPort = 80, + protocol = "tcp", + description = "Allow http" + ) + ), + egress = List( + aws.ec2.inputs.SecurityGroupEgressArgs( + protocol = "-1", + fromPort = 0, + toPort = 0, + cidrBlocks = List("0.0.0.0/0") + ) + ) + ) + ) + + AwsVpc( + vpcId = vpc.id, + subnetIds = subnets, + rdsSecurityGroupIds = rdsSecurityGroup.id.map(List(_)), + feSecurityGroupIds = feSecurityGroup.id.map(List(_)) + ) + } +end AwsVpc diff --git a/examples/aws-wordpress-fargate-rds/Pulumi.yaml b/examples/aws-wordpress-fargate-rds/Pulumi.yaml new file mode 100644 index 00000000..947dbc3f --- /dev/null +++ b/examples/aws-wordpress-fargate-rds/Pulumi.yaml @@ -0,0 +1,3 @@ +name: aws-wordpress-fargate-rds +runtime: scala +description: Deploys Wordpress in ECS Fargate with RDS backend. diff --git a/examples/aws-wordpress-fargate-rds/README.md b/examples/aws-wordpress-fargate-rds/README.md new file mode 100644 index 00000000..3a5580d8 --- /dev/null +++ b/examples/aws-wordpress-fargate-rds/README.md @@ -0,0 +1,78 @@ +# WordPress Site in AWS Fargate with RDS DB Backend + +This example serves a WordPress site in AWS ECS Fargate using an RDS MySQL Backend. + +It leverages the following Pulumi concepts/constructs: + +- [Component Resources](https://www.pulumi.com/docs/intro/concepts/programming-model/#components): Allows one to create + custom resources that encapsulate one's best practices. In this example, component resource is used to define a "VPC" + custom resource, a "Backend" custom resource that sets up the RDS DB, and a "Frontend" resource that sets up the ECS + cluster and load balancer and tasks. +- [Other Providers](https://www.pulumi.com/registry/): Beyond the providers for the various clouds and Kubernetes, etc, + Pulumi allows one to create and manage non-cloud resources. In this case, the program uses the Random provider to + create a random password if necessary. + +This sample uses the following AWS products (and related Pulumi providers): + +- [Amazon VPC](https://aws.amazon.com/vpc): Used to set up a new virtual network in which the system is deployed. +- [Amazon RDS](https://aws.amazon.com/rds): A managed DB service used to provide the MySQL backend for WordPress. +- [Amazon ECS Fargate](https://aws.amazon.com/fargate): A container service used to run the WordPress frontend. + +## Getting Started + +There are no required configuration parameters for this project since the code will use defaults or generate values as +needed. +However, you can override these defaults by using `pulumi config` to set the following values ( +e.g. `pulumi config set dbName my-wp-demo`). + +- `dbName` - The name of the MySQL DB created in RDS. +- `dbUser` - The user created with access to the MySQL DB. +- `dbPassword` - The password for the DB user. Be sure to use `--secret` if creating this config value ( + e.g. `pulumi config set dbPassword --secret`). + +## Deploying and running the program + +Note: some values in this example will be different from run to run. + +1. Create a new stack: + + ```bash + $ pulumi stack init dev + ``` + +2. Set the AWS region: + + ```bash + $ pulumi config set aws:region us-west-2 + ``` + +3. Run `pulumi up` to preview and deploy changes. After the preview is shown you will be + prompted if you want to continue or not. + + ```bash + $ pulumi up + ``` +4. The program outputs the following values: + +- `DB Endpoint`: This is the RDS DB endpoint. By default, the DB is deployed to disallow public access. This can be + overriden in the resource declaration for the backend. +- `DB Password`: This is managed as a secret. To see the value, you can use `pulumi stack output --show-secrets` +- `DB User Name`: The user name for access the DB. +- `ECS Cluster Name`: The name of the ECS cluster created by the stack. +- `Web Service URL`: This is a link to the load balancer fronting the WordPress container. Note: It may take a few + minutes for AWS to complete deploying the service and so you may see a 503 error initially. + +5. To clean up resources, run `pulumi destroy` and answer the confirmation question at the prompt. + +## Troubleshooting + +### 503 Error for the Web Service + +AWS can take a few minutes to complete deploying the WordPress container and connect the load balancer to the service. +So you may see a 503 error for a few minutes right after launching the stack. You can see the status of the service by +looking at the cluster in AWS. + +## Deployment Speed + +Since the stack creates an RDS instance, ECS cluster, load balancer, ECS service, as well as other elements, the stack +can take about 4-5 minutes to launch and become ready. diff --git a/examples/aws-wordpress-fargate-rds/project.scala b/examples/aws-wordpress-fargate-rds/project.scala new file mode 100644 index 00000000..a8953911 --- /dev/null +++ b/examples/aws-wordpress-fargate-rds/project.scala @@ -0,0 +1,6 @@ +//> using scala 3.3.1 +//> using options -Werror -Wunused:all -Wvalue-discard -Wnonunit-statement +//> using dep org.virtuslab::besom-core:0.3.1 +//> using dep org.virtuslab::besom-aws:6.31.0-core.0.3 +//> using dep org.virtuslab::besom-awsx:2.7.0-core.0.3 +//> using dep org.virtuslab::besom-random:4.16.0-core.0.3