├── .dockerignore ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── docker-compose.yml ├── docs └── screenshots │ ├── acm-certificates.png │ ├── codepipeline-detail-view.png │ ├── codepipeline-overview.png │ ├── ecs-cluster-view.png │ └── ecs-task-view.png ├── infra └── aws │ └── eu-west-1 │ └── production │ ├── acm.tf │ ├── alb.tf │ ├── codebuild.tf │ ├── codepipeline.tf │ ├── ecs-instance.tf │ ├── ecs-service.tf │ ├── ecs.tf │ ├── iam.tf │ ├── locals.tf │ ├── outputs.tf │ ├── providers.tf │ ├── rds.tf │ ├── route53.tf │ ├── ssm.tf │ ├── sts.tf │ ├── templates │ ├── bootstrap.sh.tpl │ ├── buildspec.yml │ ├── codebuild_iam_policy.json │ ├── codebuild_iam_role_policy.json │ ├── codepipeline_iam_policy.json │ ├── codepipeline_iam_role_policy.json │ ├── ecs_container_instance_iam_role_policy.json │ ├── ecs_execution_iam_policy.json │ ├── ecs_tasks_iam_role_policy.json │ └── taskdefinition.json │ ├── terraform-state.tf │ ├── variables.tf │ └── vpc.tf ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── ruanbekker │ │ └── cargarage │ │ ├── CarGarageApplication.java │ │ ├── controller │ │ ├── CarController.java │ │ └── IndexController.java │ │ ├── exception │ │ └── ResourceNotFoundException.java │ │ ├── model │ │ └── Car.java │ │ └── repository │ │ └── CarRepository.java └── resources │ ├── application.properties │ └── logback.xml └── test ├── java └── com │ └── ruanbekker │ └── cargarage │ └── CarGarageApplicationTests.java └── resources └── application.properties /.dockerignore: -------------------------------------------------------------------------------- 1 | # ignore terraform from docker builds 2 | infra/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore terraform from git 2 | infra/*/*/*/.terraform/* 3 | infra/*/*/*/*.tfstate 4 | infra/*/*/*/*.tfstate.* 5 | infra/*/*/*/crash.log 6 | infra/*/*/*/*.tfvars 7 | infra/*/*/*/override.tf 8 | infra/*/*/*/override.tf.json 9 | infra/*/*/*/*_override.tf 10 | infra/*/*/*/*_override.tf.json 11 | infra/*/*/*/.terraformrc 12 | infra/*/*/*/terraform.rc 13 | infra/*/*/*/.terraform.lock.hcl 14 | # ignore target jar 15 | target/* 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "java.configuration.updateBuildConfiguration": "interactive" 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM adoptopenjdk/maven-openjdk11 as builder 2 | WORKDIR /app 3 | COPY . /app 4 | RUN --mount=type=cache,target=/root/.m2 mvn -f /app/pom.xml clean install 5 | 6 | FROM adoptopenjdk:11-jre-hotspot 7 | WORKDIR /app 8 | ARG JAR_FILE=/app/target/*.jar 9 | COPY --from=builder $JAR_FILE /app/app.jar 10 | 11 | EXPOSE 8080 12 | CMD ["java", "-jar", "/app/app.jar"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-terraform-cicd-java-springboot 2 | Terraform: AWS CICD with CodePipeline, CodeBuild, ECS and a Springboot App 3 | 4 | The [all-in-one](https://github.com/ruanbekker/aws-terraform-cicd-java-springboot/tree/all-in-one) branch has the application code, application infrastructure and pipeline infrastructure in one repository. 5 | 6 | ## Description 7 | 8 | This is a demo on how to use Terraform to deploy your AWS Infrastructure for your Java Springboot application to run as a container on ECS. 9 | 10 | You will be able to boot your application locally using docker-compose as well as building the following infrastructure on AWS for this application: 11 | 12 | - ALB, Target Groups, 80 and 443 Target Group Listeners, with Listener Configurations 13 | - ACM Certificates, ACM Certificate Validation and Route53 Configuration 14 | - CI/CD Pipeline with CodePipeline, CodeBuild and Deployment to ECS with EC2 as target 15 | - Github Webhook (Pipeline will trigger on the main branch but configurable in `variables.tf`) 16 | - ECR Repository 17 | - ECS Container Instance with Userdata 18 | - ECS Cluster, ECS Service and ECS Task Definition with variables 19 | - S3 Buckets for CodePipeline and CodeBuild Cache 20 | - RDS MySQL Instance 21 | - SSM Parameters for RDS Password, Hostname etc, which we will place into the Task Definition as well 22 | - IAM Roles, Policies and Security Groups 23 | 24 | When I tested, terraform took `4m 24s` to deploy the infrastructure and when I made a commit to the `main` branch the pipeline took about 5 minutes to deploy. 25 | 26 | ## Deploy Local 27 | 28 | Boot our application with docker-compose: 29 | 30 | ``` 31 | $ docker-compose up --build 32 | ``` 33 | 34 | ## Test the Application Locally 35 | 36 | Make a request to view all cars: 37 | 38 | ``` 39 | $ curl http://localhost:8080/api/cars 40 | [] 41 | ``` 42 | 43 | Create one car: 44 | 45 | ``` 46 | $ curl -H "Content-Type: application/json" http://localhost:8080/api/cars -d '{"make":"bmw", "model": "m3"}' 47 | {"id":3,"make":"bmw","model":"m3","createdAt":"2021-03-01T14:12:07.624+00:00","updatedAt":"2021-03-01T14:12:07.624+00:00"} 48 | ``` 49 | 50 | View all cars again: 51 | 52 | ``` 53 | $ curl http://localhost:8080/api/cars 54 | [{"id":3,"make":"bmw","model":"m3","createdAt":"2021-03-01T14:12:08.000+00:00","updatedAt":"2021-03-01T14:12:08.000+00:00"}] 55 | ``` 56 | 57 | View a specific car: 58 | 59 | ``` 60 | $ curl http://localhost:8080/api/cars/3 61 | {"id":3,"make":"bmw","model":"m3","createdAt":"2021-03-01T14:12:08.000+00:00","updatedAt":"2021-03-01T14:12:08.000+00:00"} 62 | ``` 63 | 64 | Delete a car: 65 | 66 | ``` 67 | $ curl -XDELETE http://localhost:8080/api/cars/3 68 | ``` 69 | 70 | View application status: 71 | 72 | ``` 73 | $ curl -s http://localhost:8080/status | jq . 74 | { 75 | "status": "UP", 76 | "components": { 77 | "db": { 78 | "status": "UP", 79 | "details": { 80 | "database": "MySQL", 81 | "validationQuery": "isValid()" 82 | } 83 | }, 84 | "diskSpace": { 85 | "status": "UP", 86 | "details": { 87 | "total": 62725623808, 88 | "free": 2183278592, 89 | "threshold": 10485760, 90 | "exists": true 91 | } 92 | }, 93 | "ping": { 94 | "status": "UP" 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | Or the database status individually: 101 | 102 | ``` 103 | $ curl -s http://localhost:8080/status/db 104 | {"status":"UP","details":{"database":"MySQL","validationQuery":"isValid()"}} 105 | ``` 106 | 107 | ## Installing Terraform 108 | 109 | For Mac: 110 | 111 | ``` 112 | $ wget https://releases.hashicorp.com/terraform/0.14.7/terraform_0.14.7_darwin_amd64.zip 113 | $ unzip terraform_0.14.7_darwin_amd64.zip 114 | $ mv ./terraform /usr/local/bin/terraform 115 | $ rm -rf terraform_0.14.7_darwin_amd64.zip 116 | ``` 117 | 118 | View the version: 119 | 120 | ``` 121 | $ terraform -version 122 | Terraform v0.14.7 123 | ``` 124 | 125 | ## Assumptions for AWS 126 | 127 | For AWS, I have the current existing resources, which I will reference in terraform with the `data` source: 128 | 129 | ### vpc 130 | - vpc with the name "main", which is my non default-vpc 131 | 132 | ### subnets 133 | - 3 public subnets with tags Tier:public 134 | - 3 private subnets with tags Tier:private 135 | 136 | ### nat gateway 137 | - nat gw with eip for private range and added to my private routing table 0.0.0.0/0 to natgw 138 | 139 | ### rds 140 | - subnet group with the name "private" which is linked to my private subnets 141 | 142 | ### route53 143 | - existing hosted zone 144 | 145 | ### codestar connections 146 | - codestar connection linked to my github account: 147 | - https://eu-west-1.console.aws.amazon.com/codesuite/settings/connections 148 | - the connection id is defined in: var.codestar_connection_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 149 | 150 | ## Required Environment Variables 151 | 152 | Github Personal Access Token: 153 | 154 | Head over to https://github.com/settings/tokens/new and create a token with the following scopes: 155 | - `admin:repo_hook` 156 | 157 | Set the environment variable as: 158 | 159 | ``` 160 | $ export TF_VAR_github_token=${your-github-pat} 161 | ``` 162 | 163 | which will be referenced in `infra/aws/eu-west-1/production/locals.tf`: 164 | 165 | ``` 166 | # locals.tf 167 | locals { 168 | github_token = var.github_token 169 | } 170 | ``` 171 | 172 | Other variables that needs replacement resides in `infra/aws/eu-west-1/production/variables.tf`: 173 | 174 | ``` 175 | variable "aws_region" {} 176 | variable "codebuild_docker_image" {} 177 | variable "codebuild_security_group_name" {} 178 | variable "codepipeline_build_stage_name" {} 179 | variable "codepipeline_deploy_stage_name" {} 180 | variable "codepipeline_source_stage_name" {} 181 | variable "codestar_connection_id" {} 182 | variable "container_desired_count" {} 183 | variable "container_port" {} 184 | variable "container_reserved_task_memory" {} 185 | variable "ecs_cluster_name" {} 186 | variable "ecs_container_instance_type" {} 187 | variable "ecs_tg_healthcheck_endpoint" {} 188 | variable "environment_name" {} 189 | variable "github_branch" {} 190 | variable "github_repo_name" {} 191 | variable "github_token" {} 192 | variable "github_username" {} 193 | variable "host_port" {} 194 | variable "platform_type" {} 195 | variable "rds_admin_username" {} 196 | variable "rds_instance_type" {} 197 | variable "rds_subnet_group_name" {} 198 | variable "route53_hosted_zone" {} 199 | variable "route53_record_set" {} 200 | variable "service_hostname" {} 201 | variable "service_name" {} 202 | variable "service_name_short" {} 203 | variable "ssh_keypair_name" {} 204 | variable "vpc_name" {} 205 | ``` 206 | 207 | Also ensure your configuration matches your setup in: 208 | - `infra/aws/eu-west-1/production/providers.tf` 209 | - `infra/aws/eu-west-1/production/terraform-state.tf` 210 | 211 | ## Notes 212 | 213 | I am using the admin credentials for the application to use to authenticate against rds (for this demo), but you can use something like ansible and the local-exec provisioner to provision a rds username and password like [here](https://github.com/ruanbekker/terraformfiles/blob/master/aws-cicd-ecs-codepipeline/existing-vpc-ecs-rds-new-dbuser-ansible-ssm/infra/rds.tf#L11-L40). 214 | 215 | I am also using `String` as the type for SSM, if you save secret information, you should be using `SecureString` and encrypt it with KMS, but for the demo I won't be doing that. 216 | 217 | 218 | ## Deploy Infrastructure to AWS 219 | 220 | Validate: 221 | 222 | ``` 223 | $ terraform validate 224 | Success! The configuration is valid. 225 | ``` 226 | 227 | Variables isn't supported for backend, see [this issue](https://github.com/hashicorp/terraform/issues/13022#issuecomment-294262392), to use variables, you can [look at this example](https://github.com/ruanbekker/terraformfiles/tree/master/s3-backend-with-variables): 228 | 229 | Initialize: 230 | 231 | ``` 232 | $ terraform init -input=false 233 | ``` 234 | 235 | Plan: 236 | 237 | ``` 238 | $ terraform plan 239 | ... 240 | # aws_acm_certificate.cert will be created 241 | # aws_acm_certificate_validation.validate will be created 242 | # aws_alb.ecs will be created 243 | # aws_alb_listener.http will be created 244 | # aws_alb_listener.https will be created 245 | # aws_alb_target_group.service_tg will be created 246 | # aws_cloudwatch_log_group.ecs will be created 247 | # aws_codebuild_project.build will be created 248 | # aws_codepipeline.pipeline will be created 249 | # aws_codepipeline_webhook.webhook will be created 250 | # aws_codestarconnections_connection.github will be created 251 | # aws_db_instance.prod will be created 252 | # aws_ecr_repository.repo will be created 253 | # aws_ecs_cluster.prod will be created 254 | # aws_ecs_service.service will be created 255 | # aws_ecs_task_definition.service will be created 256 | # aws_iam_instance_profile.ecs_instance will be created 257 | # aws_iam_role.codebuild_role will be created 258 | # aws_iam_role.codepipeline_role will be created 259 | # aws_iam_role.ecs_instance_role will be created 260 | # aws_iam_role.ecs_task_role will be created 261 | # aws_iam_role_policy.codebuild_policy will be created 262 | # aws_iam_role_policy.codepipeline_policy will be created 263 | # aws_iam_role_policy.ecs_instance_policy will be created 264 | # aws_iam_role_policy.ecs_task_policy will be created 265 | # aws_instance.ec2 will be created 266 | # aws_lb_listener_rule.forward_to_tg will be created 267 | # aws_route53_record.record["rbkr.xyz"] will be created 268 | # aws_route53_record.www will be created 269 | # aws_s3_bucket.codepipeline_artifact_store will be created 270 | # aws_security_group.alb will be created 271 | # aws_security_group.codebuild will be created 272 | # aws_security_group.ecs_instance will be created 273 | # aws_security_group.rds_instance will be created 274 | # aws_security_group_rule.alb_egress will be created 275 | # aws_security_group_rule.container_port will be created 276 | # aws_security_group_rule.ec2_egress will be created 277 | # aws_security_group_rule.http will be created 278 | # aws_security_group_rule.https will be created 279 | # aws_security_group_rule.mysql will be created 280 | # aws_security_group_rule.ssh will be created 281 | # aws_ssm_parameter.database_host will be created 282 | # aws_ssm_parameter.database_name will be created 283 | # aws_ssm_parameter.database_password will be created 284 | # aws_ssm_parameter.database_port will be created 285 | # aws_ssm_parameter.database_user will be created 286 | # github_repository_webhook.webhook will be created 287 | # random_password.db_admin_password will be created 288 | # random_shuffle.subnets will be created 289 | # random_string.secret will be created 290 | Plan: 50 to add, 0 to change, 0 to destroy. 291 | ``` 292 | 293 | Apply: 294 | 295 | ``` 296 | $ terraform apply -input=false -auto-approve 297 | Apply complete! Resources: 55 added, 0 changed, 0 destroyed. 298 | Releasing state lock. This may take a few moments... 299 | 300 | Outputs: 301 | 302 | account_id = "xxxxxxxxxxxx" 303 | alb_dns = "ecs-prod-alb-xxxxxxxxxx.eu-west-1.elb.amazonaws.com" 304 | caller_arn = "arn:aws:iam::xxxxxxxxxxxx:user/x" 305 | caller_user = "AXXXXXXXXXXXXXXXXXXXXXX" 306 | db_address = "ecs-prod-rds-instance.xxxxxxxxxxxx.eu-west-1.rds.amazonaws.com" 307 | environment_name = "prod" 308 | service_hostname = "www.rbkr.xyz" 309 | 310 | ~/aws-terraform-cicd-java-springboot/infra/aws/eu-west-1/production main* 4m 24s 311 | ``` 312 | 313 | Now that your infrastructure is built, we can trigger our repo to start the pipeline: 314 | 315 | ``` 316 | $ git commit --allow-empty --message "trigger pipeline" 317 | $ git push origin main 318 | ``` 319 | 320 | ## A Tour through our Infra 321 | 322 | We can see our Pipeline when you navigate to CodePipeline: 323 | 324 | ![](docs/screenshots/codepipeline-overview.png) 325 | 326 | When you select the pipeline to see our stages: 327 | 328 | ![](docs/screenshots/codepipeline-detail-view.png) 329 | 330 | We can view our ECS Cluster: 331 | 332 | ![](docs/screenshots/ecs-cluster-view.png) 333 | 334 | Our task: 335 | 336 | ![](docs/screenshots/ecs-task-view.png) 337 | 338 | And also check that our ACM Certificates was validated (but terraform did that already): 339 | 340 | ![](docs/screenshots/acm-certificates.png) 341 | 342 | ## Test the Application on AWS 343 | 344 | Make a request to view all the cars: 345 | 346 | ``` 347 | ❯ curl -i https://www.rbkr.xyz/api/cars 348 | HTTP/2 200 349 | date: Wed, 03 Mar 2021 15:29:41 GMT 350 | content-type: application/json 351 | 352 | [] 353 | ``` 354 | 355 | Create a car: 356 | 357 | ``` 358 | ❯ curl -i -H "Content-Type: application/json" -XPOST https://www.rbkr.xyz/api/cars -d '{"make": "bmw", "model": "m3"}' 359 | HTTP/2 200 360 | date: Wed, 03 Mar 2021 15:29:33 GMT 361 | content-type: application/json 362 | 363 | {"id":1,"make":"bmw","model":"m3","createdAt":"2021-03-03T15:29:33.707+00:00","updatedAt":"2021-03-03T15:29:33.707+00:00"} 364 | ``` 365 | 366 | View all the cars: 367 | 368 | ``` 369 | ❯ curl -i https://www.rbkr.xyz/api/cars 370 | HTTP/2 200 371 | date: Wed, 03 Mar 2021 15:29:41 GMT 372 | content-type: application/json 373 | 374 | [{"id":1,"make":"bmw","model":"m3","createdAt":"2021-03-03T15:29:34.000+00:00","updatedAt":"2021-03-03T15:29:34.000+00:00"}] 375 | ``` 376 | 377 | View the application status: 378 | 379 | ``` 380 | ❯ curl -s https://www.rbkr.xyz/status | jq . 381 | { 382 | "status": "UP", 383 | "components": { 384 | "db": { 385 | "status": "UP", 386 | "details": { 387 | "database": "MySQL", 388 | "validationQuery": "isValid()" 389 | } 390 | }, 391 | "diskSpace": { 392 | "status": "UP", 393 | "details": { 394 | "total": 10501771264, 395 | "free": 9604685824, 396 | "threshold": 10485760, 397 | "exists": true 398 | } 399 | }, 400 | "ping": { 401 | "status": "UP" 402 | } 403 | } 404 | } 405 | ``` 406 | 407 | ## Destroy Infrastructure on AWS: 408 | 409 | Destroy: 410 | 411 | ``` 412 | $ terraform destroy -auto-approve 413 | Destroy complete! Resources: 55 destroyed. 414 | Releasing state lock. This may take a few moments... 415 | ``` 416 | 417 | ## Destroy Application running Locally: 418 | 419 | ``` 420 | $ docker-compose down 421 | ``` 422 | 423 | ## Resources 424 | 425 | ### AWS Resources 426 | 427 | - [Difference between Task Role and Execution Role](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_TaskDefinition.html) 428 | 429 | ### Terraform Resources 430 | 431 | - [Shunit Tests for User Data](https://alexharv074.github.io/2020/01/31/unit-testing-a-terraform-user_data-script-with-shunit2.html) 432 | 433 | ### Java Resources 434 | - [spring-testing-separate-data-source](https://www.baeldung.com/spring-testing-separate-data-source) and [github](https://github.com/eugenp/tutorials/tree/master/persistence-modules/spring-boot-persistence) 435 | - [testing-with-configuration-classes-and-profiles](https://spring.io/blog/2011/06/21/spring-3-1-m2-testing-with-configuration-classes-and-profiles) 436 | - [hibernate-ddl-auto-example](https://www.onlinetutorialspoint.com/hibernate/hbm2ddl-auto-example-hibernate-xml-config.html) 437 | - [cleaning-up-spring-boot-integration-tests-logs](https://ricardolsmendes.medium.com/cleaning-up-spring-boot-integration-tests-logs-5b2d0a5f29bc) 438 | - [docker-caching-strategies](https://testdriven.io/blog/faster-ci-builds-with-docker-cache/) 439 | 440 | ## Credit 441 | 442 | Huge thanks to [Cobus Bernard](https://github.com/cobusbernard/aws-containers-for-beginners) for his webinar back in 2019, and for sharing his terraform source code, as I learned a LOT from him, and this example is based off his terraform structure. 443 | 444 | Also great thanks to [callicoder](https://www.callicoder.com/spring-boot-rest-api-tutorial-with-mysql-jpa-hibernate/) for the rest api example which this example is based off. 445 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: ./Dockerfile 8 | container_name: app 9 | depends_on: 10 | - db 11 | environment: 12 | - M2_HOME=/root/.m2 13 | - MYSQL_HOST=db 14 | - MYSQL_USER=root 15 | - MYSQL_PASS=password 16 | - MYSQL_DATABASE=cargarage 17 | networks: 18 | - app 19 | ports: 20 | - 8080:8080 21 | volumes: 22 | - $HOME/.m2:/root/.m2 23 | restart: unless-stopped 24 | 25 | db: 26 | image: mysql:8.0 27 | container_name: db 28 | environment: 29 | - MYSQL_ROOT_PASSWORD=password 30 | - MYSQL_USERNAME=app 31 | - MYSQL_PASSWORD=app 32 | - MYSQL_DATABASE=cargarage 33 | - MYSQL_ROOT_HOST=% 34 | networks: 35 | - app 36 | volumes: 37 | - dbdata:/var/lib/mysql 38 | command: --default-authentication-plugin=mysql_native_password 39 | 40 | networks: 41 | app: {} 42 | 43 | volumes: 44 | dbdata: {} -------------------------------------------------------------------------------- /docs/screenshots/acm-certificates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruanbekker/aws-terraform-cicd-java-springboot/1b393922ab485f33cbf2665fe230cb2f701fcfc0/docs/screenshots/acm-certificates.png -------------------------------------------------------------------------------- /docs/screenshots/codepipeline-detail-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruanbekker/aws-terraform-cicd-java-springboot/1b393922ab485f33cbf2665fe230cb2f701fcfc0/docs/screenshots/codepipeline-detail-view.png -------------------------------------------------------------------------------- /docs/screenshots/codepipeline-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruanbekker/aws-terraform-cicd-java-springboot/1b393922ab485f33cbf2665fe230cb2f701fcfc0/docs/screenshots/codepipeline-overview.png -------------------------------------------------------------------------------- /docs/screenshots/ecs-cluster-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruanbekker/aws-terraform-cicd-java-springboot/1b393922ab485f33cbf2665fe230cb2f701fcfc0/docs/screenshots/ecs-cluster-view.png -------------------------------------------------------------------------------- /docs/screenshots/ecs-task-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruanbekker/aws-terraform-cicd-java-springboot/1b393922ab485f33cbf2665fe230cb2f701fcfc0/docs/screenshots/ecs-task-view.png -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/acm.tf: -------------------------------------------------------------------------------- 1 | resource "aws_acm_certificate" "cert" { 2 | domain_name = var.route53_hosted_zone 3 | subject_alternative_names = ["*.${var.route53_hosted_zone}"] 4 | validation_method = "DNS" 5 | 6 | tags = { 7 | Name = var.route53_hosted_zone 8 | Environment = var.environment_name 9 | } 10 | 11 | lifecycle { 12 | create_before_destroy = true 13 | } 14 | } 15 | 16 | data "aws_route53_zone" "zone" { 17 | name = var.route53_hosted_zone 18 | private_zone = false 19 | } 20 | 21 | resource "aws_route53_record" "record" { 22 | for_each = { 23 | for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => { 24 | name = dvo.resource_record_name 25 | record = dvo.resource_record_value 26 | type = dvo.resource_record_type 27 | } 28 | } 29 | 30 | allow_overwrite = true 31 | name = each.value.name 32 | records = [each.value.record] 33 | ttl = 60 34 | type = each.value.type 35 | zone_id = data.aws_route53_zone.zone.zone_id 36 | } 37 | 38 | resource "aws_acm_certificate_validation" "validate" { 39 | certificate_arn = aws_acm_certificate.cert.arn 40 | validation_record_fqdns = [for record in aws_route53_record.record : record.fqdn] 41 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/alb.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "alb" { 2 | name = "${var.environment_name}-${var.ecs_cluster_name}-alb-sg" 3 | description = "Allows Traffic from Internet to ALB SG" 4 | vpc_id = data.aws_vpc.main.id 5 | tags = { 6 | Name = "${var.environment_name}-${var.ecs_cluster_name}-alb-sg" 7 | } 8 | } 9 | 10 | resource "aws_security_group_rule" "https" { 11 | security_group_id = aws_security_group.alb.id 12 | description = "Allows Internet to Connect to TCP 443" 13 | type = "ingress" 14 | protocol = "tcp" 15 | from_port = 443 16 | to_port = 443 17 | cidr_blocks = ["0.0.0.0/0"] 18 | } 19 | 20 | resource "aws_security_group_rule" "http" { 21 | security_group_id = aws_security_group.alb.id 22 | description = "Allows Internet to Connect to TCP 80" 23 | type = "ingress" 24 | protocol = "tcp" 25 | from_port = 80 26 | to_port = 80 27 | cidr_blocks = ["0.0.0.0/0"] 28 | } 29 | 30 | resource "aws_security_group_rule" "alb_egress" { 31 | security_group_id = aws_security_group.alb.id 32 | description = "Allows Egress" 33 | type = "egress" 34 | protocol = "-1" 35 | from_port = 0 36 | to_port = 0 37 | cidr_blocks = ["0.0.0.0/0"] 38 | } 39 | 40 | resource "aws_alb" "ecs" { 41 | name = "ecs-${var.environment_name}-alb" 42 | internal = false 43 | load_balancer_type = "application" 44 | security_groups = [aws_security_group.alb.id] 45 | subnets = random_shuffle.public_subnets.result 46 | enable_deletion_protection = false 47 | tags = { 48 | Name = "ecs-${var.environment_name}-alb" 49 | Environment = var.environment_name 50 | } 51 | } 52 | 53 | resource "aws_alb_target_group" "service_tg" { 54 | name = "${var.environment_name}-${var.service_name_short}-ecs-tg" 55 | port = var.container_port 56 | protocol = "HTTP" 57 | vpc_id = data.aws_vpc.main.id 58 | target_type = "instance" 59 | deregistration_delay = 10 60 | 61 | health_check { 62 | interval = 15 63 | timeout = 5 64 | healthy_threshold = 2 65 | path = var.ecs_tg_healthcheck_endpoint 66 | matcher = "200" 67 | } 68 | 69 | lifecycle { 70 | create_before_destroy = true 71 | } 72 | 73 | depends_on = [ aws_alb.ecs ] 74 | } 75 | 76 | resource "aws_alb_listener" "http" { 77 | load_balancer_arn = aws_alb.ecs.arn 78 | port = "80" 79 | protocol = "HTTP" 80 | 81 | default_action { 82 | type = "redirect" 83 | 84 | redirect { 85 | port = "443" 86 | protocol = "HTTPS" 87 | status_code = "HTTP_301" 88 | } 89 | } 90 | } 91 | 92 | resource "aws_alb_listener" "https" { 93 | load_balancer_arn = aws_alb.ecs.arn 94 | port = 443 95 | protocol = "HTTPS" 96 | certificate_arn = aws_acm_certificate_validation.validate.certificate_arn 97 | 98 | default_action { 99 | type = "fixed-response" 100 | 101 | fixed_response { 102 | content_type = "text/plain" 103 | message_body = "Page cannot be found" 104 | status_code = "404" 105 | } 106 | } 107 | } 108 | 109 | resource "aws_lb_listener_rule" "forward_to_tg" { 110 | listener_arn = aws_alb_listener.https.arn 111 | 112 | action { 113 | type = "forward" 114 | target_group_arn = aws_alb_target_group.service_tg.arn 115 | } 116 | 117 | condition { 118 | source_ip { 119 | values = [ 120 | "0.0.0.0/0" 121 | ] 122 | } 123 | } 124 | 125 | condition { 126 | host_header { 127 | values = ["${var.route53_record_set}.${var.route53_hosted_zone}"] 128 | } 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/codebuild.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "codebuild_cache" { 2 | bucket = "codebuild-${var.environment_name}-cache-${data.aws_caller_identity.current.account_id}" 3 | force_destroy = true 4 | } 5 | 6 | data "template_file" "codebuild_policy" { 7 | template = file("templates/codebuild_iam_policy.json") 8 | 9 | vars = { 10 | codepipeline_bucket_arn = aws_s3_bucket.codepipeline_artifact_store.arn 11 | codebuild_cache_bucket_arn = aws_s3_bucket.codebuild_cache.arn 12 | aws_region = var.aws_region 13 | aws_account_id = data.aws_caller_identity.current.account_id 14 | service_name = var.service_name 15 | environment_name = var.environment_name 16 | subnet_id1 = element(tolist(data.aws_subnet_ids.public.ids), 0) 17 | subnet_id2 = element(tolist(data.aws_subnet_ids.public.ids), 1) 18 | subnet_id3 = element(tolist(data.aws_subnet_ids.public.ids), 2) 19 | } 20 | } 21 | 22 | resource "aws_iam_role" "codebuild_role" { 23 | name = "${var.environment_name}-${var.service_name}-codebuild-role" 24 | path = "/service-role/" 25 | assume_role_policy = file("templates/codebuild_iam_role_policy.json") 26 | } 27 | 28 | resource "aws_iam_role_policy" "codebuild_policy" { 29 | name = "${var.environment_name}-${var.service_name}-codebuild-policy" 30 | role = aws_iam_role.codebuild_role.id 31 | policy = data.template_file.codebuild_policy.rendered 32 | } 33 | 34 | resource "aws_ecr_repository" "repo" { 35 | name = "${var.environment_name}-${var.service_name}" 36 | } 37 | 38 | data "template_file" "buildspec" { 39 | template = file("templates/buildspec.yml") 40 | 41 | vars = { 42 | container_name = "${var.environment_name}-${var.service_name}" 43 | repository_url = aws_ecr_repository.repo.repository_url 44 | region = var.aws_region 45 | aws_account_id = data.aws_caller_identity.current.account_id 46 | } 47 | } 48 | 49 | resource "aws_codebuild_project" "build" { 50 | name = "${var.service_name}-${var.environment_name}-build" 51 | build_timeout = "60" 52 | service_role = aws_iam_role.codebuild_role.arn 53 | 54 | artifacts { 55 | type = "CODEPIPELINE" 56 | } 57 | 58 | cache { 59 | type = "S3" 60 | location = "${aws_s3_bucket.codebuild_cache.bucket}/${var.service_name}/${var.environment_name}/cache" 61 | } 62 | 63 | environment { 64 | compute_type = "BUILD_GENERAL1_SMALL" 65 | image = var.codebuild_docker_image 66 | type = "LINUX_CONTAINER" 67 | privileged_mode = true 68 | 69 | environment_variable { 70 | name = "SERVICE_NAME" 71 | value = var.service_name 72 | } 73 | 74 | environment_variable { 75 | name = "ENVIRONMENT_NAME" 76 | value = var.environment_name 77 | } 78 | } 79 | 80 | logs_config { 81 | cloudwatch_logs { 82 | group_name = "/aws/codebuild/${var.environment_name}" 83 | stream_name = var.service_name 84 | status = "ENABLED" 85 | } 86 | 87 | s3_logs { 88 | status = "DISABLED" 89 | } 90 | } 91 | 92 | source { 93 | type = "CODEPIPELINE" 94 | buildspec = data.template_file.buildspec.rendered 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/codepipeline.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "codepipeline_artifact_store" { 2 | bucket = "codepipeline-${var.environment_name}-artifacts-${data.aws_caller_identity.current.account_id}" 3 | force_destroy = true 4 | } 5 | 6 | resource "aws_iam_role" "codepipeline_role" { 7 | name = "${var.service_name}-${var.environment_name}-codepipeline-role" 8 | path = "/service-role/" 9 | assume_role_policy = file("templates/codepipeline_iam_role_policy.json") 10 | } 11 | 12 | data "template_file" "codepipeline_policy" { 13 | template = file("templates/codepipeline_iam_policy.json") 14 | 15 | vars = { 16 | codepipeline_bucket_arn = aws_s3_bucket.codepipeline_artifact_store.arn 17 | } 18 | } 19 | 20 | resource "aws_iam_role_policy" "codepipeline_policy" { 21 | name = "${var.service_name}-${var.environment_name}-codepipeline_policy" 22 | role = aws_iam_role.codepipeline_role.id 23 | policy = data.template_file.codepipeline_policy.rendered 24 | } 25 | 26 | resource "aws_codepipeline" "pipeline" { 27 | name = "${var.service_name}-${var.environment_name}-pipeline" 28 | role_arn = aws_iam_role.codepipeline_role.arn 29 | 30 | artifact_store { 31 | location = aws_s3_bucket.codepipeline_artifact_store.bucket 32 | type = "S3" 33 | } 34 | 35 | stage { 36 | name = var.codepipeline_source_stage_name 37 | 38 | action { 39 | name = "Source" 40 | category = "Source" 41 | owner = "AWS" 42 | provider = "CodeStarSourceConnection" 43 | version = "1" 44 | output_artifacts = ["source"] 45 | 46 | configuration = { 47 | ConnectionArn = "arn:aws:codestar-connections:${var.aws_region}:${data.aws_caller_identity.current.account_id}:connection/${var.codestar_connection_id}" 48 | FullRepositoryId = "${var.github_username}/${var.github_repo_name}" 49 | BranchName = var.github_branch 50 | } 51 | } 52 | } 53 | 54 | stage { 55 | name = var.codepipeline_build_stage_name 56 | 57 | action { 58 | name = "Build" 59 | category = "Build" 60 | owner = "AWS" 61 | provider = "CodeBuild" 62 | version = "1" 63 | input_artifacts = ["source"] 64 | output_artifacts = ["imagedefinitions"] 65 | 66 | configuration = { 67 | ProjectName = "${var.service_name}-${var.environment_name}-build" 68 | } 69 | } 70 | } 71 | 72 | stage { 73 | name = var.codepipeline_deploy_stage_name 74 | 75 | action { 76 | name = "Deploy" 77 | category = "Deploy" 78 | owner = "AWS" 79 | provider = "ECS" 80 | input_artifacts = ["imagedefinitions"] 81 | version = "1" 82 | 83 | configuration = { 84 | ClusterName = "${var.environment_name}-${var.ecs_cluster_name}" 85 | ServiceName = "${var.environment_name}-${var.service_name}" 86 | FileName = "imagedefinitions.json" 87 | } 88 | } 89 | } 90 | } 91 | 92 | resource "aws_codepipeline_webhook" "webhook" { 93 | name = "${var.service_name}-${var.environment_name}-webhook-github" 94 | authentication = "GITHUB_HMAC" 95 | target_action = "Source" 96 | target_pipeline = aws_codepipeline.pipeline.name 97 | 98 | authentication_configuration { 99 | secret_token = local.webhook_secret 100 | } 101 | 102 | filter { 103 | json_path = "$.ref" 104 | match_equals = "refs/heads/{Branch}" 105 | } 106 | } 107 | 108 | resource "github_repository_webhook" "webhook" { 109 | repository = var.github_repo_name 110 | 111 | configuration { 112 | url = aws_codepipeline_webhook.webhook.url 113 | content_type = "json" 114 | insecure_ssl = true 115 | secret = local.webhook_secret 116 | } 117 | 118 | events = ["push"] 119 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/ecs-instance.tf: -------------------------------------------------------------------------------- 1 | data "aws_ami" "latest_ecs" { 2 | most_recent = true 3 | owners = ["amazon"] 4 | 5 | filter { 6 | name = "name" 7 | values = ["amzn-ami-*-amazon-ecs-optimized"] 8 | } 9 | 10 | filter { 11 | name = "virtualization-type" 12 | values = ["hvm"] 13 | } 14 | 15 | filter { 16 | name = "root-device-type" 17 | values = ["ebs"] 18 | } 19 | } 20 | 21 | resource "aws_security_group" "ecs_instance" { 22 | name = "${var.environment_name}-${var.ecs_cluster_name}-ec2-sg" 23 | description = "${var.environment_name}-${var.ecs_cluster_name}-ec2-sg" 24 | vpc_id = data.aws_vpc.main.id 25 | tags = { 26 | Name = "${var.environment_name}-${var.ecs_cluster_name}-ec2-sg" 27 | } 28 | } 29 | 30 | resource "aws_security_group_rule" "ssh" { 31 | security_group_id = aws_security_group.ecs_instance.id 32 | description = "TCP/22 for VPC Range" 33 | type = "ingress" 34 | protocol = "tcp" 35 | from_port = 22 36 | to_port = 22 37 | cidr_blocks = ["10.0.0.0/16"] 38 | } 39 | 40 | resource "aws_security_group_rule" "container_port" { 41 | security_group_id = aws_security_group.ecs_instance.id 42 | description = "Allows ALB to Connect to Dynamic ECS Port" 43 | type = "ingress" 44 | protocol = "tcp" 45 | from_port = 0 46 | to_port = 65535 47 | source_security_group_id = aws_security_group.alb.id 48 | } 49 | 50 | resource "aws_security_group_rule" "ec2_egress" { 51 | security_group_id = aws_security_group.ecs_instance.id 52 | type = "egress" 53 | protocol = "-1" 54 | from_port = 0 55 | to_port = 0 56 | cidr_blocks = ["0.0.0.0/0"] 57 | } 58 | 59 | resource "aws_iam_instance_profile" "ecs_instance" { 60 | name = "${var.platform_type}-${var.environment_name}-instance-profile" 61 | role = aws_iam_role.ecs_container_instance_role.name 62 | } 63 | 64 | data "template_file" "userdata" { 65 | template = file("templates/bootstrap.sh.tpl") 66 | vars = { 67 | ecs_cluster_name = aws_ecs_cluster.prod.name 68 | environment_name = var.environment_name 69 | } 70 | } 71 | 72 | data "template_cloudinit_config" "userdata" { 73 | gzip = true 74 | base64_encode = true 75 | 76 | part { 77 | content_type = "text/x-shellscript" 78 | content = data.template_file.userdata.rendered 79 | } 80 | } 81 | 82 | resource "aws_instance" "ec2" { 83 | ami = data.aws_ami.latest_ecs.id 84 | instance_type = var.ecs_container_instance_type 85 | subnet_id = element(random_shuffle.private_subnets.result, 1) 86 | key_name = var.ssh_keypair_name 87 | vpc_security_group_ids = [aws_security_group.ecs_instance.id] 88 | associate_public_ip_address = false 89 | iam_instance_profile = aws_iam_instance_profile.ecs_container_instance_profile.name 90 | 91 | lifecycle { 92 | ignore_changes = [subnet_id,ami] 93 | } 94 | 95 | user_data_base64 = data.template_cloudinit_config.userdata.rendered 96 | 97 | tags = { 98 | Name = "${var.platform_type}-${var.environment_name}-ec2-instance" 99 | InstanceLifecycle = "ondemand" 100 | ECSClusterName = var.ecs_cluster_name 101 | Environment = var.environment_name 102 | ManagedBy = "terraform" 103 | } 104 | 105 | depends_on = [ aws_ecs_cluster.prod ] 106 | 107 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/ecs-service.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "ecs" { 2 | name = "${var.environment_name}-${var.ecs_cluster_name}-logs" 3 | } 4 | 5 | data "template_file" "service_task" { 6 | template = file("templates/taskdefinition.json") 7 | 8 | vars = { 9 | aws_region = var.aws_region 10 | aws_account_id = data.aws_caller_identity.current.account_id 11 | environment_name = var.environment_name 12 | service_name = var.service_name 13 | image = aws_ecr_repository.repo.repository_url 14 | container_name = "${var.environment_name}-${var.service_name}" 15 | container_port = var.container_port 16 | host_port = var.host_port 17 | reserved_task_memory = var.container_reserved_task_memory 18 | log_group = aws_cloudwatch_log_group.ecs.name 19 | } 20 | } 21 | 22 | resource "aws_ecs_task_definition" "service" { 23 | family = "${var.environment_name}-${var.service_name}" 24 | container_definitions = data.template_file.service_task.rendered 25 | requires_compatibilities = ["EC2"] 26 | network_mode = "bridge" 27 | execution_role_arn = aws_iam_role.ecs_execution_role.arn 28 | task_role_arn = aws_iam_role.ecs_task_role.arn 29 | 30 | placement_constraints { 31 | type = "memberOf" 32 | expression = "attribute:environment == ${var.environment_name}" 33 | } 34 | } 35 | 36 | data "aws_ecs_task_definition" "service_current" { 37 | task_definition = aws_ecs_task_definition.service.family 38 | } 39 | 40 | resource "aws_ecs_service" "service" { 41 | name = "${var.environment_name}-${var.service_name}" 42 | task_definition = "${aws_ecs_task_definition.service.family}:${max(aws_ecs_task_definition.service.revision, data.aws_ecs_task_definition.service_current.revision)}" 43 | 44 | cluster = aws_ecs_cluster.prod.id 45 | launch_type = "EC2" 46 | desired_count = var.container_desired_count 47 | 48 | ordered_placement_strategy { 49 | type = "spread" 50 | field = "attribute:ecs.availability-zone" 51 | } 52 | 53 | load_balancer { 54 | target_group_arn = aws_alb_target_group.service_tg.arn 55 | container_name = "${var.environment_name}-${var.service_name}" 56 | container_port = var.container_port 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/ecs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_cluster" "prod" { 2 | name = "${var.environment_name}-${var.ecs_cluster_name}" 3 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/iam.tf: -------------------------------------------------------------------------------- 1 | data "aws_iam_policy" "AmazonEC2ContainerServiceforEC2Role" { 2 | arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" 3 | } 4 | 5 | resource "aws_iam_role" "ecs_container_instance_role" { 6 | name = "${var.ecs_cluster_name}-${var.service_name}-${var.environment_name}-instance-role" 7 | path = "/service-role/" 8 | assume_role_policy = file("templates/ecs_container_instance_iam_role_policy.json") 9 | } 10 | 11 | resource "aws_iam_policy_attachment" "ecs_instance_policy_attach" { 12 | name = "ecs-instance-policy-attachment" 13 | roles = [aws_iam_role.ecs_container_instance_role.name] 14 | policy_arn = data.aws_iam_policy.AmazonEC2ContainerServiceforEC2Role.arn 15 | } 16 | 17 | resource "aws_iam_instance_profile" "ecs_container_instance_profile" { 18 | name = "${var.ecs_cluster_name}-${var.service_name}-${var.environment_name}-ecs-instance-profile" 19 | role = aws_iam_role.ecs_container_instance_role.name 20 | } 21 | 22 | data "aws_iam_policy" "AmazonECSTaskExecutionRolePolicy" { 23 | arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" 24 | } 25 | 26 | resource "aws_iam_role" "ecs_task_role" { 27 | name = "${var.ecs_cluster_name}-${var.service_name}-${var.environment_name}-task-role" 28 | path = "/service-role/" 29 | assume_role_policy = file("templates/ecs_tasks_iam_role_policy.json") 30 | } 31 | 32 | resource "aws_iam_role_policy_attachment" "ecs_task_policy_attach" { 33 | role = aws_iam_role.ecs_task_role.name 34 | policy_arn = data.aws_iam_policy.AmazonECSTaskExecutionRolePolicy.arn 35 | } 36 | 37 | resource "aws_iam_role" "ecs_execution_role" { 38 | name = "${var.ecs_cluster_name}-${var.service_name}-${var.environment_name}-execution-role" 39 | path = "/service-role/" 40 | assume_role_policy = file("templates/ecs_tasks_iam_role_policy.json") 41 | } 42 | 43 | resource "aws_iam_role_policy_attachment" "ecs_execution_policy_attach" { 44 | role = aws_iam_role.ecs_execution_role.name 45 | policy_arn = data.aws_iam_policy.AmazonECSTaskExecutionRolePolicy.arn 46 | } 47 | 48 | data "template_file" "ecs_execution_role_policy" { 49 | template = file("templates/ecs_execution_iam_policy.json") 50 | 51 | vars = { 52 | aws_region = var.aws_region 53 | aws_account_id = data.aws_caller_identity.current.account_id 54 | service_name = var.service_name 55 | environment_name = var.environment_name 56 | } 57 | } 58 | 59 | resource "aws_iam_role_policy" "ecs_execution_role_policy" { 60 | name = "${var.ecs_cluster_name}-${var.service_name}-${var.environment_name}-execution-policy" 61 | role = aws_iam_role.ecs_execution_role.id 62 | policy = data.template_file.ecs_execution_role_policy.rendered 63 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/locals.tf: -------------------------------------------------------------------------------- 1 | resource "random_string" "secret" { 2 | length = 16 3 | special = true 4 | override_special = "/@£$" 5 | } 6 | 7 | # https://github.com/settings/tokens/new 8 | # admin:repo_hook 9 | 10 | locals { 11 | webhook_secret = random_string.secret.result 12 | github_token = var.github_token 13 | github_username = var.github_username 14 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/outputs.tf: -------------------------------------------------------------------------------- 1 | output "environment_name" { 2 | value = var.environment_name 3 | } 4 | 5 | output "alb_dns" { 6 | value = aws_alb.ecs.dns_name 7 | } 8 | 9 | output "service_hostname" { 10 | value = "${var.route53_record_set}.${var.route53_hosted_zone}" 11 | } 12 | 13 | output "db_address" { 14 | value = aws_db_instance.prod.address 15 | } 16 | 17 | output "account_id" { 18 | value = data.aws_caller_identity.current.account_id 19 | } 20 | 21 | output "caller_arn" { 22 | value = data.aws_caller_identity.current.arn 23 | } 24 | 25 | output "caller_user" { 26 | value = data.aws_caller_identity.current.user_id 27 | } 28 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/providers.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | version = ">= 2.7.0" 5 | source = "hashicorp/aws" 6 | } 7 | random = { 8 | version = "~> 3.0" 9 | } 10 | null = { 11 | version = "~> 3.0" 12 | } 13 | template = { 14 | version = "~> 2.1" 15 | } 16 | github = { 17 | source = "integrations/github" 18 | version = ">= 4.5.0" 19 | } 20 | } 21 | } 22 | 23 | provider "aws" { 24 | region = "eu-west-1" 25 | profile = "demo" 26 | shared_credentials_file = "~/.aws/credentials" 27 | } 28 | 29 | provider "github" { 30 | token = local.github_token 31 | organization = local.github_username 32 | } 33 | 34 | provider "template" {} 35 | provider "random" {} 36 | provider "null" {} -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/rds.tf: -------------------------------------------------------------------------------- 1 | resource "random_password" "db_admin_password" { 2 | length = 16 3 | special = true 4 | override_special = "_" 5 | } 6 | 7 | resource "aws_security_group" "rds_instance" { 8 | name = "${var.environment_name}-${var.ecs_cluster_name}-rds-sg" 9 | description = "${var.environment_name}-${var.ecs_cluster_name}-rds-sg" 10 | vpc_id = data.aws_vpc.main.id 11 | tags = { 12 | Name = "${var.environment_name}-${var.ecs_cluster_name}-rds-sg" 13 | } 14 | } 15 | 16 | resource "aws_security_group_rule" "mysql" { 17 | security_group_id = aws_security_group.rds_instance.id 18 | description = "TCP/3306 for ECS Instances" 19 | type = "ingress" 20 | protocol = "tcp" 21 | from_port = 3306 22 | to_port = 3306 23 | source_security_group_id = aws_security_group.ecs_instance.id 24 | } 25 | 26 | resource "aws_db_instance" "prod" { 27 | identifier = "${var.platform_type}-${var.environment_name}-rds-instance" 28 | allocated_storage = 10 29 | engine = "mysql" 30 | engine_version = "8.0.20" 31 | instance_class = var.rds_instance_type 32 | name = var.service_name_short 33 | username = var.rds_admin_username 34 | password = random_password.db_admin_password.result 35 | db_subnet_group_name = var.rds_subnet_group_name 36 | parameter_group_name = "default.mysql8.0" 37 | skip_final_snapshot = true 38 | publicly_accessible = false 39 | vpc_security_group_ids = [aws_security_group.rds_instance.id] 40 | tags = { 41 | Name = "${var.platform_type}-${var.environment_name}-rds-instance" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/route53.tf: -------------------------------------------------------------------------------- 1 | data "aws_route53_zone" "selected" { 2 | name = var.route53_hosted_zone 3 | private_zone = false 4 | } 5 | 6 | resource "aws_route53_record" "www" { 7 | zone_id = data.aws_route53_zone.selected.zone_id 8 | name = "${var.route53_record_set}.${data.aws_route53_zone.selected.name}" 9 | type = "CNAME" 10 | ttl = "300" 11 | records = [aws_alb.ecs.dns_name] 12 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/ssm.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ssm_parameter" "database_host" { 2 | name = "/${var.service_name}/${var.environment_name}/MYSQL_HOST" 3 | type = "String" 4 | value = aws_db_instance.prod.address 5 | } 6 | 7 | resource "aws_ssm_parameter" "database_port" { 8 | name = "/${var.service_name}/${var.environment_name}/MYSQL_PORT" 9 | type = "String" 10 | value = aws_db_instance.prod.port 11 | } 12 | 13 | resource "aws_ssm_parameter" "database_password" { 14 | name = "/${var.service_name}/${var.environment_name}/MYSQL_PASS" 15 | type = "String" 16 | value = random_password.db_admin_password.result 17 | } 18 | 19 | resource "aws_ssm_parameter" "database_name" { 20 | name = "/${var.service_name}/${var.environment_name}/MYSQL_DATABASE" 21 | type = "String" 22 | value = var.service_name_short 23 | } 24 | 25 | resource "aws_ssm_parameter" "database_user" { 26 | name = "/${var.service_name}/${var.environment_name}/MYSQL_USER" 27 | type = "String" 28 | value = var.rds_admin_username 29 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/sts.tf: -------------------------------------------------------------------------------- 1 | data "aws_caller_identity" "current" {} 2 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/bootstrap.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # write ecs config 4 | echo "ECS_CLUSTER=${ecs_cluster_name}" >> /etc/ecs/ecs.config 5 | echo "ECS_AVAILABLE_LOGGING_DRIVERS=[\"json-file\",\"awslogs\"]" >> /etc/ecs/ecs.config 6 | echo "ECS_INSTANCE_ATTRIBUTES={\"environment\":\"${environment_name}\"}" >> /etc/ecs/ecs.config -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/buildspec.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/codebuild/latest/userguide/sample-runtime-versions.html 2 | # https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-available.html 3 | version: 0.2 4 | env: 5 | variables: 6 | M2_HOME: "/root/.m2" 7 | 8 | phases: 9 | install: 10 | runtime-versions: 11 | java: corretto11 12 | commands: 13 | - java -version 14 | pre_build: 15 | commands: 16 | - java -version 17 | - echo logging in to ECR 18 | - aws ecr get-login-password --region ${region} | docker login --username AWS --password-stdin ${aws_account_id}.dkr.ecr.${region}.amazonaws.com 19 | - REPOSITORY_URI=${repository_url} 20 | - IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7) 21 | build: 22 | commands: 23 | - echo build started on $(date) 24 | - mvn -e -X clean install 25 | - echo building the docker image 26 | - docker build -t $REPOSITORY_URI:latest . 27 | - docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG 28 | post_build: 29 | commands: 30 | - echo build completed on $(date) 31 | - echo pushing the docker images 32 | - docker push $REPOSITORY_URI:latest 33 | - docker push $REPOSITORY_URI:$IMAGE_TAG 34 | - echo writing image definitions file for deployment 35 | - printf '[{"name":"${container_name}","imageUri":"%s"}]' $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json 36 | 37 | artifacts: 38 | files: imagedefinitions.json 39 | 40 | cache: 41 | paths: 42 | - '/root/.m2/**/*' -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/codebuild_iam_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Resource": [ 7 | "*" 8 | ], 9 | "Action": [ 10 | "logs:CreateLogGroup", 11 | "logs:CreateLogStream", 12 | "logs:PutLogEvents", 13 | "ecr:GetAuthorizationToken", 14 | "ecr:InitiateLayerUpload", 15 | "ecr:UploadLayerPart", 16 | "ecr:CompleteLayerUpload", 17 | "ecr:BatchCheckLayerAvailability", 18 | "ecr:PutImage", 19 | "ecs:RunTask", 20 | "iam:PassRole" 21 | ] 22 | }, 23 | { 24 | "Effect":"Allow", 25 | "Action": [ 26 | "s3:GetObject", 27 | "s3:GetObjectVersion", 28 | "s3:GetBucketVersioning", 29 | "s3:List*", 30 | "s3:PutObject" 31 | ], 32 | "Resource": [ 33 | "${codepipeline_bucket_arn}", 34 | "${codepipeline_bucket_arn}/*", 35 | "${codebuild_cache_bucket_arn}", 36 | "${codebuild_cache_bucket_arn}/*" 37 | ] 38 | }, 39 | { 40 | "Effect": "Allow", 41 | "Action": [ 42 | "ec2:CreateNetworkInterface", 43 | "ec2:DescribeDhcpOptions", 44 | "ec2:DescribeNetworkInterfaces", 45 | "ec2:DeleteNetworkInterface", 46 | "ec2:DescribeSubnets", 47 | "ec2:DescribeSecurityGroups", 48 | "ec2:DescribeVpcs" 49 | ], 50 | "Resource": "*" 51 | }, 52 | { 53 | "Effect": "Allow", 54 | "Action": [ 55 | "ec2:CreateNetworkInterfacePermission" 56 | ], 57 | "Resource": [ 58 | "arn:aws:ec2:${aws_region}:${aws_account_id}:network-interface/*" 59 | ], 60 | "Condition": { 61 | "StringEquals": { 62 | "ec2:Subnet": [ 63 | "arn:aws:ec2:${aws_region}:${aws_account_id}:subnet/${subnet_id1}", 64 | "arn:aws:ec2:${aws_region}:${aws_account_id}:subnet/${subnet_id2}", 65 | "arn:aws:ec2:${aws_region}:${aws_account_id}:subnet/${subnet_id3}" 66 | ], 67 | "ec2:AuthorizedService": "codebuild.amazonaws.com" 68 | } 69 | } 70 | }, 71 | { 72 | "Effect": "Allow", 73 | "Action":[ 74 | "ssm:GetParametersByPath", 75 | "ssm:GetParameters", 76 | "ssm:GetParameter" 77 | ], 78 | "Resource":[ 79 | "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/${service_name}/${environment_name}/*", 80 | "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/codebuild/*" 81 | ] 82 | }, 83 | { 84 | "Effect": "Allow", 85 | "Action":[ 86 | "kms:Decrypt", 87 | "kms:GenerateDataKey" 88 | ], 89 | "Resource":[ 90 | "*" 91 | ] 92 | } 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/codebuild_iam_role_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Principal": { 7 | "Service": "codebuild.amazonaws.com" 8 | }, 9 | "Action": "sts:AssumeRole" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/codepipeline_iam_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect":"Allow", 6 | "Action": [ 7 | "s3:GetObject", 8 | "s3:GetObjectVersion", 9 | "s3:GetBucketVersioning", 10 | "s3:List*", 11 | "s3:PutObject", 12 | "s3:PutObjectAcl" 13 | ], 14 | "Resource": [ 15 | "${codepipeline_bucket_arn}", 16 | "${codepipeline_bucket_arn}/*" 17 | ] 18 | }, 19 | { 20 | "Effect": "Allow", 21 | "Action": [ 22 | "codebuild:BatchGetBuilds", 23 | "codebuild:StartBuild" 24 | ], 25 | "Resource": "*" 26 | }, 27 | { 28 | "Action": [ 29 | "ecs:*", 30 | "events:DescribeRule", 31 | "events:DeleteRule", 32 | "events:ListRuleNamesByTarget", 33 | "events:ListTargetsByRule", 34 | "events:PutRule", 35 | "events:PutTargets", 36 | "events:RemoveTargets", 37 | "iam:ListAttachedRolePolicies", 38 | "iam:ListInstanceProfiles", 39 | "iam:ListRoles", 40 | "logs:CreateLogGroup", 41 | "logs:DescribeLogGroups", 42 | "logs:FilterLogEvents" 43 | ], 44 | "Resource": "*", 45 | "Effect": "Allow" 46 | }, 47 | { 48 | "Action": "iam:PassRole", 49 | "Effect": "Allow", 50 | "Resource": [ 51 | "*" 52 | ], 53 | "Condition": { 54 | "StringLike": { 55 | "iam:PassedToService": "ecs-tasks.amazonaws.com" 56 | } 57 | } 58 | }, 59 | { 60 | "Action": "iam:PassRole", 61 | "Effect": "Allow", 62 | "Resource": [ 63 | "arn:aws:iam::*:role/ecsInstanceRole*" 64 | ], 65 | "Condition": { 66 | "StringLike": { 67 | "iam:PassedToService": [ 68 | "ec2.amazonaws.com", 69 | "ec2.amazonaws.com.cn" 70 | ] 71 | } 72 | } 73 | }, 74 | { 75 | "Effect": "Allow", 76 | "Action": "iam:CreateServiceLinkedRole", 77 | "Resource": "*", 78 | "Condition": { 79 | "StringLike": { 80 | "iam:AWSServiceName": [ 81 | "ecs.amazonaws.com", 82 | "spot.amazonaws.com", 83 | "spotfleet.amazonaws.com" 84 | ] 85 | } 86 | } 87 | }, 88 | { 89 | "Effect": "Allow", 90 | "Action": [ 91 | "codestar-connections:UseConnection" 92 | ], 93 | "Resource": [ 94 | "*" 95 | ] 96 | } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/codepipeline_iam_role_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Principal": { 7 | "Service": "codepipeline.amazonaws.com" 8 | }, 9 | "Action": "sts:AssumeRole" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/ecs_container_instance_iam_role_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2008-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "", 6 | "Effect": "Allow", 7 | "Principal": { 8 | "Service": "ec2.amazonaws.com" 9 | }, 10 | "Action": "sts:AssumeRole" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/ecs_execution_iam_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action":[ 7 | "ssm:GetParametersByPath", 8 | "ssm:GetParameters", 9 | "ssm:GetParameter" 10 | ], 11 | "Resource":[ 12 | "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/${service_name}/${environment_name}/*" 13 | ] 14 | }, 15 | { 16 | "Effect": "Allow", 17 | "Action": [ 18 | "logs:CreateLogGroup", 19 | "logs:CreateLogStream", 20 | "logs:PutLogEvents" 21 | ], 22 | "Resource": "*" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/ecs_tasks_iam_role_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "", 6 | "Effect": "Allow", 7 | "Principal": { 8 | "Service": "ecs-tasks.amazonaws.com" 9 | }, 10 | "Action": "sts:AssumeRole" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/templates/taskdefinition.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "${container_name}", 4 | "image": "${image}", 5 | "memoryReservation": ${reserved_task_memory}, 6 | "portMappings": [ 7 | { 8 | "containerPort": ${container_port}, 9 | "hostPort": ${host_port} 10 | } 11 | ], 12 | "environment": [ 13 | { 14 | "name": "AWS_DEFAULT_REGION", 15 | "value": "eu-west-1" 16 | } 17 | ], 18 | "secrets": [ 19 | { 20 | "name": "MYSQL_HOST", 21 | "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/${service_name}/${environment_name}/MYSQL_HOST" 22 | }, 23 | { 24 | "name": "MYSQL_PORT", 25 | "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/${service_name}/${environment_name}/MYSQL_PORT" 26 | }, 27 | { 28 | "name": "MYSQL_PASS", 29 | "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/${service_name}/${environment_name}/MYSQL_PASS" 30 | }, 31 | { 32 | "name": "MYSQL_DATABASE", 33 | "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/${service_name}/${environment_name}/MYSQL_DATABASE" 34 | }, 35 | { 36 | "name": "MYSQL_USER", 37 | "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/${service_name}/${environment_name}/MYSQL_USER" 38 | } 39 | ], 40 | "networkMode": "bridge", 41 | "essential": true, 42 | "logConfiguration": { 43 | "logDriver": "awslogs", 44 | "options": { 45 | "awslogs-group": "${log_group}", 46 | "awslogs-region": "${aws_region}", 47 | "awslogs-stream-prefix": "${service_name}" 48 | } 49 | } 50 | } 51 | ] 52 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/terraform-state.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | encrypt = true 4 | bucket = "your-terraform-remote-state" 5 | key = "aws-terraform-cicd-java-springboot/prod/ecs/terraform.tfstate" 6 | region = "eu-west-1" 7 | profile = "demo" 8 | shared_credentials_file = "~/.aws/credentials" 9 | dynamodb_table = "terraform-remote-state" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/variables.tf: -------------------------------------------------------------------------------- 1 | variable "aws_region" { 2 | type = string 3 | default = "eu-west-1" 4 | } 5 | 6 | variable "environment_name" { 7 | type = string 8 | default = "prod" 9 | } 10 | 11 | variable "service_name" { 12 | type = string 13 | default = "cargarage-api" 14 | } 15 | 16 | variable "service_name_short" { 17 | type = string 18 | default = "cargarage" 19 | } 20 | 21 | variable "ecs_container_instance_type" { 22 | type = string 23 | default = "t2.micro" 24 | } 25 | 26 | variable "codebuild_security_group_name" { 27 | type = string 28 | default = "vpc-codebuild" 29 | } 30 | 31 | variable "container_port" { 32 | type = string 33 | default = "8080" 34 | } 35 | 36 | variable "host_port" { 37 | type = string 38 | default = "0" 39 | } 40 | 41 | variable "container_desired_count" { 42 | type = string 43 | default = "1" 44 | } 45 | 46 | variable "container_reserved_task_memory" { 47 | type = string 48 | default = "128" 49 | } 50 | 51 | variable "ecs_cluster_name" { 52 | type = string 53 | default = "ecs-cluster" 54 | } 55 | 56 | variable "ecs_tg_healthcheck_endpoint" { 57 | type = string 58 | default = "/status" 59 | } 60 | 61 | variable "github_username" { 62 | type = string 63 | default = "ruanbekker" 64 | } 65 | 66 | variable "github_repo_name" { 67 | type = string 68 | default = "aws-terraform-cicd-java-springboot" 69 | } 70 | 71 | variable "github_branch" { 72 | type = string 73 | default = "main" 74 | } 75 | 76 | variable "github_token" { 77 | type = string 78 | } 79 | 80 | variable "route53_hosted_zone" { 81 | type = string 82 | default = "example.com" 83 | } 84 | 85 | variable "route53_record_set" { 86 | type = string 87 | default = "www" 88 | } 89 | 90 | variable "service_hostname" { 91 | type = string 92 | default = "www.example.com" 93 | } 94 | 95 | variable "codestar_connection_id" { 96 | type = string 97 | default = "" 98 | } 99 | 100 | variable "codebuild_docker_image" { 101 | type = string 102 | default = "aws/codebuild/standard:5.0" 103 | } 104 | 105 | variable "codepipeline_source_stage_name" { 106 | type = string 107 | default = "Source" 108 | } 109 | 110 | variable "codepipeline_build_stage_name" { 111 | type = string 112 | default = "Build" 113 | } 114 | 115 | variable "codepipeline_deploy_stage_name" { 116 | type = string 117 | default = "Prod" 118 | } 119 | 120 | variable "rds_admin_username" { 121 | type = string 122 | default = "admin" 123 | } 124 | 125 | variable "rds_subnet_group_name" { 126 | type = string 127 | default = "private" 128 | } 129 | 130 | variable "rds_instance_type" { 131 | type = string 132 | default = "db.t2.micro" 133 | } 134 | 135 | variable "platform_type" { 136 | type = string 137 | default = "ecs" 138 | } 139 | 140 | variable "ssh_keypair_name" { 141 | type = string 142 | default = "" 143 | } 144 | 145 | variable "vpc_name" { 146 | type = string 147 | default = "main" 148 | } -------------------------------------------------------------------------------- /infra/aws/eu-west-1/production/vpc.tf: -------------------------------------------------------------------------------- 1 | data "aws_vpc" "main" { 2 | default = false 3 | tags = { 4 | Name = var.vpc_name 5 | } 6 | } 7 | 8 | data "aws_subnet_ids" "private" { 9 | vpc_id = data.aws_vpc.main.id 10 | tags = { 11 | Tier = "private" 12 | } 13 | } 14 | 15 | data "aws_subnet_ids" "public" { 16 | vpc_id = data.aws_vpc.main.id 17 | tags = { 18 | Tier = "public" 19 | } 20 | } 21 | 22 | resource "random_shuffle" "private_subnets" { 23 | input = tolist(data.aws_subnet_ids.private.ids) 24 | result_count = 3 25 | } 26 | 27 | resource "random_shuffle" "public_subnets" { 28 | input = tolist(data.aws_subnet_ids.public.ids) 29 | result_count = 3 30 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | com.ruanbekker 5 | cargarage 6 | 0.0.1-SNAPSHOT 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 2.3.3.RELEASE 11 | 12 | cargarage 13 | Car Garage API 14 | 15 | 11 16 | UTF-8 17 | 18 | 19 | 20 | org.springframework.boot 21 | spring-boot-starter-web 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-data-jpa 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-actuator 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-validation 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-devtools 38 | runtime 39 | 40 | 41 | mysql 42 | mysql-connector-java 43 | runtime 44 | 45 | 46 | org.springframework.boot 47 | spring-boot-starter-test 48 | test 49 | 50 | 51 | com.h2database 52 | h2 53 | 54 | 55 | 56 | io.micrometer 57 | micrometer-registry-prometheus 58 | 1.9.4 59 | 60 | 61 | 83 | 84 | 85 | 86 | 87 | org.springframework.boot 88 | spring-boot-maven-plugin 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /src/main/java/com/ruanbekker/cargarage/CarGarageApplication.java: -------------------------------------------------------------------------------- 1 | package com.ruanbekker.cargarage; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing; 6 | 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | 10 | @SpringBootApplication 11 | @EnableJpaAuditing 12 | public class CarGarageApplication { 13 | 14 | private static Logger log = LoggerFactory.getLogger(CarGarageApplication.class); 15 | 16 | public static void main(String[] args) { 17 | SpringApplication.run(CarGarageApplication.class, args); 18 | log.info("application is starting with info level logging"); 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/java/com/ruanbekker/cargarage/controller/CarController.java: -------------------------------------------------------------------------------- 1 | package com.ruanbekker.cargarage.controller; 2 | 3 | import com.ruanbekker.cargarage.exception.ResourceNotFoundException; 4 | import com.ruanbekker.cargarage.model.Car; 5 | import com.ruanbekker.cargarage.repository.CarRepository; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.web.bind.annotation.*; 9 | 10 | import javax.validation.Valid; 11 | import java.util.List; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | @RestController 16 | @RequestMapping("/api") 17 | public class CarController { 18 | 19 | private static final Logger log = LoggerFactory.getLogger(CarController.class); 20 | 21 | @Autowired 22 | CarRepository carRepository; 23 | 24 | @GetMapping("/cars") 25 | public List getAllCars() { 26 | log.info("listing all cars"); 27 | return carRepository.findAll(); 28 | } 29 | 30 | @PostMapping("/cars") 31 | public Car createCar(@Valid @RequestBody Car car) { 32 | log.info("creating entry for a new car"); 33 | return carRepository.save(car); 34 | } 35 | 36 | @GetMapping("/cars/{id}") 37 | public Car getCarById(@PathVariable(value = "id") Long carId) { 38 | log.info("attempting to fetch car id {}", carId); 39 | return carRepository.findById(carId) 40 | .orElseThrow(() -> new ResourceNotFoundException("Car", "id", carId)); 41 | } 42 | 43 | @PutMapping("/cars/{id}") 44 | public Car updateCar(@PathVariable(value = "id") Long carId, @Valid @RequestBody Car carDetails) { 45 | 46 | Car car = carRepository.findById(carId).orElseThrow(() -> new ResourceNotFoundException("Car", "id", carId)); 47 | car.setMake(carDetails.getMake()); 48 | car.setModel(carDetails.getModel()); 49 | log.info("updating car id {}", carId); 50 | Car updatedCar = carRepository.save(car); 51 | 52 | return updatedCar; 53 | } 54 | 55 | @DeleteMapping("/cars/{id}") 56 | public ResponseEntity deleteCar(@PathVariable(value = "id") Long carId) { 57 | Car car = carRepository.findById(carId).orElseThrow(() -> new ResourceNotFoundException("Car", "id", carId)); 58 | carRepository.delete(car); 59 | log.info("deleting car id {}", carId); 60 | 61 | return ResponseEntity.ok().build(); 62 | } 63 | } -------------------------------------------------------------------------------- /src/main/java/com/ruanbekker/cargarage/controller/IndexController.java: -------------------------------------------------------------------------------- 1 | package com.ruanbekker.cargarage.controller; 2 | 3 | import org.springframework.web.bind.annotation.GetMapping; 4 | import org.springframework.web.bind.annotation.RequestMapping; 5 | import org.springframework.web.bind.annotation.RestController; 6 | 7 | @RestController 8 | @RequestMapping("/") 9 | public class IndexController { 10 | 11 | @GetMapping 12 | public String sayHello() { 13 | return "Hello and Welcome to the CarGarage application. You can create a new car entry by making a POST request to /api/cars endpoint."; 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/java/com/ruanbekker/cargarage/exception/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ruanbekker.cargarage.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.NOT_FOUND) 7 | public class ResourceNotFoundException extends RuntimeException { 8 | private String resourceName; 9 | private String fieldName; 10 | private Object fieldValue; 11 | 12 | public ResourceNotFoundException( String resourceName, String fieldName, Object fieldValue) { 13 | super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue)); 14 | this.resourceName = resourceName; 15 | this.fieldName = fieldName; 16 | this.fieldValue = fieldValue; 17 | } 18 | 19 | public String getResourceName() { 20 | return resourceName; 21 | } 22 | 23 | public String getFieldName() { 24 | return fieldName; 25 | } 26 | 27 | public Object getFieldValue() { 28 | return fieldValue; 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/java/com/ruanbekker/cargarage/model/Car.java: -------------------------------------------------------------------------------- 1 | package com.ruanbekker.cargarage.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import org.springframework.data.annotation.CreatedDate; 5 | import org.springframework.data.annotation.LastModifiedDate; 6 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 7 | 8 | import javax.persistence.*; 9 | import javax.validation.constraints.NotBlank; 10 | import java.util.Date; 11 | 12 | @Entity 13 | @Table(name = "cars") 14 | @EntityListeners(AuditingEntityListener.class) 15 | @JsonIgnoreProperties(value = {"createdAt", "updatedAt"}, allowGetters = true) 16 | public class Car { 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.IDENTITY) 19 | private Long id; 20 | 21 | @NotBlank 22 | private String make; 23 | 24 | @NotBlank 25 | private String model; 26 | 27 | @Column(nullable = false, updatable = false) 28 | @Temporal(TemporalType.TIMESTAMP) 29 | @CreatedDate 30 | private Date createdAt; 31 | 32 | @Column(nullable = false) 33 | @Temporal(TemporalType.TIMESTAMP) 34 | @LastModifiedDate 35 | private Date updatedAt; 36 | 37 | public Long getId() { 38 | return id; 39 | } 40 | 41 | public void setId(Long id) { 42 | this.id = id; 43 | } 44 | 45 | public String getMake() { 46 | return make; 47 | } 48 | 49 | public void setMake(String make) { 50 | this.make = make; 51 | } 52 | 53 | public String getModel() { 54 | return model; 55 | } 56 | 57 | public void setModel(String model) { 58 | this.model = model; 59 | } 60 | 61 | public Date getCreatedAt() { 62 | return createdAt; 63 | } 64 | 65 | public void setCreatedAt(Date createdAt) { 66 | this.createdAt = createdAt; 67 | } 68 | 69 | public Date getUpdatedAt() { 70 | return updatedAt; 71 | } 72 | 73 | public void setUpdatedAt(Date updatedAt) { 74 | this.updatedAt = updatedAt; 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/main/java/com/ruanbekker/cargarage/repository/CarRepository.java: -------------------------------------------------------------------------------- 1 | package com.ruanbekker.cargarage.repository; 2 | 3 | import com.ruanbekker.cargarage.model.Car; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface CarRepository extends JpaRepository { 9 | 10 | } -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # web 2 | management.server.port=8080 3 | management.endpoints.web.exposure.include=health,info,metrics,prometheus 4 | management.endpoints.web.base-path=/ 5 | management.endpoints.web.path-mapping.health=status 6 | management.endpoint.health.show-details=always 7 | 8 | # jpa 9 | # (create, create-drop, validate, update) 10 | spring.jpa.hibernate.ddl-auto=update 11 | spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect 12 | spring.jpa.database-platform = org.hibernate.dialect.MySQL8Dialect 13 | spring.jpa.generate-ddl=true 14 | # datasource 15 | spring.datasource.url=jdbc:mysql://${MYSQL_HOST:127.0.0.1}/${MYSQL_DATABASE:cargarage}?autoReconnect=true&failOverReadOnly=false&maxReconnects=10 16 | spring.datasource.username=${MYSQL_USER:app} 17 | spring.datasource.password=${MYSQL_PASS:app} 18 | spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver 19 | 20 | # logging 21 | logging.level.com.ruanbekker.cargarage=INFO 22 | logging.level.org.springframework.web=INFO -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n%throwable 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/test/java/com/ruanbekker/cargarage/CarGarageApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ruanbekker.cargarage; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.junit4.SpringRunner; 7 | 8 | @RunWith(SpringRunner.class) 9 | @SpringBootTest 10 | public class CarGarageApplicationTests { 11 | 12 | @Test 13 | public void contextLoads() { 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | # https://www.baeldung.com/spring-testing-separate-data-source 2 | spring.datasource.driver-class-name=org.h2.Driver 3 | spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1 4 | spring.datasource.username=admin 5 | spring.datasource.password=admin --------------------------------------------------------------------------------