diff --git a/.gitignore b/.gitignore index f199469c..48a1d6a7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ contexts/**/.talos/ contexts/**/.aws/ contexts/**/.omni/ contexts/**/.azure/ + +# macOS system files +**/.DS_Store diff --git a/contexts/aws-example/blueprint.yaml b/contexts/aws-example/blueprint.yaml new file mode 100644 index 00000000..cdda6557 --- /dev/null +++ b/contexts/aws-example/blueprint.yaml @@ -0,0 +1,93 @@ +kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-example + description: This blueprint outlines resources in the aws-example context +repository: + url: https://github.com/windsorcli/core + ref: + branch: main + secretName: flux-system +sources: +- name: core + url: github.com/windsorcli/core + ref: + branch: main +terraform: +- path: network/aws-vpc +- path: cluster/aws-eks +- path: gitops/flux +kustomize: +- name: telemetry-base + path: telemetry/base + source: core + components: + - prometheus + - prometheus/flux +- name: telemetry-resources + path: telemetry/resources + source: core + dependsOn: + - telemetry-base + components: + - metrics-server + - prometheus + - prometheus/flux +- name: policy-base + path: policy/base + source: core + components: + - kyverno +- name: policy-resources + path: policy/resources + source: core + dependsOn: + - policy-base +- name: ingress-base + path: ingress/base + source: core + dependsOn: + - pki-resources + force: true + components: + - nginx + - nginx/flux-webhook + - nginx/web +- name: pki-base + path: pki/base + source: core + dependsOn: + - policy-resources + force: true + components: + - cert-manager + - trust-manager +- name: pki-resources + path: pki/resources + source: core + dependsOn: + - pki-base + force: true + components: + - private-issuer/ca + - public-issuer/selfsigned +- name: gitops + path: gitops/flux + source: core + dependsOn: + - ingress-base + force: true + components: + - webhook +- name: observability + path: observability + source: core + dependsOn: + - ingress-base + components: + - grafana + - grafana/ingress + - grafana/prometheus + - grafana/node + - grafana/kubernetes + - grafana/flux diff --git a/contexts/aws-example/terraform/cluster/aws-eks.tfvars b/contexts/aws-example/terraform/cluster/aws-eks.tfvars new file mode 100644 index 00000000..84aa3876 --- /dev/null +++ b/contexts/aws-example/terraform/cluster/aws-eks.tfvars @@ -0,0 +1 @@ +// Managed by Windsor CLI: This file is partially managed by the windsor CLI. Your changes will not be overwritten. diff --git a/contexts/aws-example/terraform/gitops/flux.tfvars b/contexts/aws-example/terraform/gitops/flux.tfvars new file mode 100644 index 00000000..84aa3876 --- /dev/null +++ b/contexts/aws-example/terraform/gitops/flux.tfvars @@ -0,0 +1 @@ +// Managed by Windsor CLI: This file is partially managed by the windsor CLI. Your changes will not be overwritten. diff --git a/contexts/aws-example/terraform/network/aws-vpc.tfvars b/contexts/aws-example/terraform/network/aws-vpc.tfvars new file mode 100644 index 00000000..84aa3876 --- /dev/null +++ b/contexts/aws-example/terraform/network/aws-vpc.tfvars @@ -0,0 +1 @@ +// Managed by Windsor CLI: This file is partially managed by the windsor CLI. Your changes will not be overwritten. diff --git a/terraform/cluster/aws-eks/.terraform.lock.hcl b/terraform/cluster/aws-eks/.terraform.lock.hcl new file mode 100644 index 00000000..78476919 --- /dev/null +++ b/terraform/cluster/aws-eks/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.97.0" + constraints = "~> 5.0" + hashes = [ + "h1:rUDE0OgA+6IiEA+w0cPp3/QQNH4SpjFjYcQ6p7byKS4=", + "zh:02790ad98b767d8f24d28e8be623f348bcb45590205708334d52de2fb14f5a95", + "zh:088b4398a161e45762dc28784fcc41c4fa95bd6549cb708b82de577f2d39ffc7", + "zh:0c381a457b7af391c43fc0167919443f6105ad2702bde4d02ddea9fd7c9d3539", + "zh:1a4b57a5043dcca64d8b8bae8b30ef4f6b98ed2144f792f39c4e816d3f1e2c56", + "zh:1bf00a67f39e67664337bde065180d41d952242801ebcd1c777061d4ffaa1cc1", + "zh:24c549f53d6bd022af31426d3e78f21264d8a72409821669e7fd41966ae68b2b", + "zh:3abda50bbddb35d86081fe39522e995280aea7f004582c4af22112c03ac8b375", + "zh:7388ed7f21ce2eb46bd9066626ce5f3e2a5705f67f643acce8ae71972f66eaf6", + "zh:96740f2ff94e5df2b2d29a5035a1a1026fe821f61712b2099b224fb2c2277663", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f399f8e8683a3a3a6d63a41c7c3a5a5f266eedef40ea69eba75bacf03699879", + "zh:bcf2b288d4706ebd198f75d2159663d657535483331107f2cdef381f10688baf", + "zh:cc76c8a9fc3bad05a8779c1f80fe8c388734f1ec1dd0affa863343490527b466", + "zh:de4359cf1b057bfe7a563be93829ec64bf72e7a2b85a72d075238081ef5eb1db", + "zh:e208fa77051a1f9fa1eff6c5c58aabdcab0de1695b97cdea7b8dd81df3e0ed73", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.5.2" + hashes = [ + "h1:IyFbOIO6mhikFNL/2h1iZJ6kyN3U00jgkpCLUCThAfE=", + "zh:136299545178ce281c56f36965bf91c35407c11897f7082b3b983d86cb79b511", + "zh:3b4486858aa9cb8163378722b642c57c529b6c64bfbfc9461d940a84cd66ebea", + "zh:4855ee628ead847741aa4f4fc9bed50cfdbf197f2912775dd9fe7bc43fa077c0", + "zh:4b8cd2583d1edcac4011caafe8afb7a95e8110a607a1d5fb87d921178074a69b", + "zh:52084ddaff8c8cd3f9e7bcb7ce4dc1eab00602912c96da43c29b4762dc376038", + "zh:71562d330d3f92d79b2952ffdda0dad167e952e46200c767dd30c6af8d7c0ed3", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:805f81ade06ff68fa8b908d31892eaed5c180ae031c77ad35f82cb7a74b97cf4", + "zh:8b6b3ebeaaa8e38dd04e56996abe80db9be6f4c1df75ac3cccc77642899bd464", + "zh:ad07750576b99248037b897de71113cc19b1a8d0bc235eb99173cc83d0de3b1b", + "zh:b9f1c3bfadb74068f5c205292badb0661e17ac05eb23bfe8bd809691e4583d0e", + "zh:cc4cbcd67414fefb111c1bf7ab0bc4beb8c0b553d01719ad17de9a047adff4d1", + ] +} diff --git a/terraform/cluster/aws-eks/main.tf b/terraform/cluster/aws-eks/main.tf new file mode 100644 index 00000000..134965d1 --- /dev/null +++ b/terraform/cluster/aws-eks/main.tf @@ -0,0 +1,666 @@ +// Define the required Terraform version and providers +terraform { + required_version = ">=1.8" + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.97.0" + } + } +} + +locals { + name = var.cluster_name != "" ? var.cluster_name : "cluster-${var.context_id}" +} + +#----------------------------------------------------------------------------------------------------------------------- +# Data +#----------------------------------------------------------------------------------------------------------------------- + +data "aws_vpc" "default" { + count = var.vpc_id == null ? 1 : 0 + filter { + name = "tag:WindsorContextID" + values = [var.context_id] + } +} + +data "aws_subnets" "private" { + filter { + name = "tag:Tier" + values = ["private"] + } + filter { + name = "vpc-id" + values = [var.vpc_id != null ? var.vpc_id : data.aws_vpc.default[0].id] + } +} + +data "aws_region" "current" {} + +#----------------------------------------------------------------------------------------------------------------------- +# EKS Cluster +#----------------------------------------------------------------------------------------------------------------------- +resource "aws_eks_cluster" "this" { + # checkov:skip=CKV_AWS_38: Public access set via a variable. + # checkov:skip=CKV_AWS_39: Public access set via a variable. + name = local.name + role_arn = aws_iam_role.cluster.arn + version = var.kubernetes_version + + vpc_config { + subnet_ids = data.aws_subnets.private.ids + endpoint_private_access = true + endpoint_public_access = var.endpoint_public_access + security_group_ids = [aws_security_group.cluster_api_access.id] + } + + # Enable secrets encryption using AWS KMS + encryption_config { + provider { + key_arn = aws_kms_key.eks_encryption_key.arn + } + resources = ["secrets"] + } + + # Enable control plane logging for all log types + enabled_cluster_log_types = [ + "api", + "audit", + "authenticator", + "controllerManager", + "scheduler" + ] + + depends_on = [ + aws_iam_role_policy_attachment.cluster_AmazonEKSClusterPolicy, + aws_iam_role_policy_attachment.cluster_AmazonEKSVPCResourceController, + aws_kms_key.eks_encryption_key, + ] +} + +resource "aws_security_group" "cluster_api_access" { + name = "${local.name}-cluster-api-access" + description = "Security group for EKS cluster API access" + vpc_id = data.aws_vpc.default[0].id + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [var.cluster_api_access_cidr_block] + description = "Allow K8s API access from the specified CIDR block" + } +} + +resource "aws_kms_key" "eks_encryption_key" { + description = "KMS key for EKS cluster ${local.name} secrets encryption" + deletion_window_in_days = 7 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Sid = "Enable IAM User Permissions", + Effect = "Allow", + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + }, + Action = "kms:*", + Resource = "*" + }, + { + Sid = "Allow EKS to use the key for secrets encryption", + Effect = "Allow", + Principal = { + Service = "eks.amazonaws.com" + }, + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ], + Resource = "*" + } + ] + }) +} + +data "aws_caller_identity" "current" {} + +#----------------------------------------------------------------------------------------------------------------------- +# IAM Roles +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_iam_role" "cluster" { + name = "${local.name}-cluster" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "eks.amazonaws.com" + } + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSClusterPolicy" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" + role = aws_iam_role.cluster.name +} + +resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSVPCResourceController" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController" + role = aws_iam_role.cluster.name +} + +resource "aws_iam_role" "node_group" { + name = "${local.name}-node-group" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "node_group_AmazonEKSWorkerNodePolicy" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy" + role = aws_iam_role.node_group.name +} + +resource "aws_iam_role_policy_attachment" "node_group_AmazonEKS_CNI_Policy" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy" + role = aws_iam_role.node_group.name +} + +resource "aws_iam_role_policy_attachment" "node_group_AmazonEC2ContainerRegistryReadOnly" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" + role = aws_iam_role.node_group.name +} + +#----------------------------------------------------------------------------------------------------------------------- +# Node Groups +#----------------------------------------------------------------------------------------------------------------------- +resource "aws_eks_node_group" "this" { + for_each = var.node_groups + + cluster_name = aws_eks_cluster.this.name + node_group_name = each.key + node_role_arn = aws_iam_role.node_group.arn + subnet_ids = data.aws_subnets.private.ids + instance_types = each.value.instance_types + + scaling_config { + desired_size = each.value.desired_size + max_size = each.value.max_size + min_size = each.value.min_size + } + + dynamic "taint" { + for_each = each.value.taints + content { + key = taint.value.key + value = taint.value.value + effect = taint.value.effect + } + } + + labels = each.value.labels + + # Set max pods per node to 64 + launch_template { + name = aws_launch_template.node_group[each.key].name + version = aws_launch_template.node_group[each.key].latest_version + } + + depends_on = [ + aws_iam_role_policy_attachment.node_group_AmazonEKSWorkerNodePolicy, + aws_iam_role_policy_attachment.node_group_AmazonEKS_CNI_Policy, + aws_iam_role_policy_attachment.node_group_AmazonEC2ContainerRegistryReadOnly, + ] +} + +resource "aws_launch_template" "node_group" { + for_each = var.node_groups + + name = "${local.name}-${each.key}" + + block_device_mappings { + device_name = "/dev/xvda" + ebs { + volume_size = each.value.disk_size + volume_type = "gp3" + delete_on_termination = true + } + } + + network_interfaces { + associate_public_ip_address = false + delete_on_termination = true + security_groups = [aws_eks_cluster.this.vpc_config[0].cluster_security_group_id] + } + + # Disable IMDSv1 and require IMDSv2 + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + http_put_response_hop_limit = 1 + instance_metadata_tags = "enabled" + } + + user_data = base64encode(<<-EOT +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="==BOUNDARY==" + +--==BOUNDARY== +Content-Type: text/x-shellscript; charset="us-ascii" + +#!/bin/bash +set -o xtrace +/etc/eks/bootstrap.sh ${aws_eks_cluster.this.name} --use-max-pods false --kubelet-extra-args '--max-pods=110' + +--==BOUNDARY==-- +EOT + ) +} + +#----------------------------------------------------------------------------------------------------------------------- +# Fargate Profile +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_iam_role" "fargate" { + name = "${local.name}-fargate-profile" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "eks-fargate-pods.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "fargate_pod_execution_role_policy" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy" + role = aws_iam_role.fargate.name +} + +resource "aws_eks_fargate_profile" "this" { + for_each = var.fargate_profiles != null ? var.fargate_profiles : {} + + cluster_name = aws_eks_cluster.this.name + fargate_profile_name = each.key + pod_execution_role_arn = aws_iam_role.fargate.arn + subnet_ids = data.aws_subnets.private.ids + + dynamic "selector" { + for_each = each.value.selectors + content { + namespace = selector.value.namespace + labels = lookup(selector.value, "labels", null) + } + } + + tags = lookup(each.value, "tags", {}) +} + +#----------------------------------------------------------------------------------------------------------------------- +# Add-On Versions +#----------------------------------------------------------------------------------------------------------------------- + +data "aws_eks_addon_version" "default" { + for_each = var.addons + + addon_name = each.key + kubernetes_version = aws_eks_cluster.this.version + most_recent = true +} + +#----------------------------------------------------------------------------------------------------------------------- +# VPC CNI IAM Role +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_iam_role" "vpc_cni" { + count = contains(keys(var.addons), "vpc-cni") ? 1 : 0 + name = "${local.name}-vpc-cni" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:TagSession"] + Effect = "Allow" + Principal = { + Service = "pods.eks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${local.name}-vpc-cni" + } +} + +resource "aws_iam_role_policy_attachment" "vpc_cni" { + count = contains(keys(var.addons), "vpc-cni") ? 1 : 0 + policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy" + role = aws_iam_role.vpc_cni[0].name +} + + +#----------------------------------------------------------------------------------------------------------------------- +# EBS CSI Driver IAM Role +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_iam_role" "ebs_csi" { + count = contains(keys(var.addons), "aws-ebs-csi-driver") ? 1 : 0 + name = "${local.name}-aws-ebs-csi-driver" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:TagSession"] + Effect = "Allow" + Principal = { + Service = "pods.eks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${local.name}-aws-ebs-csi-driver" + } +} + +resource "aws_iam_role_policy_attachment" "ebs_csi" { + count = contains(keys(var.addons), "aws-ebs-csi-driver") ? 1 : 0 + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy" + role = aws_iam_role.ebs_csi[0].name +} + +#----------------------------------------------------------------------------------------------------------------------- +# EFS CSI Driver IAM Role +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_iam_role" "efs_csi" { + count = contains(keys(var.addons), "aws-efs-csi-driver") ? 1 : 0 + name = "${local.name}-aws-efs-csi-driver" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:TagSession"] + Effect = "Allow" + Principal = { + Service = "pods.eks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${local.name}-aws-efs-csi-driver" + } +} + +resource "aws_iam_role_policy_attachment" "efs_csi" { + count = contains(keys(var.addons), "aws-efs-csi-driver") ? 1 : 0 + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy" + role = aws_iam_role.efs_csi[0].name +} + +#----------------------------------------------------------------------------------------------------------------------- +# Pod Identity Agent IAM Role +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_iam_role" "pod_identity_agent" { + count = contains(keys(var.addons), "pod-identity-agent") ? 1 : 0 + name = "${local.name}-pod-identity-agent" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "eks.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = { + Name = "${local.name}-pod-identity-agent" + } +} + +resource "aws_iam_policy" "pod_identity_agent" { + count = contains(keys(var.addons), "pod-identity-agent") ? 1 : 0 + name = "${local.name}-pod-identity-agent" + description = "IAM policy for EKS Pod Identity Agent" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "iam:CreateServiceLinkedRole" + ] + Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/eks-auth.amazonaws.com" + Condition = { + StringEquals = { + "iam:AWSServiceName" = "eks-auth.amazonaws.com" + } + } + } + ] + }) + + tags = { + Name = "${local.name}-pod-identity-agent" + } +} + +resource "aws_iam_role_policy_attachment" "pod_identity_agent" { + count = contains(keys(var.addons), "pod-identity-agent") ? 1 : 0 + policy_arn = aws_iam_policy.pod_identity_agent[0].arn + role = aws_iam_role.pod_identity_agent[0].name +} + + +#----------------------------------------------------------------------------------------------------------------------- +# External DNS IAM Role +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_iam_role" "external_dns" { + count = contains(keys(var.addons), "external-dns") ? 1 : 0 + name = "${local.name}-external-dns" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:TagSession"] + Effect = "Allow" + Principal = { + Service = "pods.eks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${local.name}-external-dns" + } +} + +resource "aws_iam_policy" "external_dns" { + # This policy is based on the official External DNS documentation for AWS + # https://kubernetes-sigs.github.io/external-dns/v0.17.0/docs/tutorials/aws/#iam-policy + # checkov:skip=CKV_AWS_355: This policy is straight from the External DNS documentation + count = contains(keys(var.addons), "external-dns") ? 1 : 0 + name = "${local.name}-external-dns" + description = "IAM policy for External DNS" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "route53:ChangeResourceRecordSets" + ] + Resource = [ + "arn:aws:route53:::hostedzone/*" + ] + }, + { + Effect = "Allow" + Action = [ + "route53:ListHostedZones", + "route53:ListResourceRecordSets", + "route53:ListTagsForResource" + ] + Resource = [ + "*" + ] + } + ] + }) + + tags = { + Name = "${local.name}-external-dns" + } +} + +resource "aws_iam_role_policy_attachment" "external_dns" { + count = contains(keys(var.addons), "external-dns") ? 1 : 0 + policy_arn = aws_iam_policy.external_dns[0].arn + role = aws_iam_role.external_dns[0].name +} + +#----------------------------------------------------------------------------------------------------------------------- +# Create Add-Ons +#----------------------------------------------------------------------------------------------------------------------- + +locals { + addon_configuration = { + for name, addon in var.addons : name => { + version = lookup(addon, "version", data.aws_eks_addon_version.default[name].version) + role_arn = ( + name == "vpc-cni" ? try(aws_iam_role.vpc_cni[0].arn, null) : + name == "aws-ebs-csi-driver" ? try(aws_iam_role.ebs_csi[0].arn, null) : + name == "aws-efs-csi-driver" ? try(aws_iam_role.efs_csi[0].arn, null) : + name == "eks-pod-identity-agent" ? try(aws_iam_role.pod_identity_agent[0].arn, null) : + name == "external-dns" ? try(aws_iam_role.external_dns[0].arn, null) : + null + ) + service_account_name = ( + name == "vpc-cni" ? "aws-node" : + name == "aws-ebs-csi-driver" ? "ebs-csi-controller-sa" : + name == "aws-efs-csi-driver" ? "efs-csi-controller-sa" : + name == "eks-pod-identity-agent" ? "pod-identity-agent" : + name == "external-dns" ? "external-dns" : + null + ) + tags = lookup(addon, "tags", {}) + } + } +} + +resource "aws_eks_addon" "this" { + for_each = var.addons + + cluster_name = aws_eks_cluster.this.name + addon_name = each.key + addon_version = local.addon_configuration[each.key].version + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + service_account_role_arn = ( + each.key == "eks-pod-identity-agent" ? local.addon_configuration[each.key].role_arn : null + ) + + # Configure VPC CNI to allow more max pods per node + configuration_values = each.key == "vpc-cni" ? jsonencode({ + env = { + ENABLE_PREFIX_DELEGATION = tostring(var.vpc_cni_config.enable_prefix_delegation) + WARM_PREFIX_TARGET = tostring(var.vpc_cni_config.warm_prefix_target) + WARM_IP_TARGET = tostring(var.vpc_cni_config.warm_ip_target) + MINIMUM_IP_TARGET = tostring(var.vpc_cni_config.minimum_ip_target) + } + }) : null + + dynamic "pod_identity_association" { + for_each = ( + each.key != "eks-pod-identity-agent" && + local.addon_configuration[each.key].role_arn != null + ) ? [1] : [] + content { + role_arn = local.addon_configuration[each.key].role_arn + service_account = local.addon_configuration[each.key].service_account_name + } + } + tags = local.addon_configuration[each.key].tags +} + +#----------------------------------------------------------------------------------------------------------------------- +# Kubeconfig +#----------------------------------------------------------------------------------------------------------------------- + +locals { + kubeconfig_path = "${var.context_path}/.kube/config" +} + +# Create the kubeconfig directory if it doesn't exist +resource "null_resource" "create_kubeconfig_dir" { + count = local.kubeconfig_path != "" ? 1 : 0 + + provisioner "local-exec" { + command = "mkdir -p $(dirname ${local.kubeconfig_path})" + } +} + + +resource "local_sensitive_file" "kubeconfig" { + count = local.kubeconfig_path != "" ? 1 : 0 + + content = templatefile("${path.module}/templates/kubeconfig.tpl", { + cluster_name = aws_eks_cluster.this.name + cluster_endpoint = aws_eks_cluster.this.endpoint + cluster_ca = aws_eks_cluster.this.certificate_authority[0].data + region = data.aws_region.current.name + }) + filename = local.kubeconfig_path + file_permission = "0600" + + lifecycle { + ignore_changes = [content] // Ignore changes to content to prevent unnecessary updates + } +} diff --git a/terraform/cluster/aws-eks/outputs.tf b/terraform/cluster/aws-eks/outputs.tf new file mode 100644 index 00000000..730e5ef0 --- /dev/null +++ b/terraform/cluster/aws-eks/outputs.tf @@ -0,0 +1,28 @@ +#----------------------------------------------------------------------------------------------------------------------- +# Outputs +#----------------------------------------------------------------------------------------------------------------------- + +# output "cluster_id" { +# description = "The name/id of the EKS cluster." +# value = aws_eks_cluster.this.id +# } + +# output "cluster_arn" { +# description = "The Amazon Resource Name (ARN) of the cluster." +# value = aws_eks_cluster.this.arn +# } + +# output "cluster_endpoint" { +# description = "The endpoint for the Kubernetes API server." +# value = aws_eks_cluster.this.endpoint +# } + +# output "cluster_security_group_id" { +# description = "The security group ID attached to the EKS cluster." +# value = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id +# } + +# output "kubeconfig_certificate_authority_data" { +# description = "The base64 encoded certificate data required to communicate with the cluster." +# value = aws_eks_cluster.this.certificate_authority[0].data +# } diff --git a/terraform/cluster/aws-eks/templates/kubeconfig.tpl b/terraform/cluster/aws-eks/templates/kubeconfig.tpl new file mode 100644 index 00000000..9354987d --- /dev/null +++ b/terraform/cluster/aws-eks/templates/kubeconfig.tpl @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Config +clusters: +- name: ${cluster_name} + cluster: + server: ${cluster_endpoint} + certificate-authority-data: ${cluster_ca} +contexts: +- name: ${cluster_name} + context: + cluster: ${cluster_name} + user: ${cluster_name} +current-context: ${cluster_name} +users: +- name: ${cluster_name} + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: aws + args: + - eks + - get-token + - --cluster-name + - ${cluster_name} + - --region + - ${region} diff --git a/terraform/cluster/aws-eks/variables.tf b/terraform/cluster/aws-eks/variables.tf new file mode 100644 index 00000000..c2b768f4 --- /dev/null +++ b/terraform/cluster/aws-eks/variables.tf @@ -0,0 +1,128 @@ + +#----------------------------------------------------------------------------------------------------------------------- +# Variables +#----------------------------------------------------------------------------------------------------------------------- + +variable "context_path" { + type = string + description = "The path to the context folder, where kubeconfig is stored" + default = "" +} + +variable "context_id" { + type = string + description = "The windsor context id for this deployment" + default = "" +} + +variable "cluster_name" { + description = "The name of the EKS cluster." + type = string + default = "" +} + +variable "kubernetes_version" { + description = "The kubernetes version to deploy." + type = string + # renovate: datasource=github-releases depName=kubernetes package=kubernetes/kubernetes + default = "1.32" + validation { + condition = can(regex("^1\\.\\d+\\$", var.kubernetes_version)) + error_message = "The Kubernetes version should be in version format like '1.32'." + } +} + + +variable "endpoint_public_access" { + description = "Whether to enable public access to the EKS cluster." + type = bool + default = true +} + +variable "cluster_api_access_cidr_block" { + description = "The CIDR block for the cluster API access." + type = string + default = "0.0.0.0/0" +} + +variable "vpc_id" { + description = "The ID of the VPC where the EKS cluster will be created." + type = string + default = null +} + +variable "node_groups" { + description = "Map of EKS managed node group definitions to create." + type = map(object({ + instance_types = list(string) + min_size = number + max_size = number + desired_size = number + disk_size = optional(number, 64) + labels = optional(map(string), {}) + taints = optional(list(object({ + key = string + value = string + effect = string + })), []) + })) + default = { + default = { + instance_types = ["t3.medium"] + min_size = 1 + max_size = 3 + desired_size = 2 + } + } +} + +variable "max_pods_per_node" { + description = "Maximum number of pods that can run on a single node" + type = number + default = 64 +} + +variable "vpc_cni_config" { + description = "Configuration for the VPC CNI addon" + type = object({ + enable_prefix_delegation = bool + warm_prefix_target = number + warm_ip_target = number + minimum_ip_target = number + }) + default = { + enable_prefix_delegation = true + warm_prefix_target = 1 + warm_ip_target = 1 + minimum_ip_target = 1 + } +} + + +variable "fargate_profiles" { + description = "Map of EKS Fargate profile definitions to create." + type = map(object({ + selectors = list(object({ + namespace = string + labels = optional(map(string), {}) + })) + tags = optional(map(string), {}) + })) + default = {} +} + +variable "addons" { + description = "Map of EKS add-ons" + type = map(object({ + version = optional(string) + tags = optional(map(string), {}) + })) + default = { + vpc-cni = {} + aws-efs-csi-driver = {} + aws-ebs-csi-driver = {} + eks-pod-identity-agent = {} + coredns = {} + external-dns = {} + } +} diff --git a/terraform/network/aws-vpc/.terraform.lock.hcl b/terraform/network/aws-vpc/.terraform.lock.hcl new file mode 100644 index 00000000..e6762f06 --- /dev/null +++ b/terraform/network/aws-vpc/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.97.0" + constraints = "~> 5.0" + hashes = [ + "h1:rUDE0OgA+6IiEA+w0cPp3/QQNH4SpjFjYcQ6p7byKS4=", + "zh:02790ad98b767d8f24d28e8be623f348bcb45590205708334d52de2fb14f5a95", + "zh:088b4398a161e45762dc28784fcc41c4fa95bd6549cb708b82de577f2d39ffc7", + "zh:0c381a457b7af391c43fc0167919443f6105ad2702bde4d02ddea9fd7c9d3539", + "zh:1a4b57a5043dcca64d8b8bae8b30ef4f6b98ed2144f792f39c4e816d3f1e2c56", + "zh:1bf00a67f39e67664337bde065180d41d952242801ebcd1c777061d4ffaa1cc1", + "zh:24c549f53d6bd022af31426d3e78f21264d8a72409821669e7fd41966ae68b2b", + "zh:3abda50bbddb35d86081fe39522e995280aea7f004582c4af22112c03ac8b375", + "zh:7388ed7f21ce2eb46bd9066626ce5f3e2a5705f67f643acce8ae71972f66eaf6", + "zh:96740f2ff94e5df2b2d29a5035a1a1026fe821f61712b2099b224fb2c2277663", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f399f8e8683a3a3a6d63a41c7c3a5a5f266eedef40ea69eba75bacf03699879", + "zh:bcf2b288d4706ebd198f75d2159663d657535483331107f2cdef381f10688baf", + "zh:cc76c8a9fc3bad05a8779c1f80fe8c388734f1ec1dd0affa863343490527b466", + "zh:de4359cf1b057bfe7a563be93829ec64bf72e7a2b85a72d075238081ef5eb1db", + "zh:e208fa77051a1f9fa1eff6c5c58aabdcab0de1695b97cdea7b8dd81df3e0ed73", + ] +} diff --git a/terraform/network/aws-vpc/main.tf b/terraform/network/aws-vpc/main.tf new file mode 100644 index 00000000..d998c13b --- /dev/null +++ b/terraform/network/aws-vpc/main.tf @@ -0,0 +1,301 @@ +// Define the required Terraform version and providers +terraform { + required_version = ">=1.8" + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.97.0" + } + } +} + +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + +locals { + name = var.name != "" ? var.name : "network-${var.context_id}" +} + +#----------------------------------------------------------------------------------------------------------------------- +# AWS VPC Configuration +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_vpc" "main" { + cidr_block = var.cidr_block + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = local.name + WindsorContextID = var.context_id + } +} + +resource "aws_default_security_group" "default" { + vpc_id = aws_vpc.main.id + + # Ensure no ingress rules are defined (restricting all inbound traffic) + ingress = [] + + # Ensure no egress rules are defined (restricting all outbound traffic) + egress = [] + + tags = { + Name = "${local.name}-default" + Description = "Default security group with all traffic restricted" + } +} + +# Enable VPC Flow Logs +resource "aws_flow_log" "vpc_flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + log_destination = aws_cloudwatch_log_group.vpc_flow_logs.arn + log_destination_type = "cloud-watch-logs" + traffic_type = "ALL" + vpc_id = aws_vpc.main.id + iam_role_arn = aws_iam_role.vpc_flow_logs[0].arn +} + +resource "aws_cloudwatch_log_group" "vpc_flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "/aws/vpc-flow-logs/${local.name}" + retention_in_days = 365 + kms_key_id = var.create_flow_logs_kms_key ? aws_kms_key.cloudwatch_logs_encryption[0].arn : var.flow_logs_kms_key_id + + tags = { + Name = "${local.name}-vpc-flow-logs" + } +} + +resource "aws_kms_key" "cloudwatch_logs_encryption" { + count = var.create_flow_logs_kms_key ? 1 : 0 + description = "KMS key for CloudWatch Logs encryption" + deletion_window_in_days = 7 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Sid = "Enable IAM User Permissions", + Effect = "Allow", + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + }, + Action = "kms:*", + Resource = "*" + }, + { + Sid = "Allow CloudWatch Logs to use the key", + Effect = "Allow", + Principal = { + Service = "logs.${data.aws_region.current.name}.amazonaws.com" + }, + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ], + Resource = "*" + } + ] + }) +} + +resource "aws_iam_role" "vpc_flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "${local.name}-vpc-flow-logs" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "vpc-flow-logs.amazonaws.com" + } + }] + }) +} + +resource "aws_iam_role_policy" "vpc_flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "${local.name}-vpc-flow-logs" + role = aws_iam_role.vpc_flow_logs[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams" + ] + Effect = "Allow" + Resource = "arn:aws:logs:*:*:log-group:/aws/vpc-flow-logs/*" + }] + }) +} + +#----------------------------------------------------------------------------------------------------------------------- +# Subnets +#----------------------------------------------------------------------------------------------------------------------- + +data "aws_availability_zones" "available" {} + +# Public Subnets +resource "aws_subnet" "public" { + count = var.availability_zones + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.cidr_block, var.subnet_newbits, count.index) + availability_zone = data.aws_availability_zones.available.names[count.index] + + map_public_ip_on_launch = false # Disable automatic public IP assignment for security + + tags = { + Name = "${local.name}-public-${data.aws_availability_zones.available.names[count.index]}" + Tier = "public" + } +} + +# Private Subnets +resource "aws_subnet" "private" { + count = var.availability_zones + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet( + var.cidr_block, var.subnet_newbits, count.index + length(data.aws_availability_zones.available.names) + ) + availability_zone = data.aws_availability_zones.available.names[count.index] + + tags = { + Name = "${local.name}-private-${data.aws_availability_zones.available.names[count.index]}" + Tier = "private" + } +} + +# Isolated Subnets +resource "aws_subnet" "isolated" { + count = var.availability_zones + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet( + var.cidr_block, var.subnet_newbits, count.index + 2 * length(data.aws_availability_zones.available.names) + ) + availability_zone = data.aws_availability_zones.available.names[count.index] + + tags = { + Name = "${local.name}-isolated-${data.aws_availability_zones.available.names[count.index]}" + Tier = "isolated" + } +} + +#----------------------------------------------------------------------------------------------------------------------- +# Internet Gateway +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = local.name + } +} + +#----------------------------------------------------------------------------------------------------------------------- +# NAT Gateways +#----------------------------------------------------------------------------------------------------------------------- + +resource "aws_eip" "nat" { + count = var.availability_zones + domain = "vpc" + + tags = { + Name = "${local.name}-nat-${data.aws_availability_zones.available.names[count.index]}" + } +} + +resource "aws_nat_gateway" "main" { + count = var.availability_zones + allocation_id = aws_eip.nat[count.index].id + subnet_id = aws_subnet.public[count.index].id + + tags = { + Name = "${local.name}-nat-${data.aws_availability_zones.available.names[count.index]}" + } + + depends_on = [aws_internet_gateway.main] +} + +#----------------------------------------------------------------------------------------------------------------------- +# Route Tables +#----------------------------------------------------------------------------------------------------------------------- + +# Public Route Table +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${local.name}-public" + } +} + +# Private Route Tables (one per AZ) +resource "aws_route_table" "private" { + count = var.availability_zones + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main[count.index].id + } + + tags = { + Name = "${local.name}-private-${data.aws_availability_zones.available.names[count.index]}" + } +} + +#----------------------------------------------------------------------------------------------------------------------- +# Route Table Associations +#----------------------------------------------------------------------------------------------------------------------- + +# Public Subnet Associations +resource "aws_route_table_association" "public" { + count = var.availability_zones + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +# Private Subnet Associations +resource "aws_route_table_association" "private" { + count = var.availability_zones + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[count.index].id +} + +# Data Subnet Associations +resource "aws_route_table_association" "data" { + count = var.availability_zones + subnet_id = aws_subnet.data[count.index].id + route_table_id = aws_route_table.data[count.index].id +} + +#----------------------------------------------------------------------------------------------------------------------- +# Route53 Hosted Zone +#----------------------------------------------------------------------------------------------------------------------- +resource "aws_route53_zone" "main" { + count = var.domain_name != null ? 1 : 0 + name = var.domain_name + + vpc { + vpc_id = aws_vpc.main.id + } +} diff --git a/terraform/network/aws-vpc/outputs.tf b/terraform/network/aws-vpc/outputs.tf new file mode 100644 index 00000000..3ef8d93e --- /dev/null +++ b/terraform/network/aws-vpc/outputs.tf @@ -0,0 +1,23 @@ +#----------------------------------------------------------------------------------------------------------------------- +# Outputs +#----------------------------------------------------------------------------------------------------------------------- + +# output "vpc_id" { +# description = "The ID of the VPC" +# value = aws_vpc.main.id +# } + +# output "public_subnet_ids" { +# description = "List of public subnet IDs" +# value = aws_subnet.public[*].id +# } + +# output "private_subnet_ids" { +# description = "List of private subnet IDs" +# value = aws_subnet.private[*].id +# } + +# output "data_subnet_ids" { +# description = "List of data subnet IDs" +# value = aws_subnet.data[*].id +# } diff --git a/terraform/network/aws-vpc/variables.tf b/terraform/network/aws-vpc/variables.tf new file mode 100644 index 00000000..bfcb90bd --- /dev/null +++ b/terraform/network/aws-vpc/variables.tf @@ -0,0 +1,87 @@ +#----------------------------------------------------------------------------------------------------------------------- +# Variables +#----------------------------------------------------------------------------------------------------------------------- + +variable "context_id" { + type = string + description = "The windsor context id for this deployment" + default = "" +} + +variable "name" { + description = "Name prefix for all resources in the VPC" + type = string + default = "" +} + +variable "cidr_block" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "Number of availability zones to use for the subnets" + type = number + default = 3 +} + +variable "subnet_newbits" { + description = "Number of new bits for the subnet" + type = number + default = 4 +} + +variable "enable_dns_hostnames" { + description = "Enable DNS hostnames in the VPC" + type = bool + default = true +} + +variable "enable_dns_support" { + description = "Enable DNS support in the VPC" + type = bool + default = true +} + +variable "enable_nat_gateway" { + description = "Enable NAT Gateway for private subnets" + type = bool + default = true +} + +variable "single_nat_gateway" { + description = "Use a single NAT Gateway for all private subnets" + type = bool + default = false +} + +variable "enable_flow_logs" { + description = "Enable flow logs for the VPC" + type = bool + default = true +} + +variable "create_flow_logs_kms_key" { + description = "Create a KMS key for flow logs" + type = bool + default = true +} + +variable "flow_logs_kms_key_id" { + description = "The KMS key ID for flow logs" + type = string + default = null +} + +variable "tags" { + description = "Additional tags for all resources" + type = map(string) + default = {} +} + +variable "domain_name" { + description = "The domain name for the Route53 hosted zone" + type = string + default = null +} diff --git a/windsor.yaml b/windsor.yaml index e23fbb32..da85eb1c 100644 --- a/windsor.yaml +++ b/windsor.yaml @@ -59,3 +59,15 @@ contexts: domain: test forward: - 10.5.0.1:8053 + aws-example: + environment: + TF_VAR_domain_name: aws-test.internal + aws: + enabled: true + region: us-east-2 + terraform: + enabled: true + backend: + type: local + dns: + domain: aws-test.internal