├── images └── architecture.jpg ├── terraform ├── deploy │ ├── environment │ │ ├── us-east-1.tfvars │ │ ├── us-east-2.tfvars │ │ ├── us-west-1.tfvars │ │ ├── eu-west-1.tfvars │ │ ├── ca-central-1.tfvars │ │ └── ap-southeast-2.tfvars │ ├── modules │ │ └── alb │ │ │ ├── variables.tf │ │ │ ├── outputs.tf │ │ │ └── alb.tf │ ├── outputs.tf │ ├── variables.tf │ ├── 00_workspace_check.tf │ ├── backend.tf │ ├── providers.tf │ ├── 04_authentication.tf │ ├── 02_k8_lbc.tf │ ├── 03_k8s_storage.tf │ ├── 05_application.tf │ ├── 06_secrets_manager.tf │ ├── 01_infrastructure.tf │ └── .terraform.lock.hcl ├── init │ ├── providers.tf │ ├── main.tf │ ├── .terraform.lock.hcl │ └── policies │ │ └── iam_policy.json └── hello │ ├── main.tf │ └── .terraform.lock.hcl ├── LICENSE ├── .gitignore ├── docs ├── separate_configs.md ├── known_issues.md └── cleanup.md ├── CLAUDE.md ├── scripts ├── cleanup_cluster.sh └── ez_cluster_deploy.sh └── README.md /images/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/setheliot/eks_demo/HEAD/images/architecture.jpg -------------------------------------------------------------------------------- /terraform/deploy/environment/us-east-1.tfvars: -------------------------------------------------------------------------------- 1 | env_name = "use1" 2 | instance_type = "t3.medium" 3 | aws_region = "us-east-1" -------------------------------------------------------------------------------- /terraform/deploy/environment/us-east-2.tfvars: -------------------------------------------------------------------------------- 1 | env_name = "use2" 2 | instance_type = "t3.medium" 3 | aws_region = "us-east-2" -------------------------------------------------------------------------------- /terraform/deploy/environment/us-west-1.tfvars: -------------------------------------------------------------------------------- 1 | env_name = "usw1" 2 | instance_type = "t3.medium" 3 | aws_region = "us-west-1" -------------------------------------------------------------------------------- /terraform/deploy/environment/eu-west-1.tfvars: -------------------------------------------------------------------------------- 1 | env_name = "euw1" 2 | instance_type = "t3.medium" 3 | aws_region = "eu-west-1" 4 | -------------------------------------------------------------------------------- /terraform/deploy/environment/ca-central-1.tfvars: -------------------------------------------------------------------------------- 1 | env_name = "cac1" 2 | instance_type = "t3.medium" 3 | aws_region = "ca-central-1" 4 | -------------------------------------------------------------------------------- /terraform/deploy/environment/ap-southeast-2.tfvars: -------------------------------------------------------------------------------- 1 | env_name = "apse4" 2 | instance_type = "t3.medium" 3 | aws_region = "ap-southeast-2" 4 | -------------------------------------------------------------------------------- /terraform/init/providers.tf: -------------------------------------------------------------------------------- 1 | 2 | # AWS Provider 3 | # Region does not matter since this module only creates IAM resources which are global. 4 | 5 | provider "aws" { 6 | region = "us-east-1" 7 | } 8 | -------------------------------------------------------------------------------- /terraform/deploy/modules/alb/variables.tf: -------------------------------------------------------------------------------- 1 | variable "prefix_env" { 2 | description = "prefix used to name resources" 3 | type = string 4 | } 5 | 6 | variable "app_name" { 7 | description = "Application name" 8 | type = string 9 | } 10 | 11 | -------------------------------------------------------------------------------- /terraform/hello/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | local = { 4 | source = "hashicorp/local" 5 | } 6 | } 7 | } 8 | 9 | # Create a file with "Hello, World!" text 10 | resource "local_file" "hello_file" { 11 | content = "Hello, World!" 12 | filename = "hello.txt" 13 | } 14 | 15 | 16 | output "filename" { 17 | value = local_file.hello_file.filename 18 | } 19 | 20 | -------------------------------------------------------------------------------- /terraform/deploy/modules/alb/outputs.tf: -------------------------------------------------------------------------------- 1 | output "alb_dns_name" { 2 | description = "Application Load Balancer DNS Name" 3 | value = ( 4 | length(kubernetes_ingress_v1.ingress_alb.status) > 0 && 5 | length(kubernetes_ingress_v1.ingress_alb.status[0].load_balancer) > 0 && 6 | length(kubernetes_ingress_v1.ingress_alb.status[0].load_balancer[0].ingress) > 0 7 | ) ? kubernetes_ingress_v1.ingress_alb.status[0].load_balancer[0].ingress[0].hostname : "ALB is still provisioning" 8 | } 9 | -------------------------------------------------------------------------------- /terraform/deploy/outputs.tf: -------------------------------------------------------------------------------- 1 | # Output the VPC ID 2 | output "vpc_id" { 3 | description = "VPC ID" 4 | value = module.vpc.vpc_id 5 | } 6 | 7 | # Output the EKS cluster details 8 | output "eks_cluster_name" { 9 | description = "Kubernetes Cluster Name" 10 | value = module.eks.cluster_name 11 | } 12 | 13 | output "eks_cluster_endpoint" { 14 | description = "EKS Cluster API Endpoint" 15 | value = module.eks.cluster_endpoint 16 | } 17 | 18 | output "eks_cluster_arn" { 19 | description = "EKS Cluster ARN" 20 | value = module.eks.cluster_arn 21 | } 22 | 23 | # Output the AWS Region 24 | output "aws_region" { 25 | description = "AWS region" 26 | value = var.aws_region 27 | } 28 | 29 | -------------------------------------------------------------------------------- /terraform/init/main.tf: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # This should only be run if the IAM policies do not already exist 4 | 5 | # 6 | # Create policy to give EKS nodes necessary permissions to run the LBC 7 | # IAM policy is from here: 8 | # https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.6/deploy/installation/#configure-iam 9 | # source for policy document is here: 10 | # https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.6.1/docs/install/iam_policy.json 11 | resource "aws_iam_policy" "alb_controller_custom" { 12 | name = "AWSLoadBalancerControllerIAMPolicy" 13 | description = "IAM policy for AWS Load Balancer Controller" 14 | policy = file("${path.module}/policies/iam_policy.json") 15 | } 16 | 17 | 18 | -------------------------------------------------------------------------------- /terraform/deploy/variables.tf: -------------------------------------------------------------------------------- 1 | # Define environment stage name 2 | variable "env_name" { 3 | description = "Unique identifier for tfvars configuration used" 4 | type = string 5 | } 6 | 7 | # Define the instance type for EKS nodes 8 | variable "instance_type" { 9 | description = "Instance type for EKS worker nodes" 10 | type = string 11 | default = "t3.micro" 12 | } 13 | 14 | # AWS Region to deploy the EKS cluster 15 | variable "aws_region" { 16 | description = "AWS region to deploy the EKS cluster" 17 | type = string 18 | } 19 | 20 | # EKS version 21 | variable "eks_cluster_version" { 22 | description = "EKS version" 23 | type = string 24 | default = "1.33" 25 | } 26 | 27 | # Use ALB - can set this to false for to get NLB 28 | ### NLB not yet implemented. If false you get no load balancer 29 | variable "use_alb" { 30 | description = "When true, uses AWS LBC to create ALB. When false an NLB is created" 31 | type = bool 32 | default = true 33 | } -------------------------------------------------------------------------------- /terraform/deploy/00_workspace_check.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # 3 | # As a convention (and enforced here) users deploying these resources should use a terraform workspace 4 | # that matches the env_name from the .tfvars file they are using. This prevents name conflicts within a region 5 | # as well as for global resources (like IAM roles). 6 | # 7 | # Logical order: 00 8 | ##### "Logical order" refers to the order a human would think of these executions 9 | ##### (although Terraform will determine actual order executed) 10 | # 11 | 12 | # Fetch the current workspace name 13 | locals { 14 | current_workspace = terraform.workspace 15 | } 16 | 17 | # Log a failure and quit if the workspace does not match `var.env_name` 18 | resource "null_resource" "check_workspace" { 19 | count = local.current_workspace != var.env_name ? 1 : 0 20 | 21 | provisioner "local-exec" { 22 | command = < 55 | 56 | terraform init 57 | ``` 58 | -------------------------------------------------------------------------------- /terraform/deploy/04_authentication.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # 3 | # Resources needed to give the application the necessary permissions 4 | # Includes IAM Role and Kubernetes ServiceAccount 5 | # 6 | # Logical order: 04 7 | ##### "Logical order" refers to the order a human would think of these executions 8 | ##### (although Terraform will determine actual order executed) 9 | # 10 | 11 | # 12 | # Use IRSA to give pods the necessary permissions 13 | # 14 | 15 | # 16 | # Create trust policy to be used by Service Account role 17 | 18 | locals { 19 | ddb_serviceaccount = "ddb-${local.prefix_env}-serviceaccount" 20 | oidc = module.eks.oidc_provider 21 | } 22 | 23 | 24 | data "aws_iam_policy_document" "service_account_trust_policy" { 25 | statement { 26 | actions = ["sts:AssumeRoleWithWebIdentity"] 27 | 28 | principals { 29 | type = "Federated" 30 | identifiers = ["arn:aws:iam::${local.aws_account}:oidc-provider/${local.oidc}"] 31 | } 32 | 33 | condition { 34 | test = "StringEquals" 35 | variable = "${local.oidc}:sub" 36 | values = ["system:serviceaccount:default:${local.ddb_serviceaccount}"] 37 | } 38 | 39 | condition { 40 | test = "StringEquals" 41 | variable = "${local.oidc}:aud" 42 | values = ["sts.amazonaws.com"] 43 | } 44 | } 45 | } 46 | 47 | 48 | resource "aws_iam_role" "ddb_access_role" { 49 | name = "ddb-${local.prefix_env}-${var.aws_region}-role" 50 | 51 | assume_role_policy = data.aws_iam_policy_document.service_account_trust_policy.json 52 | 53 | description = "Role used by Service Account to access DynamoDB" 54 | 55 | tags = { 56 | Name = "IRSA role used by DDB" 57 | } 58 | } 59 | 60 | resource "aws_iam_role_policy_attachment" "ddb_access_attachment" { 61 | role = aws_iam_role.ddb_access_role.name 62 | policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess" 63 | } 64 | 65 | 66 | # 67 | # Create service account 68 | resource "kubernetes_service_account" "ddb_serviceaccount" { 69 | metadata { 70 | name = local.ddb_serviceaccount 71 | namespace = "default" 72 | annotations = { 73 | "eks.amazonaws.com/role-arn" = aws_iam_role.ddb_access_role.arn 74 | } 75 | } 76 | 77 | # Give time for the cluster to complete (controllers, RBAC and IAM propagation) 78 | # See https://github.com/setheliot/eks_demo/blob/main/docs/separate_configs.md 79 | depends_on = [module.eks] 80 | } 81 | 82 | -------------------------------------------------------------------------------- /terraform/deploy/02_k8_lbc.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # 3 | # AWS Load Balancer Controller (LBC) in the Kubernetes Cluster 4 | # https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.6/deploy/installation 5 | # 6 | # Logical order: 02 7 | ##### "Logical order" refers to the order a human would think of these executions 8 | ##### (although Terraform will determine actual order executed) 9 | # 10 | 11 | # 12 | # AWS Load Balancer Controller 13 | 14 | # Retrieve the LBC IAM policy 15 | # This should have already been created once per account by the init modules 16 | # If it does not exist, will fail with timeout after 2 minutes 17 | data "aws_iam_policy" "lbc_policy" { 18 | name = "AWSLoadBalancerControllerIAMPolicy" 19 | } 20 | 21 | # Attach the policy to the node IAM role 22 | resource "aws_iam_role_policy_attachment" "alb_policy_node" { 23 | policy_arn = data.aws_iam_policy.lbc_policy.arn 24 | role = module.eks.eks_managed_node_groups["node_group_1"].iam_role_name 25 | } 26 | 27 | # 28 | # Create the K8s Service Account that will be used by Helm 29 | resource "kubernetes_service_account" "alb_controller" { 30 | metadata { 31 | name = "aws-load-balancer-controller" 32 | namespace = "kube-system" 33 | } 34 | 35 | # Give time for the cluster to complete (controllers, RBAC and IAM propagation) 36 | # See https://github.com/setheliot/eks_demo/blob/main/docs/separate_configs.md 37 | depends_on = [module.eks] 38 | } 39 | 40 | resource "helm_release" "aws_load_balancer_controller" { 41 | name = "aws-load-balancer-controller" 42 | namespace = "kube-system" 43 | repository = "https://aws.github.io/eks-charts" 44 | chart = "aws-load-balancer-controller" 45 | 46 | # This uses the v3 Helm provider syntax (https://registry.terraform.io/providers/hashicorp/helm/3.0.2/docs/guides/v3-upgrade-guide) 47 | set = [ 48 | { 49 | name = "clusterName" 50 | value = local.cluster_name 51 | }, 52 | { 53 | name = "serviceAccount.create" 54 | value = "false" 55 | }, 56 | { 57 | name = "serviceAccount.name" 58 | value = kubernetes_service_account.alb_controller.metadata[0].name 59 | }, 60 | { 61 | name = "region" 62 | value = var.aws_region 63 | }, 64 | { 65 | name = "vpcId" 66 | value = module.vpc.vpc_id 67 | } 68 | ] 69 | 70 | 71 | # Give time for the cluster to complete (controllers, RBAC and IAM propagation) 72 | # See https://github.com/setheliot/eks_demo/blob/main/docs/separate_configs.md 73 | depends_on = [module.eks] 74 | } 75 | 76 | -------------------------------------------------------------------------------- /docs/cleanup.md: -------------------------------------------------------------------------------- 1 | # Tear-down (clean up) all the resources - explained 2 | 3 | 4 | We enforce the following specific order of destruction: 5 | * `Deployment` → `PersistentVolumeClaim` → EKS Cluster 6 | 7 | This is the order required for a clean removal when using `terraform destroy`. This allows the cluster controllers to handle necessary cleanup operations. 8 | 1. Remove the `Deployment` first to allow cluster controllers to properly delete the pods. 9 | - Pods must be deleted before the `PersistentVolumeClaim`; otherwise, the deletion process will hang. 10 | 1. Remove the `PersistentVolumeClaim` while the cluster is still active to ensure controllers properly detach and delete the EBS volume. 11 | 1. Then delete everything else. 12 | 13 | --- 14 | ## What is going on here? 15 | 16 | When a single `terraform destroy` command is used to destroy everything, destruction of the `Deployment` and `ReplicaSet` does _not_ delete the pods. Some _other_ resource needed by the cluster controllers (possibly a component of the VPC), is getting destroyed before the `Deployment` is. 17 | 18 | This in turn prevents the `PersistentVolumeClaim` from deleting, because it is being used by the pods. 19 | 20 | It is possible that a critical VPC component impacts communication between the Kubernetes control plane and the AWS control plane, or something similar. 21 | 22 | This problem with `terraform destroy` can be solved by adding the following to the `module "eks"` block: 23 | 24 | ``` 25 | depends_on = [ module.vpc ] 26 | ``` 27 | 28 | This forces destruction of the VPC to wait until after destruction of the EKS cluster. 29 | 30 | ### But... this introduced new problems 31 | 32 | * It takes much longer to deploy resources with `apply` 33 | 34 | * Deploying resources with `apply` becomes unreliable. 35 | 36 | The change in dependency and timing introduces a new issue where Terraform attempts to create Kubernetes resources _before_ the proper RBAC configurations are applied (e.g., `ClusterRoles`, `RoleBindings`). These resources then fail with errors like 37 | 38 | ``` 39 | Error: serviceaccounts is forbidden: User "arn:aws:sts::12345678912:assumed-role/MyAdmin" cannot create resource "serviceaccounts" in API group "" in the namespace "default" 40 | ``` 41 | 42 | After the `apply` failure, running the same exact `apply` command then succeeds, because by that time the RBAC have propagated. 43 | 44 | * Time to tear-down resources with `destroy` also seems longer 45 | 46 | ### The goal of this repository is to show how to create these resources 47 | 48 | For this repo, the focus is on education and simplicity in creating these resources; therefore, it will not use the `depends_on` fix. 49 | 50 | Also this repo aims to show best practices, and in general it is a best practice to let Terraform determine dependency relationships. 51 | 52 | ### How else might we handle this? 53 | 54 | Using [separate distinct Terraform configurations](./separate_configs.md) is the best way to address this issue. 55 | -------------------------------------------------------------------------------- /terraform/deploy/03_k8s_storage.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # 3 | # Storage resources in the Kubernetes Cluster 4 | # 5 | # Logical order: 03 6 | ##### "Logical order" refers to the order a human would think of these executions 7 | ##### (although Terraform will determine actual order executed) 8 | # 9 | 10 | # 11 | # Retrieve the CSI driver policy 12 | data "aws_iam_policy" "csi_policy" { 13 | name = "AmazonEBSCSIDriverPolicy" 14 | } 15 | 16 | # 17 | # Attach the policy to the cluster IAM role 18 | resource "aws_iam_role_policy_attachment" "csi_policy_attachment" { 19 | policy_arn = data.aws_iam_policy.csi_policy.arn 20 | role = module.eks.eks_managed_node_groups["node_group_1"].iam_role_name 21 | } 22 | 23 | # 24 | # EBS Storage Class 25 | 26 | resource "kubernetes_storage_class" "ebs" { 27 | metadata { 28 | name = "ebs-storage-class" 29 | annotations = { 30 | "storageclass.kubernetes.io/is-default-class" = "true" 31 | } 32 | } 33 | 34 | # Storage provisioner used by CSI https://github.com/kubernetes-sigs/aws-ebs-csi-driver 35 | storage_provisioner = "ebs.csi.aws.com" 36 | 37 | # The reclaim policy for a PersistentVolume tells the cluster 38 | # what to do with the volume after it has been released of its claim 39 | reclaim_policy = "Delete" 40 | 41 | # Delay the binding and provisioning of a PersistentVolume until a Pod 42 | # using the PersistentVolumeClaim is created 43 | volume_binding_mode = "WaitForFirstConsumer" 44 | 45 | # see StorageClass Parameters Reference here: 46 | # https://docs.aws.amazon.com/eks/latest/userguide/create-storage-class.html 47 | parameters = { 48 | type = "gp3" 49 | fsType = "ext4" 50 | encrypted = "true" 51 | } 52 | 53 | # Give time for the cluster to complete (controllers, RBAC and IAM propagation) 54 | # See https://github.com/setheliot/eks_demo/blob/main/docs/separate_configs.md 55 | depends_on = [module.eks] 56 | } 57 | 58 | 59 | # 60 | # EBS Persistent Volume Claim 61 | 62 | resource "kubernetes_persistent_volume_claim_v1" "ebs_pvc" { 63 | metadata { 64 | name = local.ebs_claim_name 65 | } 66 | 67 | spec { 68 | # Volume can be mounted as read-write by a single node 69 | # 70 | # ReadWriteOnce access mode enables multiple pods to 71 | # access it when the pods are running on the same node. 72 | access_modes = ["ReadWriteOnce"] 73 | 74 | resources { 75 | requests = { 76 | storage = "1Gi" 77 | } 78 | } 79 | 80 | storage_class_name = "ebs-storage-class" 81 | 82 | } 83 | 84 | # Setting this allows `Terraform apply` to continue 85 | # Otherwise it would hang here waiting for claim to bind to a pod 86 | wait_until_bound = false 87 | 88 | # Give time for the cluster to complete (controllers, RBAC and IAM propagation) 89 | # See https://github.com/setheliot/eks_demo/blob/main/docs/separate_configs.md 90 | depends_on = [module.eks] 91 | } 92 | 93 | # This will create the PVC, which will wait until a pod needs it, and then create a PersistentVolume -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is an educational Terraform project that deploys a demo "guestbook" application on AWS EKS. It demonstrates EKS best practices including VPC setup, managed node groups, AWS Load Balancer Controller, EBS CSI storage, DynamoDB integration, and Secrets Manager with CSI driver. 8 | 9 | ## Commands 10 | 11 | ### Deploy the cluster 12 | ```bash 13 | cd scripts 14 | ./ez_cluster_deploy.sh 15 | ``` 16 | This interactive script validates AWS credentials, checks backend state, and deploys all resources. 17 | 18 | ### Tear down the cluster 19 | ```bash 20 | cd scripts 21 | ./cleanup_cluster.sh -var-file=environment/.tfvars 22 | ``` 23 | 24 | ### Manual Terraform commands (from terraform/deploy/) 25 | ```bash 26 | terraform init 27 | terraform workspace new # or: terraform workspace select 28 | terraform plan -var-file=environment/.tfvars 29 | terraform apply -var-file=environment/.tfvars -auto-approve 30 | ``` 31 | 32 | ## Architecture 33 | 34 | ### Terraform Structure 35 | - `terraform/init/` - One-time setup: creates AWSLoadBalancerControllerIAMPolicy (run once per AWS account) 36 | - `terraform/deploy/` - Main deployment configuration with numbered files indicating logical order: 37 | - `00_workspace_check.tf` - Enforces workspace name matches env_name from tfvars 38 | - `01_infrastructure.tf` - VPC, subnets, EKS cluster, DynamoDB table, VPC endpoints for SSM 39 | - `02_k8_lbc.tf` - AWS Load Balancer Controller via Helm 40 | - `03_k8s_storage.tf` - EBS CSI driver setup, StorageClass, PersistentVolumeClaim 41 | - `04_authentication.tf` - IRSA (IAM Roles for Service Accounts) for DynamoDB access 42 | - `05_application.tf` - Kubernetes Deployment for guestbook app, ALB module 43 | - `06_secrets_manager.tf` - Secrets Store CSI Driver and AWS provider DaemonSet 44 | - `terraform/deploy/modules/alb/` - ALB Ingress configuration 45 | - `terraform/deploy/environment/` - Region-specific tfvars files (us-east-1.tfvars, eu-west-1.tfvars, etc.) 46 | 47 | ### Key Patterns 48 | - **Workspace convention**: Terraform workspace name must match `env_name` in the tfvars file 49 | - **Resource naming**: Uses `eks-demo-` prefix pattern (stored in `local.prefix_env`) 50 | - **Dependencies**: Most K8s resources include `depends_on = [module.eks]` to handle RBAC propagation timing 51 | - **Backend state**: Uses S3 + DynamoDB for state locking (must be configured in backend.tf before first use) 52 | 53 | ### Providers 54 | - AWS (~> 5.95.0) 55 | - Kubernetes (~> 2.35) 56 | - Helm (~> 3.0) 57 | - kubectl (alekc/kubectl ~> 2.0) - for raw manifest application 58 | 59 | ## Important Notes 60 | 61 | - This cluster does NOT use EKS Auto Mode (see https://github.com/setheliot/eks_auto_mode for that) 62 | - The guestbook app container is from `ghcr.io/setheliot/xyz-demo-app:latest` 63 | - Cleanup requires staged destroys (deployment -> PVC -> ingress -> everything else) due to Kubernetes finalizer dependencies 64 | -------------------------------------------------------------------------------- /scripts/cleanup_cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e # Exit on any error 4 | 5 | # Check for the required argument 6 | if [[ $# -lt 1 ]]; then 7 | echo "Usage: $0 -var-file=" 8 | exit 1 9 | fi 10 | 11 | # Parse argument 12 | if [[ "$1" == "-var-file" ]]; then 13 | TFVARS_FILE=$2 14 | elif [[ "$1" =~ ^-var-file=(.*)$ ]]; then 15 | TFVARS_FILE=${BASH_REMATCH[1]} 16 | else 17 | echo "Error: Invalid argument format. Usage: $0 -var-file=" 18 | exit 1 19 | fi 20 | 21 | TFVARS_FILE=$(basename $TFVARS_FILE) 22 | 23 | # Find the terraform directory 24 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)" 25 | REPO_DIR=$(dirname "$SCRIPT_DIR") 26 | TF_DIR="$REPO_DIR/terraform/deploy" 27 | cd $TF_DIR 28 | 29 | 30 | # Ensure the tfvars file exists 31 | if [[ ! -f "environment/$TFVARS_FILE" ]]; then 32 | echo "Error: $TFVARS_FILE does not exist." 33 | exit 1 34 | else 35 | TFVARS_FILE="environment/$TFVARS_FILE" 36 | fi 37 | 38 | 39 | # Extract env_name from the selected file 40 | ENV_NAME=$(awk -F'"' '/env_name/ {print $2}' "$TFVARS_FILE" | xargs) 41 | 42 | if [[ -z "$ENV_NAME" ]]; then 43 | echo "❌ Could not extract env_name from $TFVARS_FILE. Ensure the file is correctly formatted." 44 | exit 1 45 | fi 46 | 47 | # Get AWS Account ID using STS 48 | AWS_ACCOUNT=$(aws sts get-caller-identity --query "Account" --output text 2>/dev/null) 49 | 50 | # Check if AWS_ACCOUNT is empty (invalid credentials) 51 | if [[ -z "$AWS_ACCOUNT" || "$AWS_ACCOUNT" == "None" ]]; then 52 | echo "There are no valid AWS credentials. Please update your AWS credentials to target the correct AWS account." 53 | exit 1 54 | fi 55 | 56 | # Prompt the user for confirmation 57 | echo "✅ Selected environment: [$ENV_NAME] (from [$(basename "$TFVARS_FILE")])" 58 | echo "💣 Your EKS cluster in AWS account [${AWS_ACCOUNT}] will be DESTROYED" 59 | echo "😇 Is that what you want?\n" 60 | read -r -p "Proceed? [y/n]: " response 61 | 62 | # Check if response is "y" or "yes" 63 | if [[ ! "$response" =~ ^[Yy]([Ee][Ss])?$ ]]; then 64 | echo "👋 Good bye!" 65 | exit 1 66 | fi 67 | 68 | echo "🏃 Running terraform init..." 69 | if ! terraform init 2> terraform_init_err.log; then 70 | if grep -q "Error refreshing state: state data in S3 does not have the expected content." terraform_init_err.log; then 71 | echo "👍 Ignoring known state data error and continuing..." 72 | else 73 | echo "❌ Unexpected error occurred. Exiting." 74 | exit 1 75 | fi 76 | fi 77 | 78 | if [ -f terraform_init_err.log ]; then 79 | rm terraform_init_err.log 80 | fi 81 | 82 | # Check the current Terraform workspace 83 | CURRENT_WS=$(terraform workspace show 2>/dev/null) 84 | 85 | if [[ "$CURRENT_WS" != "$ENV_NAME" ]]; then 86 | echo "🔄 Switching to Terraform workspace: $ENV_NAME" 87 | 88 | # Check if the workspace exists 89 | if ! terraform workspace select "$ENV_NAME" 2>/dev/null; then 90 | echo "❌ Workspace '$ENV_NAME' does not exist." 91 | fi 92 | fi 93 | 94 | echo "✅ Using Terraform workspace [$ENV_NAME]" 95 | 96 | 97 | # Run Terraform destroy commands 98 | echo "🏃 1 of 4 - Running terraform destroy on kubernetes_deployment_v1..." 99 | 100 | terraform destroy \ 101 | -auto-approve \ 102 | -target=kubernetes_deployment_v1.guestbook_app_deployment \ 103 | -var-file=$TFVARS_FILE 104 | 105 | echo "✅ kubernetes_deployment_v1 deleted" 106 | 107 | 108 | echo "🏃 2 of 4 - Running terraform destroy on kubernetes_persistent_volume_claim_v1..." 109 | 110 | terraform destroy \ 111 | -auto-approve \ 112 | -target=kubernetes_persistent_volume_claim_v1.ebs_pvc \ 113 | -var-file=$TFVARS_FILE 114 | 115 | echo "✅ kubernetes_persistent_volume_claim_v1 deleted" 116 | 117 | 118 | echo "🏃 3 of 4 - Running terraform destroy on kubernetes_ingress_v1..." 119 | 120 | terraform destroy \ 121 | -auto-approve \ 122 | -target=module.alb[0].kubernetes_ingress_v1.ingress_alb \ 123 | -var-file=$TFVARS_FILE 124 | 125 | echo "✅ kubernetes_ingress_v1 deleted" 126 | 127 | 128 | echo "🏃 4 of 4 - Running terraform destroy on all remaining resources..." 129 | 130 | terraform destroy \ 131 | -auto-approve \ 132 | -var-file=$TFVARS_FILE 133 | 134 | echo "✅✅✅ All resources deleted" -------------------------------------------------------------------------------- /terraform/deploy/05_application.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # 3 | # Deploy containers to run application code, and a Load Balancer to access the app 4 | # 5 | # Logical order: 05 6 | ##### "Logical order" refers to the order a human would think of these executions 7 | ##### (although Terraform will determine actual order executed) 8 | # 9 | 10 | 11 | # Define the app name 12 | locals { 13 | app_name = "guestbook-${local.prefix_env}" 14 | } 15 | 16 | # This defines the kubernetes deployment for the guestbook app 17 | resource "kubernetes_deployment_v1" "guestbook_app_deployment" { 18 | metadata { 19 | name = "${local.app_name}-deployment" 20 | labels = { 21 | app = local.app_name 22 | } 23 | } 24 | 25 | spec { 26 | replicas = 3 27 | selector { 28 | match_labels = { 29 | app = local.app_name 30 | } 31 | } 32 | template { 33 | metadata { 34 | labels = { 35 | app = local.app_name 36 | } 37 | } 38 | spec { 39 | service_account_name = local.ddb_serviceaccount 40 | container { 41 | # Application is from here https://github.com/setheliot/xyz_app_poc/tree/main/src 42 | # Improvements and pull requests welcomed! 43 | image = "ghcr.io/setheliot/xyz-demo-app:latest" 44 | name = "${local.app_name}-xyz-demo-app-container" 45 | 46 | port { 47 | container_port = 80 48 | } 49 | 50 | resources { 51 | limits = { 52 | cpu = "0.5" 53 | memory = "512Mi" 54 | } 55 | requests = { 56 | cpu = "250m" 57 | memory = "50Mi" 58 | } 59 | } 60 | 61 | # Mount the PVC as a volume in the container 62 | volume_mount { 63 | name = "ebs-k8s-attached-storage" 64 | mount_path = "/app/data" # Path inside the container 65 | } 66 | 67 | # Mount secrets from Secrets Manager 68 | volume_mount { 69 | name = "secrets-store" 70 | mount_path = "/mnt/secrets" 71 | read_only = true 72 | } 73 | 74 | # Store the DDB Table name for use by the container 75 | env { 76 | name = "DDB_TABLE" 77 | value = aws_dynamodb_table.guestbook.name 78 | } 79 | 80 | # Add environment variable using Kubernetes Downward API to get node name 81 | env { 82 | name = "NODE_NAME" 83 | value_from { 84 | field_ref { 85 | field_path = "spec.nodeName" 86 | } 87 | } 88 | } 89 | # Add environment variable for the region 90 | env { 91 | name = "AWS_REGION" 92 | value = var.aws_region # This is the region where the EKS cluster is deployed 93 | } 94 | } #container 95 | 96 | # Define the volume using the PVC 97 | volume { 98 | name = "ebs-k8s-attached-storage" 99 | 100 | persistent_volume_claim { 101 | claim_name = local.ebs_claim_name 102 | } 103 | } #volumes 104 | 105 | # Define the secrets CSI volume 106 | volume { 107 | name = "secrets-store" 108 | 109 | csi { 110 | driver = "secrets-store.csi.k8s.io" 111 | read_only = true 112 | 113 | volume_attributes = { 114 | secretProviderClass = "test-secret-provider" 115 | } 116 | } 117 | } #secrets volume 118 | } #spec (template) 119 | } #template 120 | } #spec (resource) 121 | 122 | # Give time for the cluster to complete (controllers, RBAC and IAM propagation) 123 | # See https://github.com/setheliot/eks_demo/blob/main/docs/separate_configs.md 124 | depends_on = [module.eks] 125 | } 126 | 127 | # Create ALB 128 | 129 | module "alb" { 130 | 131 | depends_on = [module.eks] 132 | 133 | source = "./modules/alb" 134 | prefix_env = local.prefix_env 135 | app_name = local.app_name 136 | 137 | count = var.use_alb ? 1 : 0 138 | } 139 | 140 | output "alb_dns_name" { 141 | value = var.use_alb ? module.alb[0].alb_dns_name : "(ALB not provisioned)" 142 | } 143 | 144 | 145 | ############### 146 | # Create NLB 147 | # module "nlb" { 148 | # source = "./modules/nlb" 149 | # prefix_env = local.prefix_env 150 | # app_name = local.app_name 151 | # count = var.use_alb ? 0 : 1 152 | # } 153 | # 154 | # output "nlb_dns_name" { 155 | # value = var.use_alb ? "(This app uses an ALB)" : module.legacy-nlb[0].nlb_dns_name 156 | # } 157 | 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS EKS Terraform demo 2 | 3 | This repo provides the Terraform configuration to deploy a demo app running on an AWS EKS Cluster using best practices. This was created as an _educational_ tool to learn about EKS and Terraform. It is _not_ recommended that this configuration be used in production without further assessment to ensure it meets organization requirements. 4 | 5 | ## Deployed resources 6 | 7 | This Terraform configuration deploys the following resources: 8 | * AWS EKS Cluster using Amazon EC2 nodes 9 | * Amazon DynamoDB table 10 | * Amazon Elastic Block Store (EBS) volume used as attached storage for the Kubernetes cluster (a `PersistentVolume`) 11 | * Demo "guestbook" application, deployed via containers 12 | * Application Load Balancer (ALB) to access the app 13 | * AWS Secrets Manager Secret integrated using [secrets-store CSI](https://secrets-store-csi-driver.sigs.k8s.io/) 14 | 15 | Plus several other supporting resources, as shown in the following diagram: 16 | 17 | ![architecture](images/architecture.jpg) 18 | 19 | ## Auto Mode Disabled 20 | This cluster does _not_ use EKS Auto Mode. To learn about EKS Auo Mode see this repo instead: https://github.com/setheliot/eks_auto_mode/ 21 | 22 | ## Deploy EKS cluster and app resources 23 | 24 | Run all commands from an environment that has 25 | * Terraform installed 26 | * AWS CLI installed 27 | * AWS credentials configured for the target account 28 | 29 | You have two options: 30 | 31 | ### Option 1. Automatic configuration and execution 32 | 33 | 1. Update the S3 bucket and DynamoDB table used for Terraform backend state here: [backend.tf](terraform/deploy/backend.tf). Instructions are in the comments in that file. 34 | 1. Choose one of the `tfvars` configuration files in the [terraform/deploy/environment](terraform/deploy/environment) directory, or create a new one. The environment name `env_name` should be unique to each `tfvars` configuration file. You can also set the AWS Region in the configuration file. 35 | 1. Run the following commands: 36 | ```bash 37 | cd scripts 38 | 39 | ./ez_cluster_deploy.sh 40 | ``` 41 | 42 | 43 | ### Option 2. For those familiar with using Terraform 44 | 1. Update the S3 bucket and DynamoDB table used for Terraform backend state here: [backend.tf](terraform/deploy/backend.tf). Instructions are in the comments in that file. 45 | 1. Create the IAM policy to be used by AWS Load Balancer Controller 46 | 1. This only needs to be done _once_ per AWS account 47 | 2. Create the IAM policy using the terraform in [terraform/init](terraform/init) 48 | 1. Choose one of the `tfvars` configuration files in the [terraform/deploy/environment](terraform/deploy/environment) directory, or create a new one. The environment name `env_name` should be unique to each `tfvars` configuration file. You can also set the AWS Region in the configuration file. 49 | 1. `cd` into the `terraform/deploy` directory 50 | 1. Initialize Terraform 51 | ```bash 52 | terraform init 53 | ``` 54 | 55 | 1. Set the terraform workspace to the same value as the environment name `env_name` for the `tfvars` configuration file you are using. 56 | * If this is your first time running then use 57 | ```bash 58 | terraform workspace new 59 | ``` 60 | * On subsequent uses, use 61 | ```bash 62 | terraform workspace select 63 | ``` 64 | 1. Generate the plan and review it 65 | ```bash 66 | terraform plan -var-file=environment/ 67 | ``` 68 | 69 | 1. Deploy the resources 70 | ```bash 71 | terraform apply -var-file=environment/ -auto-approve 72 | ``` 73 | 74 | Under **Outputs** there may be a value for `alb_dns_name`. If not, then 75 | * you can wait a few seconds and re-run the `terraform apply` command, or 76 | * you can look up the value in your EKS cluster by examining the `Ingress` Kubernetes resource 77 | 78 | Use this DNS name to access the app. Use `http://` (do _not_ use https). It may take about a minute after initial deployment for the application to start working. 79 | 80 | If you want to experiment and make changes to the Terraform, you should be able to start at step 3. 81 | 82 | ## Tear-down (clean up) all the resources created 83 | 84 | ### Option 1. Scripted 85 | 86 | ```bash 87 | cd scripts 88 | 89 | ./cleanup_cluster.sh \ 90 | -var-file=environment/ 91 | ``` 92 | 93 | ### Option 2. Do it yourself 94 | 95 | ```bash 96 | terraform init 97 | terraform workspace select 98 | ``` 99 | 100 | ```bash 101 | terraform destroy \ 102 | -auto-approve \ 103 | -target=kubernetes_deployment_v1.guestbook_app_deployment \ 104 | -var-file=environment/ 105 | 106 | terraform destroy \ 107 | -auto-approve \ 108 | -target=kubernetes_persistent_volume_claim_v1.ebs_pvc \ 109 | -var-file=environment/ 110 | 111 | terraform destroy \ 112 | -auto-approve \ 113 | -target=module.alb[0].kubernetes_ingress_v1.ingress_alb \ 114 | -var-file=environment/ 115 | 116 | terraform destroy \ 117 | -auto-approve \ 118 | -var-file=environment/ 119 | ``` 120 | 121 | To understand why this requires these separate `destroy` operations, [see this](docs/cleanup.md#tear-down-clean-up-all-the-resources-created). 122 | 123 | ## Known issues 124 | * [Known issues](docs/known_issues.md) 125 | --- 126 | I welcome feedback or bug reports (use GitHub issues) and Pull Requests. 127 | 128 | [MIT License](LICENSE) -------------------------------------------------------------------------------- /terraform/deploy/06_secrets_manager.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # 3 | # Secrets Manager integration with EKS using Secrets Store CSI Driver 4 | # 5 | # Logical order: 06 6 | # 7 | 8 | # Create the secret in AWS Secrets Manager 9 | resource "aws_secretsmanager_secret" "test_secret" { 10 | name = "test-secret" 11 | 12 | tags = { 13 | Environment = local.prefix_env 14 | Terraform = "true" 15 | } 16 | } 17 | 18 | resource "aws_secretsmanager_secret_version" "test_secret_version" { 19 | secret_id = aws_secretsmanager_secret.test_secret.id 20 | secret_string = jsonencode({ 21 | username = "admin" 22 | password = "MySecurePassword123" 23 | }) 24 | } 25 | 26 | 27 | 28 | # IAM policy for Secrets Manager access 29 | resource "aws_iam_policy" "secrets_manager_policy" { 30 | name = "guestbook-secrets-${local.prefix_env}-${var.aws_region}-policy" 31 | description = "Policy for accessing Secrets Manager" 32 | 33 | policy = jsonencode({ 34 | Version = "2012-10-17" 35 | Statement = [ 36 | { 37 | Effect = "Allow" 38 | Action = [ 39 | "secretsmanager:GetSecretValue", 40 | "secretsmanager:DescribeSecret" 41 | ] 42 | Resource = "${aws_secretsmanager_secret.test_secret.arn}" 43 | } 44 | ] 45 | }) 46 | } 47 | 48 | # Attach the policy to the DDB IAM role 49 | resource "aws_iam_role_policy_attachment" "secrets_manager_attachment" { 50 | policy_arn = aws_iam_policy.secrets_manager_policy.arn 51 | role = aws_iam_role.ddb_access_role.name 52 | } 53 | 54 | # Install Secrets Store CSI Driver via Helm 55 | resource "helm_release" "secrets_store_csi_driver" { 56 | name = "csi-secrets-store" 57 | repository = "https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts" 58 | chart = "secrets-store-csi-driver" 59 | namespace = "kube-system" 60 | 61 | values = [ 62 | yamlencode({ 63 | syncSecret = { 64 | enabled = true 65 | } 66 | }) 67 | ] 68 | 69 | depends_on = [module.eks] 70 | } 71 | 72 | # AWS Secrets Manager CSI Provider - ServiceAccount 73 | resource "kubernetes_service_account" "aws_provider" { 74 | metadata { 75 | name = "csi-secrets-store-provider-aws" 76 | namespace = "kube-system" 77 | } 78 | 79 | depends_on = [helm_release.secrets_store_csi_driver] 80 | } 81 | 82 | # AWS Secrets Manager CSI Provider - ClusterRole 83 | resource "kubernetes_cluster_role" "aws_provider" { 84 | metadata { 85 | name = "csi-secrets-store-provider-aws-cluster-role" 86 | } 87 | 88 | rule { 89 | api_groups = [""] 90 | resources = ["serviceaccounts/token"] 91 | verbs = ["create"] 92 | } 93 | 94 | rule { 95 | api_groups = [""] 96 | resources = ["serviceaccounts"] 97 | verbs = ["get"] 98 | } 99 | 100 | rule { 101 | api_groups = [""] 102 | resources = ["pods"] 103 | verbs = ["get"] 104 | } 105 | 106 | rule { 107 | api_groups = [""] 108 | resources = ["nodes"] 109 | verbs = ["get"] 110 | } 111 | 112 | depends_on = [helm_release.secrets_store_csi_driver] 113 | } 114 | 115 | # AWS Secrets Manager CSI Provider - ClusterRoleBinding 116 | resource "kubernetes_cluster_role_binding" "aws_provider" { 117 | metadata { 118 | name = "csi-secrets-store-provider-aws-cluster-rolebinding" 119 | } 120 | 121 | role_ref { 122 | api_group = "rbac.authorization.k8s.io" 123 | kind = "ClusterRole" 124 | name = kubernetes_cluster_role.aws_provider.metadata[0].name 125 | } 126 | 127 | subject { 128 | kind = "ServiceAccount" 129 | name = kubernetes_service_account.aws_provider.metadata[0].name 130 | namespace = "kube-system" 131 | } 132 | } 133 | 134 | # AWS Secrets Manager CSI Provider - DaemonSet 135 | resource "kubernetes_daemonset" "aws_provider" { 136 | metadata { 137 | name = "csi-secrets-store-provider-aws" 138 | namespace = "kube-system" 139 | labels = { 140 | app = "csi-secrets-store-provider-aws" 141 | } 142 | } 143 | 144 | spec { 145 | selector { 146 | match_labels = { 147 | app = "csi-secrets-store-provider-aws" 148 | } 149 | } 150 | 151 | template { 152 | metadata { 153 | labels = { 154 | app = "csi-secrets-store-provider-aws" 155 | } 156 | } 157 | 158 | spec { 159 | service_account_name = kubernetes_service_account.aws_provider.metadata[0].name 160 | host_network = false 161 | 162 | container { 163 | name = "provider-aws-installer" 164 | image = "public.ecr.aws/aws-secrets-manager/secrets-store-csi-driver-provider-aws:2.1.0" 165 | image_pull_policy = "Always" 166 | 167 | args = ["--provider-volume=/var/run/secrets-store-csi-providers"] 168 | 169 | resources { 170 | requests = { 171 | cpu = "50m" 172 | memory = "100Mi" 173 | } 174 | limits = { 175 | cpu = "50m" 176 | memory = "100Mi" 177 | } 178 | } 179 | 180 | security_context { 181 | privileged = false 182 | allow_privilege_escalation = false 183 | } 184 | 185 | volume_mount { 186 | name = "providervol" 187 | mount_path = "/var/run/secrets-store-csi-providers" 188 | } 189 | 190 | volume_mount { 191 | name = "mountpoint-dir" 192 | mount_path = "/var/lib/kubelet/pods" 193 | mount_propagation = "HostToContainer" 194 | } 195 | } 196 | 197 | volume { 198 | name = "providervol" 199 | host_path { 200 | path = "/var/run/secrets-store-csi-providers" 201 | } 202 | } 203 | 204 | volume { 205 | name = "mountpoint-dir" 206 | host_path { 207 | path = "/var/lib/kubelet/pods" 208 | type = "DirectoryOrCreate" 209 | } 210 | } 211 | 212 | node_selector = { 213 | "kubernetes.io/os" = "linux" 214 | } 215 | } 216 | } 217 | 218 | strategy { 219 | type = "RollingUpdate" 220 | } 221 | } 222 | 223 | depends_on = [ 224 | kubernetes_service_account.aws_provider, 225 | kubernetes_cluster_role_binding.aws_provider 226 | ] 227 | } 228 | 229 | # Create SecretProviderClass 230 | resource "kubectl_manifest" "secret_provider_class" { 231 | yaml_body = <<-YAML 232 | apiVersion: secrets-store.csi.x-k8s.io/v1 233 | kind: SecretProviderClass 234 | metadata: 235 | name: test-secret-provider 236 | namespace: default 237 | spec: 238 | provider: aws 239 | parameters: 240 | objects: | 241 | - objectName: "${aws_secretsmanager_secret.test_secret.name}" 242 | objectType: "secretsmanager" 243 | YAML 244 | 245 | depends_on = [kubernetes_daemonset.aws_provider] 246 | } 247 | -------------------------------------------------------------------------------- /terraform/deploy/01_infrastructure.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # 3 | # AWS Infrastructure including the EKS Cluster 4 | # 5 | # Logical order: 01 6 | ##### "Logical order" refers to the order a human would think of these executions 7 | ##### (although Terraform will determine actual order executed) 8 | # 9 | ############### 10 | 11 | # 12 | # VPC and Subnets 13 | data "aws_availability_zones" "available" { 14 | # Exclude local zones 15 | filter { 16 | name = "opt-in-status" 17 | values = ["opt-in-not-required"] 18 | } 19 | } 20 | 21 | locals { 22 | az_count = length(data.aws_availability_zones.available.names) 23 | max_azs = min(local.az_count, 3) # Use up to 3 AZs, but only if available (looking at you, us-west-1 👀) 24 | } 25 | 26 | module "vpc" { 27 | 28 | source = "terraform-aws-modules/vpc/aws" 29 | version = "~> 5.0" 30 | 31 | name = "${local.prefix_env}-vpc" 32 | cidr = "10.0.0.0/16" 33 | azs = slice(data.aws_availability_zones.available.names, 0, local.max_azs) 34 | private_subnets = slice(["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"], 0, local.max_azs) 35 | public_subnets = slice(["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"], 0, local.max_azs) 36 | 37 | enable_nat_gateway = true 38 | single_nat_gateway = true 39 | enable_dns_hostnames = true 40 | 41 | # Tag subnets for use by AWS Load Balancer controller 42 | # https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.1/deploy/subnet_discovery/ 43 | public_subnet_tags = { 44 | "kubernetes.io/role/elb" = "1" # ✅ Required for ALB 45 | "kubernetes.io/cluster/${local.cluster_name}" = "owned" # Links subnet to EKS 46 | "Name" = "${local.prefix_env}-public-subnet" 47 | } 48 | 49 | private_subnet_tags = { 50 | "kubernetes.io/role/internal-elb" = "1" # For internal load balancers 51 | "kubernetes.io/cluster/${local.cluster_name}" = "owned" 52 | "Name" = "${local.prefix_env}-private-subnet" 53 | } 54 | 55 | tags = { 56 | Terraform = "true" 57 | Environment = local.prefix_env 58 | 59 | # Ensure workspace check logic runs before resources created 60 | always_zero = length(null_resource.check_workspace) 61 | } 62 | } 63 | 64 | # The managed node group will add a unique ID to the end of this 65 | locals { 66 | eks_node_iam_role_name = substr("eks-node-role-${local.cluster_name}", 0, 36) 67 | } 68 | 69 | 70 | # 71 | # EKS Cluster 72 | module "eks" { 73 | 74 | source = "terraform-aws-modules/eks/aws" 75 | version = "~> 20.37" # latest 20.x 76 | 77 | cluster_name = local.cluster_name 78 | cluster_version = local.cluster_version 79 | 80 | cluster_endpoint_public_access = true 81 | cluster_endpoint_private_access = false 82 | 83 | vpc_id = module.vpc.vpc_id 84 | subnet_ids = module.vpc.private_subnets 85 | 86 | # coredns, kube-proxy, and vpc-cni are automatically installed by EKS 87 | cluster_addons = { 88 | eks-pod-identity-agent = {}, 89 | aws-ebs-csi-driver = {} 90 | } 91 | 92 | eks_managed_node_groups = { 93 | node_group_1 = { 94 | name = "${local.prefix_env}-node-group" 95 | ami_type = "AL2023_x86_64_STANDARD" 96 | use_latest_ami_release_version = true 97 | instance_types = [var.instance_type] 98 | 99 | min_size = 1 100 | max_size = 5 101 | desired_size = 3 102 | 103 | # Setup a custom launch template for the managed nodes 104 | # Notes these settings are the same as the defaults 105 | use_custom_launch_template = true 106 | create_launch_template = true 107 | 108 | # Enable Instance Metadata Service (IMDS) 109 | metadata_options = { 110 | http_endpoint = "enabled" 111 | http_tokens = "required" 112 | http_put_response_hop_limit = 2 113 | } 114 | 115 | # Attach the managed policies for SSM access by the nodes 116 | iam_role_name = local.eks_node_iam_role_name 117 | iam_role_additional_policies = { 118 | ssm_access = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" 119 | } # iam_role_additional_policies 120 | } # node_group_1 121 | } # eks_managed_node_groups 122 | 123 | # Cluster access entry 124 | enable_cluster_creator_admin_permissions = true 125 | 126 | tags = { 127 | Environment = local.prefix_env 128 | Terraform = "true" 129 | 130 | # Ensure workspace check logic runs before resources created 131 | always_zero = length(null_resource.check_workspace) 132 | } 133 | 134 | # Transient failures in creating StorageClass, PersistentVolumeClaim, 135 | # ServiceAccount, Deployment, were observed due to RBAC propagation not 136 | # completed. Therefore raising this from its default 30s 137 | dataplane_wait_duration = "60s" 138 | 139 | } 140 | 141 | locals { 142 | node_security_group_id = module.eks.node_security_group_id 143 | } 144 | 145 | # Create VPC endpoints (Private Links) for SSM Session Manager access to nodes 146 | resource "aws_security_group" "vpc_endpoint_sg" { 147 | name = "${local.prefix_env}-vpc-endpoint-sg" 148 | vpc_id = module.vpc.vpc_id 149 | 150 | ingress { 151 | description = "Allow EKS Nodes to access VPC Endpoints" 152 | from_port = 443 153 | to_port = 443 154 | protocol = "tcp" 155 | security_groups = [local.node_security_group_id] 156 | } 157 | 158 | egress { 159 | from_port = 0 160 | to_port = 0 161 | protocol = "-1" 162 | cidr_blocks = ["0.0.0.0/0"] 163 | } 164 | 165 | tags = { 166 | Environment = local.prefix_env 167 | Terraform = "true" 168 | } 169 | } 170 | 171 | resource "aws_vpc_endpoint" "private_link_ssm" { 172 | vpc_id = module.vpc.vpc_id 173 | service_name = "com.amazonaws.${var.aws_region}.ssm" 174 | vpc_endpoint_type = "Interface" 175 | security_group_ids = [aws_security_group.vpc_endpoint_sg.id] 176 | subnet_ids = module.vpc.private_subnets 177 | private_dns_enabled = true 178 | 179 | tags = { 180 | Environment = local.prefix_env 181 | Terraform = "true" 182 | } 183 | } 184 | 185 | resource "aws_vpc_endpoint" "private_link_ssmmessages" { 186 | vpc_id = module.vpc.vpc_id 187 | service_name = "com.amazonaws.${var.aws_region}.ssmmessages" 188 | vpc_endpoint_type = "Interface" 189 | security_group_ids = [aws_security_group.vpc_endpoint_sg.id] 190 | subnet_ids = module.vpc.private_subnets 191 | private_dns_enabled = true 192 | 193 | tags = { 194 | Environment = local.prefix_env 195 | Terraform = "true" 196 | } 197 | } 198 | 199 | resource "aws_vpc_endpoint" "private_link_ec2messages" { 200 | vpc_id = module.vpc.vpc_id 201 | service_name = "com.amazonaws.${var.aws_region}.ec2messages" 202 | vpc_endpoint_type = "Interface" 203 | security_group_ids = [aws_security_group.vpc_endpoint_sg.id] 204 | subnet_ids = module.vpc.private_subnets 205 | private_dns_enabled = true 206 | 207 | tags = { 208 | Environment = local.prefix_env 209 | Terraform = "true" 210 | } 211 | } 212 | 213 | # DynamoDb table 214 | resource "aws_vpc_endpoint" "private_link_dynamodb" { 215 | 216 | vpc_id = module.vpc.vpc_id 217 | service_name = "com.amazonaws.${var.aws_region}.dynamodb" 218 | vpc_endpoint_type = "Gateway" 219 | route_table_ids = module.vpc.private_route_table_ids 220 | 221 | tags = { 222 | Environment = local.prefix_env 223 | Terraform = "true" 224 | } 225 | } 226 | 227 | resource "aws_dynamodb_table" "guestbook" { 228 | 229 | name = "${local.prefix_env}-guestbook" 230 | billing_mode = "PROVISIONED" 231 | read_capacity = 2 232 | write_capacity = 2 233 | hash_key = "GuestID" 234 | range_key = "Name" 235 | stream_enabled = true 236 | stream_view_type = "NEW_IMAGE" 237 | 238 | point_in_time_recovery { 239 | enabled = true 240 | } 241 | 242 | attribute { 243 | name = "GuestID" 244 | type = "S" 245 | } 246 | 247 | attribute { 248 | name = "Name" 249 | type = "S" 250 | } 251 | 252 | tags = { 253 | Environment = local.prefix_env 254 | Terraform = "true" 255 | } 256 | 257 | # Ensure workspace check logic runs before resources created 258 | depends_on = [null_resource.check_workspace] 259 | 260 | } 261 | -------------------------------------------------------------------------------- /terraform/deploy/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/alekc/kubectl" { 5 | version = "2.1.3" 6 | constraints = "~> 2.0" 7 | hashes = [ 8 | "h1:poWSAAtK4FI1x79C2OyLaNrvWUGTQdr1ZT58edDz+Rs=", 9 | "zh:0e601ae36ebc32eb8c10aff4c48c1125e471fa09f5668465af7581c9057fa22c", 10 | "zh:1773f08a412d1a5f89bac174fe1efdfd255ecdda92d31a2e31937e4abf843a2f", 11 | "zh:1da2db1f940c5d34e31c2384c7bd7acba68725cc1d3ba6db0fec42efe80dbfb7", 12 | "zh:20dc810fb09031bcfea4f276e1311e8286d8d55705f55433598418b7bcc76357", 13 | "zh:326a01c86ba90f6c6eb121bacaabb85cfa9059d6587aea935a9bbb6d3d8e3f3f", 14 | "zh:5a3737ea1e08421fe3e700dc833c6fd2c7b8c3f32f5444e844b3fe0c2352757b", 15 | "zh:5f490acbd0348faefea273cb358db24e684cbdcac07c71002ee26b6cfd2c54a0", 16 | "zh:777688cda955213ba637e2ac6b1994e438a5af4d127a34ecb9bb010a8254f8a8", 17 | "zh:7acc32371053592f55ee0bcbbc2f696a8466415dea7f4bc5a6573f03953fc926", 18 | "zh:81f0108e2efe5ae71e651a8826b61d0ce6918811ccfdc0e5b81b2cfb0f7f57fe", 19 | "zh:88b785ea7185720cf40679cb8fa17e57b8b07fd6322cf2d4000b835282033d81", 20 | "zh:89d833336b5cd027e671b46f9c5bc7d10c5109e95297639bbec8001da89aa2f7", 21 | "zh:df108339a89d4372e5b13f77bd9d53c02a04362fb5d85e1d9b6b47292e30821c", 22 | "zh:e8a2e3a5c50ca124e6014c361d72a9940d8e815f37ae2d1e9487ac77c3043013", 23 | ] 24 | } 25 | 26 | provider "registry.terraform.io/hashicorp/aws" { 27 | version = "5.95.0" 28 | constraints = ">= 4.33.0, >= 5.79.0, >= 5.95.0, ~> 5.95.0, < 6.0.0" 29 | hashes = [ 30 | "h1:PUug/LLWa4GM08rXqnmCVUXj8ibCTvQxgvawhat3bMo=", 31 | "zh:20aac8c95edd444e659f235d19fa6af9b259c5a70fce19d400539ee88687e7d4", 32 | "zh:29c55846fadd19dde0c5108f74d507c296d6c37cabdd466a96d3721a7c261743", 33 | "zh:325fa5cb42d58c9203c279450863c49e534672f7101c067af465f9d7f4be3be5", 34 | "zh:4f18c643584f7ba554399c0db3dd1c81629dfc2508a8777890f9f3b80b5213b7", 35 | "zh:561e38e9cc6f0be5470c187ea8d51047c4133d9cb74cc1c364a9ebe41f40a06b", 36 | "zh:6ec2cceed96ca5e47591ef11686614c663b05e112a814d24246a2739066577b6", 37 | "zh:710a227c02b8a50f75a82a7f063d2416e85783e02ed91bb22cc12e7a8e11a3cf", 38 | "zh:97a2f5e9bf4cf9a38274eddb7967e1cb4e5b04960c7da3603d9b1c15e18b8626", 39 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 40 | "zh:bf6bfb01fff8226d86c1b219d67cd96f37bb9312b17d00340e6ff00dda2dbe82", 41 | "zh:cba74d606149cbaaa8dfb69f369f2496b851643a879adc24b11515fcece42b66", 42 | "zh:d5a2c36739cab677a48f4856958c96be6f018ff0da50d233ca93a3a21aaceca1", 43 | "zh:df5d1466144852fe5da4af0628db6f02b5186c59f683e5085705d9b90cacfbc0", 44 | "zh:f82d96b45983b3c73b78dced9e344512b7a9adb06e8c1e3e4f422605efbb756d", 45 | "zh:fb523f787077270059a8f3ab52c0fc56257c0b3a06f0219be247c8b15ff0ca2a", 46 | ] 47 | } 48 | 49 | provider "registry.terraform.io/hashicorp/cloudinit" { 50 | version = "2.3.7" 51 | constraints = ">= 2.0.0" 52 | hashes = [ 53 | "h1:M9TpQxKAE/hyOwytdX9MUNZw30HoD/OXqYIug5fkqH8=", 54 | "zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e", 55 | "zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5", 56 | "zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd", 57 | "zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1", 58 | "zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7", 59 | "zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01", 60 | "zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9", 61 | "zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a", 62 | "zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13", 63 | "zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14", 64 | "zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224", 65 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 66 | ] 67 | } 68 | 69 | provider "registry.terraform.io/hashicorp/helm" { 70 | version = "3.0.2" 71 | constraints = "~> 3.0" 72 | hashes = [ 73 | "h1:tOye2RnjFNXH236AsqGaIWtz4j6PZrpPuJhOSBt0KxU=", 74 | "zh:2778de76c7dfb2e85c75fe6de3c11172a25551ed499bfb9e9f940a5be81167b0", 75 | "zh:3b4c436a41e4fbae5f152852a9bd5c97db4460af384e26977477a40adf036690", 76 | "zh:617a372f5bb2288f3faf5fd4c878a68bf08541cf418a3dbb8a19bc41ad4a0bf2", 77 | "zh:84de431479548c96cb61c495278e320f361e80ab4f8835a5425ece24a9b6d310", 78 | "zh:8b4cf5f81d10214e5e1857d96cff60a382a22b9caded7f5d7a92e5537fc166c1", 79 | "zh:baeb26a00ffbcf3d507cdd940b2a2887eee723af5d3319a53eec69048d5e341e", 80 | "zh:ca05a8814e9bf5fbffcd642df3a8d9fae9549776c7057ceae6d6f56471bae80f", 81 | "zh:ca4bf3f94dedb5c5b1a73568f2dad7daf0ef3f85e688bc8bc2d0e915ec148366", 82 | "zh:d331f2129fd3165c4bda875c84a65555b22eb007801522b9e017d065ac69b67e", 83 | "zh:e583b2b478dde67da28e605ab4ef6521c2e390299b471d7d8ef05a0b608dcdad", 84 | "zh:f238b86611647c108c073d265f8891a2738d3158c247468ae0ff5b1a3ac4122a", 85 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 86 | ] 87 | } 88 | 89 | provider "registry.terraform.io/hashicorp/kubernetes" { 90 | version = "2.38.0" 91 | constraints = "~> 2.35" 92 | hashes = [ 93 | "h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=", 94 | "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", 95 | "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", 96 | "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", 97 | "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", 98 | "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", 99 | "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", 100 | "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", 101 | "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", 102 | "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", 103 | "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", 104 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 105 | "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", 106 | ] 107 | } 108 | 109 | provider "registry.terraform.io/hashicorp/null" { 110 | version = "3.2.4" 111 | constraints = ">= 3.0.0" 112 | hashes = [ 113 | "h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=", 114 | "zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2", 115 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 116 | "zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43", 117 | "zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a", 118 | "zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991", 119 | "zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f", 120 | "zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e", 121 | "zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615", 122 | "zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442", 123 | "zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5", 124 | "zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f", 125 | "zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f", 126 | ] 127 | } 128 | 129 | provider "registry.terraform.io/hashicorp/time" { 130 | version = "0.13.1" 131 | constraints = ">= 0.9.0" 132 | hashes = [ 133 | "h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=", 134 | "zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74", 135 | "zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f", 136 | "zh:26f8e51bb7c275c404ba6028c1b530312066009194db721a8427a7bc5cdbc83a", 137 | "zh:772ff8dbdbef968651ab3ae76d04afd355c32f8a868d03244db3f8496e462690", 138 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 139 | "zh:898db5d2b6bd6ca5457dccb52eedbc7c5b1a71e4a4658381bcbb38cedbbda328", 140 | "zh:8de913bf09a3fa7bedc29fec18c47c571d0c7a3d0644322c46f3aa648cf30cd8", 141 | "zh:9402102c86a87bdfe7e501ffbb9c685c32bbcefcfcf897fd7d53df414c36877b", 142 | "zh:b18b9bb1726bb8cfbefc0a29cf3657c82578001f514bcf4c079839b6776c47f0", 143 | "zh:b9d31fdc4faecb909d7c5ce41d2479dd0536862a963df434be4b16e8e4edc94d", 144 | "zh:c951e9f39cca3446c060bd63933ebb89cedde9523904813973fbc3d11863ba75", 145 | "zh:e5b773c0d07e962291be0e9b413c7a22c044b8c7b58c76e8aa91d1659990dfb5", 146 | ] 147 | } 148 | 149 | provider "registry.terraform.io/hashicorp/tls" { 150 | version = "4.1.0" 151 | constraints = ">= 3.0.0" 152 | hashes = [ 153 | "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=", 154 | "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2", 155 | "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8", 156 | "zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc", 157 | "zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc", 158 | "zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac", 159 | "zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882", 160 | "zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d", 161 | "zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298", 162 | "zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297", 163 | "zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a", 164 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 165 | "zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54", 166 | ] 167 | } 168 | -------------------------------------------------------------------------------- /terraform/init/policies/iam_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "iam:CreateServiceLinkedRole" 8 | ], 9 | "Resource": "*", 10 | "Condition": { 11 | "StringEquals": { 12 | "iam:AWSServiceName": "elasticloadbalancing.amazonaws.com" 13 | } 14 | } 15 | }, 16 | { 17 | "Effect": "Allow", 18 | "Action": [ 19 | "ec2:DescribeAccountAttributes", 20 | "ec2:DescribeAddresses", 21 | "ec2:DescribeAvailabilityZones", 22 | "ec2:DescribeInternetGateways", 23 | "ec2:DescribeVpcs", 24 | "ec2:DescribeVpcPeeringConnections", 25 | "ec2:DescribeSubnets", 26 | "ec2:DescribeSecurityGroups", 27 | "ec2:DescribeInstances", 28 | "ec2:DescribeNetworkInterfaces", 29 | "ec2:DescribeTags", 30 | "ec2:GetCoipPoolUsage", 31 | "ec2:DescribeCoipPools", 32 | "ec2:GetSecurityGroupsForVpc", 33 | "elasticloadbalancing:DescribeLoadBalancers", 34 | "elasticloadbalancing:DescribeLoadBalancerAttributes", 35 | "elasticloadbalancing:DescribeListeners", 36 | "elasticloadbalancing:DescribeListenerCertificates", 37 | "elasticloadbalancing:DescribeSSLPolicies", 38 | "elasticloadbalancing:DescribeRules", 39 | "elasticloadbalancing:DescribeTargetGroups", 40 | "elasticloadbalancing:DescribeTargetGroupAttributes", 41 | "elasticloadbalancing:DescribeTargetHealth", 42 | "elasticloadbalancing:DescribeTags", 43 | "elasticloadbalancing:DescribeTrustStores", 44 | "elasticloadbalancing:DescribeListenerAttributes", 45 | "elasticloadbalancing:DescribeCapacityReservation" 46 | ], 47 | "Resource": "*" 48 | }, 49 | { 50 | "Effect": "Allow", 51 | "Action": [ 52 | "cognito-idp:DescribeUserPoolClient", 53 | "acm:ListCertificates", 54 | "acm:DescribeCertificate", 55 | "iam:ListServerCertificates", 56 | "iam:GetServerCertificate", 57 | "waf-regional:GetWebACL", 58 | "waf-regional:GetWebACLForResource", 59 | "waf-regional:AssociateWebACL", 60 | "waf-regional:DisassociateWebACL", 61 | "wafv2:GetWebACL", 62 | "wafv2:GetWebACLForResource", 63 | "wafv2:AssociateWebACL", 64 | "wafv2:DisassociateWebACL", 65 | "shield:GetSubscriptionState", 66 | "shield:DescribeProtection", 67 | "shield:CreateProtection", 68 | "shield:DeleteProtection" 69 | ], 70 | "Resource": "*" 71 | }, 72 | { 73 | "Effect": "Allow", 74 | "Action": [ 75 | "ec2:AuthorizeSecurityGroupIngress", 76 | "ec2:RevokeSecurityGroupIngress" 77 | ], 78 | "Resource": "*" 79 | }, 80 | { 81 | "Effect": "Allow", 82 | "Action": [ 83 | "ec2:CreateSecurityGroup" 84 | ], 85 | "Resource": "*" 86 | }, 87 | { 88 | "Effect": "Allow", 89 | "Action": [ 90 | "ec2:CreateTags" 91 | ], 92 | "Resource": "arn:aws:ec2:*:*:security-group/*", 93 | "Condition": { 94 | "StringEquals": { 95 | "ec2:CreateAction": "CreateSecurityGroup" 96 | }, 97 | "Null": { 98 | "aws:RequestTag/elbv2.k8s.aws/cluster": "false" 99 | } 100 | } 101 | }, 102 | { 103 | "Effect": "Allow", 104 | "Action": [ 105 | "ec2:CreateTags", 106 | "ec2:DeleteTags" 107 | ], 108 | "Resource": "arn:aws:ec2:*:*:security-group/*", 109 | "Condition": { 110 | "Null": { 111 | "aws:RequestTag/elbv2.k8s.aws/cluster": "true", 112 | "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" 113 | } 114 | } 115 | }, 116 | { 117 | "Effect": "Allow", 118 | "Action": [ 119 | "ec2:AuthorizeSecurityGroupIngress", 120 | "ec2:RevokeSecurityGroupIngress", 121 | "ec2:DeleteSecurityGroup" 122 | ], 123 | "Resource": "*", 124 | "Condition": { 125 | "Null": { 126 | "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" 127 | } 128 | } 129 | }, 130 | { 131 | "Effect": "Allow", 132 | "Action": [ 133 | "elasticloadbalancing:CreateLoadBalancer", 134 | "elasticloadbalancing:CreateTargetGroup" 135 | ], 136 | "Resource": "*", 137 | "Condition": { 138 | "Null": { 139 | "aws:RequestTag/elbv2.k8s.aws/cluster": "false" 140 | } 141 | } 142 | }, 143 | { 144 | "Effect": "Allow", 145 | "Action": [ 146 | "elasticloadbalancing:CreateListener", 147 | "elasticloadbalancing:DeleteListener", 148 | "elasticloadbalancing:CreateRule", 149 | "elasticloadbalancing:DeleteRule" 150 | ], 151 | "Resource": "*" 152 | }, 153 | { 154 | "Effect": "Allow", 155 | "Action": [ 156 | "elasticloadbalancing:AddTags", 157 | "elasticloadbalancing:RemoveTags" 158 | ], 159 | "Resource": [ 160 | "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", 161 | "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", 162 | "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" 163 | ], 164 | "Condition": { 165 | "Null": { 166 | "aws:RequestTag/elbv2.k8s.aws/cluster": "true", 167 | "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" 168 | } 169 | } 170 | }, 171 | { 172 | "Effect": "Allow", 173 | "Action": [ 174 | "elasticloadbalancing:AddTags", 175 | "elasticloadbalancing:RemoveTags" 176 | ], 177 | "Resource": [ 178 | "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*", 179 | "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", 180 | "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", 181 | "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*" 182 | ] 183 | }, 184 | { 185 | "Effect": "Allow", 186 | "Action": [ 187 | "elasticloadbalancing:ModifyLoadBalancerAttributes", 188 | "elasticloadbalancing:SetIpAddressType", 189 | "elasticloadbalancing:SetSecurityGroups", 190 | "elasticloadbalancing:SetSubnets", 191 | "elasticloadbalancing:DeleteLoadBalancer", 192 | "elasticloadbalancing:ModifyTargetGroup", 193 | "elasticloadbalancing:ModifyTargetGroupAttributes", 194 | "elasticloadbalancing:DeleteTargetGroup", 195 | "elasticloadbalancing:ModifyListenerAttributes", 196 | "elasticloadbalancing:ModifyCapacityReservation" 197 | ], 198 | "Resource": "*", 199 | "Condition": { 200 | "Null": { 201 | "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" 202 | } 203 | } 204 | }, 205 | { 206 | "Effect": "Allow", 207 | "Action": [ 208 | "elasticloadbalancing:AddTags" 209 | ], 210 | "Resource": [ 211 | "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", 212 | "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", 213 | "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" 214 | ], 215 | "Condition": { 216 | "StringEquals": { 217 | "elasticloadbalancing:CreateAction": [ 218 | "CreateTargetGroup", 219 | "CreateLoadBalancer" 220 | ] 221 | }, 222 | "Null": { 223 | "aws:RequestTag/elbv2.k8s.aws/cluster": "false" 224 | } 225 | } 226 | }, 227 | { 228 | "Effect": "Allow", 229 | "Action": [ 230 | "elasticloadbalancing:RegisterTargets", 231 | "elasticloadbalancing:DeregisterTargets" 232 | ], 233 | "Resource": "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" 234 | }, 235 | { 236 | "Effect": "Allow", 237 | "Action": [ 238 | "elasticloadbalancing:SetWebAcl", 239 | "elasticloadbalancing:ModifyListener", 240 | "elasticloadbalancing:AddListenerCertificates", 241 | "elasticloadbalancing:RemoveListenerCertificates", 242 | "elasticloadbalancing:ModifyRule" 243 | ], 244 | "Resource": "*" 245 | } 246 | ] 247 | } 248 | -------------------------------------------------------------------------------- /scripts/ez_cluster_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Functions ================== 4 | 5 | # Function to check if S3 bucket exists and is writable 6 | check_s3_bucket() { 7 | if aws s3api head-bucket --bucket "$BUCKET_NAME" --region "$BE_REGION" 2>/dev/null; then 8 | # Try to write a test file 9 | TEST_FILE="s3://$BUCKET_NAME/test-write-$(date +%s)" 10 | if echo "test" | aws s3 cp - "$TEST_FILE" --region "$BE_REGION" >/dev/null 2>&1; then 11 | aws s3 rm "$TEST_FILE" --region "$BE_REGION" >/dev/null 2>&1 12 | echo "✅ S3 bucket '$BUCKET_NAME' exists and is writable." 13 | else 14 | echo "❌ S3 bucket '$BUCKET_NAME' exists but is NOT writable." 15 | BACKEND_ISOK=false 16 | fi 17 | else 18 | echo "❌ S3 bucket '$BUCKET_NAME' does NOT exist." 19 | BACKEND_ISOK=false 20 | fi 21 | } 22 | 23 | # Function to check if DynamoDB table exists and is writable 24 | check_dynamodb_table() { 25 | if aws dynamodb describe-table --table-name "$DDB_TABLE_NAME" --region "$BE_REGION" >/dev/null 2>&1; then 26 | # Try to write a test item 27 | TEST_ITEM="{\"LockID\": {\"S\": \"test-lock-$(date +%s)\"}}" 28 | if aws dynamodb put-item --table-name "$DDB_TABLE_NAME" --item "$TEST_ITEM" --region "$BE_REGION" >/dev/null 2>&1; then 29 | aws dynamodb delete-item --table-name "$DDB_TABLE_NAME" --key "{\"LockID\": {\"S\": \"test-lock-$(date +%s)\"}}" --region "$BE_REGION" >/dev/null 2>&1 30 | echo "✅ DynamoDB table '$DDB_TABLE_NAME' exists and is writable." 31 | else 32 | echo "❌ DynamoDB table '$DDB_TABLE_NAME' exists but is NOT writable." 33 | BACKEND_ISOK=false 34 | fi 35 | else 36 | echo "❌ DynamoDB table '$DDB_TABLE_NAME' does NOT exist." 37 | BACKEND_ISOK=false 38 | fi 39 | } 40 | 41 | # ==================================== 42 | 43 | IS_SETH=false 44 | 45 | if [[ "$1" == "-seth" || "$1" == "--seth" ]]; then 46 | IS_SETH=true 47 | fi 48 | 49 | ### 50 | # Verify user is targeting the correct AWS account 51 | ### 52 | 53 | echo -e "\n=========================================" 54 | echo "😎 Let's create an Amazon EKS Cluster !!!" 55 | echo -e "=========================================\n" 56 | 57 | # Check if AWS CLI is installed 58 | if ! command -v aws &> /dev/null; then 59 | echo "🫵 AWS CLI is not installed. Please install AWS CLI and try again." 60 | exit 1 61 | fi 62 | 63 | # Check if Terraform is installed 64 | if ! command -v terraform &> /dev/null; then 65 | echo "🫵 Terraform is not installed. Please install Terraform and try again." 66 | exit 1 67 | fi 68 | 69 | 70 | # Get AWS Account ID using STS 71 | AWS_ACCOUNT=$(aws sts get-caller-identity --query "Account" --output text 2>/dev/null) 72 | 73 | # Check if AWS_ACCOUNT is empty (invalid credentials) 74 | if [[ -z "$AWS_ACCOUNT" || "$AWS_ACCOUNT" == "None" ]]; then 75 | echo "There are no valid AWS credentials. Please update your AWS credentials to target the correct AWS account." 76 | exit 1 77 | fi 78 | 79 | # Prompt the user for confirmation 80 | echo -e "\nYour EKS cluster will deploy to AWS account ===> ${AWS_ACCOUNT} <===. Is that what you want?\n" 81 | echo "**** 👀 You MUST ensure this is NOT a production account and is NOT 👀 ****" 82 | echo "**** 👀 currently in use for any business purpose 👀 ****" 83 | echo "**** ****" 84 | echo "**** This script and Terraform will create resources in this account ****" 85 | echo "**** ****" 86 | echo "**** If you are unsure, then do NOT proceed ****" 87 | printf "Proceed? [y/n]: " 88 | read -r response 89 | 90 | 91 | # Check if response is "y" or "yes" 92 | if [[ ! "$response" =~ ^[Yy]([Ee][Ss])?$ ]]; then 93 | echo "👋 Good bye!" 94 | exit 1 95 | fi 96 | 97 | echo "Proceeding with deployment..." 98 | 99 | # Find the terraform directory 100 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)" 101 | REPO_DIR=$(dirname "$SCRIPT_DIR") 102 | INIT_DIR="$REPO_DIR/terraform/init" 103 | TF_DIR="$REPO_DIR/terraform/deploy" 104 | 105 | ### 106 | # Create AWS LBC policy if it does not already exist 107 | ### 108 | cd $INIT_DIR 109 | 110 | echo "Checking AWSLoadBalancerControllerIAMPolicy..." 111 | 112 | # Check AWS account whether IAM policy "AWSLoadBalancerControllerIAMPolicy" exists 113 | if aws iam get-policy --policy-arn "arn:aws:iam::${AWS_ACCOUNT}:policy/AWSLoadBalancerControllerIAMPolicy" >/dev/null 2>&1; then 114 | echo "🗝️ AWSLoadBalancerControllerIAMPolicy already exists." 115 | else 116 | echo "👉 Creating AWSLoadBalancerControllerIAMPolicy..." 117 | terraform init 118 | terraform apply -auto-approve 119 | echo "🗝️ AWSLoadBalancerControllerIAMPolicy created." 120 | fi 121 | 122 | ### 123 | # Verify if backend state is setup and accessible. 124 | ### 125 | 126 | cd $TF_DIR 127 | 128 | # Extract backend configuration from backend.tf 129 | BACKEND_FILE="./backend.tf" 130 | 131 | # Parse S3 bucket name 132 | BUCKET_NAME=$(awk -F'"' '/bucket/{print $2}' "$BACKEND_FILE" | xargs) 133 | DDB_TABLE_NAME=$(awk -F'"' '/dynamodb_table/{print $2}' "$BACKEND_FILE" | xargs) 134 | BE_REGION=$(awk -F'"' '/region/{print $2}' "$BACKEND_FILE" | xargs) 135 | 136 | if [[ -z "$BUCKET_NAME" || -z "$DDB_TABLE_NAME" || -z "$BE_REGION" ]]; then 137 | echo "❌ Error: Unable to parse backend configuration from $BACKEND_FILE" 138 | exit 1 139 | elif [[ "$IS_SETH" == "false" && "$BUCKET_NAME" == "terraform-state-bucket-eks-demo-uniqueid" ]]; then 140 | echo "❌ Error: Please update the backend configuration in $BACKEND_FILE with a UNIQUE bucket name." 141 | exit 1 142 | else 143 | echo "✅ Backend configuration parsed successfully." 144 | echo "🪣 S3 bucket name: $BUCKET_NAME" 145 | echo "📋 DynamoDB table name: $DDB_TABLE_NAME" 146 | echo "🌎 Region: $BE_REGION - Used for backend state (where S3 bucket and DynamoDb table are)" 147 | echo " Actual EKS cluster region may be different" 148 | fi 149 | 150 | 151 | # Run checks of backend state 152 | echo "=========================" 153 | echo "Checking backend state..." 154 | 155 | BACKEND_ISOK=true 156 | 157 | check_s3_bucket 158 | check_dynamodb_table 159 | 160 | if [[ "$BACKEND_ISOK" == "false" ]]; then 161 | echo "=================================================" 162 | echo "❌ Backend state is NOT setup correctly. Please update the backend configuration in $BACKEND_FILE." 163 | echo "👉 You need to create a S3 bucket and a DynamoDB table in the same region as the EKS cluster." 164 | echo "👉 Then you need to update $BACKEND_FILE with the new S3 bucket name" 165 | echo "👉 Instructions are in the comments section of $BACKEND_FILE." 166 | echo "=================================================" 167 | 168 | exit 1 169 | fi 170 | 171 | 172 | echo "✅ All checks of Terraform backend state passed!" 173 | 174 | ### 175 | # Deploy the cluster 176 | ### 177 | 178 | echo "=========================" 179 | echo "Deploying EKS cluster..." 180 | 181 | # List all *.tfvars files in ./environment/ with numbered options 182 | ENV_DIR="./environment" 183 | TFVARS_FILES=($(ls -1 "$ENV_DIR"/*.tfvars 2>/dev/null)) # Store files in an array 184 | 185 | # Check if there are any .tfvars files 186 | if [[ ${#TFVARS_FILES[@]} -eq 0 ]]; then 187 | echo "❌ No .tfvars files found in $ENV_DIR. Please add environment files and try again." 188 | exit 1 189 | fi 190 | 191 | # Display the available environment files with numbers 192 | echo "Available environments:" 193 | for i in "${!TFVARS_FILES[@]}"; do 194 | echo "$((i+1)). ${TFVARS_FILES[$i]##*/}" # Show just the filename 195 | done 196 | 197 | # Prompt the user to select an environment 198 | printf "Select a number: " 199 | read -r choice 200 | 201 | 202 | # Validate user input 203 | if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#TFVARS_FILES[@]} )); then 204 | echo "❌ Invalid selection. Please enter a valid number." 205 | exit 1 206 | fi 207 | 208 | # Get the selected tfvars file 209 | TFVARS_FILE="${TFVARS_FILES[$((choice-1))]}" 210 | 211 | # Extract env_name from the selected file 212 | ENV_NAME=$(awk -F'"' '/env_name/ {print $2}' "$TFVARS_FILE" | xargs) 213 | REGION=$(awk -F'"' '/aws_region/ {print $2}' "$TFVARS_FILE" | xargs) 214 | 215 | if [[ -z "$ENV_NAME" ]]; then 216 | echo "❌ Could not extract env_name from $TFVARS_FILE. Ensure the file is correctly formatted." 217 | exit 1 218 | elif [[ -z "$REGION" ]]; then 219 | echo "❌ Could not extract aws_region from $TFVARS_FILE. Ensure the file is correctly formatted." 220 | exit 1 221 | fi 222 | 223 | echo "✅ Selected environment [$ENV_NAME] to deploy to AWS Region [$REGION] (from $(basename "$TFVARS_FILE"))" 224 | printf "Is this correct? [y/n]: " 225 | read -r response 226 | 227 | 228 | # Check if response is "y" or "yes" 229 | if [[ ! "$response" =~ ^[Yy]([Ee][Ss])?$ ]]; then 230 | echo "🛑 Please check your $TFVARS_FILE configuration and try again." 231 | exit 1 232 | fi 233 | 234 | echo "🏃 Running terraform init..." 235 | if ! terraform init 2> terraform_init_err.log; then 236 | if grep -q "Error refreshing state: state data in S3 does not have the expected content." terraform_init_err.log; then 237 | echo "👍 Ignoring known state data error and continuing..." 238 | elif grep -q "Backend configuration changed" terraform_init_err.log; then 239 | if ! terraform init -reconfigure 2>> terraform_init_err.log; then 240 | echo "❌ Error occurred during terraform init -reconfigure. Exiting." 241 | echo "👉 Check $TF_DIR/terraform_init_err.log for more details." 242 | exit 1 243 | fi 244 | else 245 | echo "❌ Unexpected error occurred. Exiting." 246 | echo "👉 Check $TF_DIR/terraform_init_err.log for more details." 247 | exit 1 248 | fi 249 | fi 250 | 251 | echo "✅ terraform init completed successfully." 252 | 253 | if [ -f terraform_init_err.log ]; then 254 | rm terraform_init_err.log 255 | fi 256 | 257 | # Check the current Terraform workspace 258 | CURRENT_WS=$(terraform workspace show 2>/dev/null) 259 | 260 | if [[ "$CURRENT_WS" != "$ENV_NAME" ]]; then 261 | echo "🔄 Switching to Terraform workspace: $ENV_NAME" 262 | 263 | # Check if the workspace exists 264 | if ! terraform workspace select "$ENV_NAME" 2>/dev/null; then 265 | echo "🔄 Workspace '$ENV_NAME' does not exist. Creating it..." 266 | terraform workspace new "$ENV_NAME" 267 | fi 268 | fi 269 | 270 | # Run Terraform apply 271 | echo "🚀 Running Terraform apply..." 272 | terraform apply -auto-approve -var-file="$TFVARS_FILE" 273 | 274 | ##### 275 | # Get the ALB URL 276 | 277 | echo -e "\n==========================" 278 | # Wait for 10 seconds 279 | echo -n "🔄 Getting ALB URL. Please stand by..." 280 | for i in {1..10}; do 281 | echo -n "." 282 | sleep 1 283 | done 284 | echo "" 285 | 286 | 287 | # Run terraform apply and capture the output 288 | OUTPUT=$(terraform apply -var-file="$TFVARS_FILE" -target="module.alb" -auto-approve) 289 | 290 | # Extract the value of alb_dns_name 291 | # ALB_DNS_NAME=$(echo "$OUTPUT" | grep -oP '(?<=alb_dns_name = \").*?(?=\")') 292 | ALB_DNS_NAME=$(echo "$OUTPUT" | awk -F ' = "' '/alb_dns_name/ {gsub(/"/, "", $2); print $2}') 293 | 294 | # This may include multiple lines, so extract the URL 295 | URL=$(echo "$ALB_DNS_NAME" | grep -oE '[a-zA-Z0-9.-]+\.elb\.amazonaws\.com' | head -n1) 296 | 297 | # If nothing found then this is an error 298 | if [ -z "$ALB_DNS_NAME" ]; then 299 | echo "❌ Error: Cannot find Application Load Balancer URL." 300 | exit 1 301 | # If not URL and ALB still processing, then all is well, but do not have URL yet 302 | elif [[ -z "$URL" && "$ALB_DNS_NAME" == *"ALB is still provisioning"* ]]; then 303 | echo "⏳ The URL for your application is not ready yet..." 304 | exit 1 305 | # Output the URL 306 | else 307 | echo "⭐️ Here is the URL of you newly deployed application running on EKS:" 308 | echo "💻 http://$URL " 309 | echo "⏳ Please be patient. It may take up to a minute to become available" 310 | fi 311 | --------------------------------------------------------------------------------