diff --git a/felsokning/infra/aws/10-natverk.tf b/felsokning/infra/aws/10-natverk.tf new file mode 100644 index 0000000..60c4d1e --- /dev/null +++ b/felsokning/infra/aws/10-natverk.tf @@ -0,0 +1,267 @@ +# Nätet. +# +# Tre lager subnät per zon: +# publikt bara lastbalanserare och NAT-gateway +# privat noderna — inga publika IP-adresser +# data Aurora, helt utan väg ut +# +# Databasen får ett eget lager i stället för att dela nodernas subnät. +# Det gör "vem kan nå databasen" till en fråga om routing och inte bara +# om säkerhetsgrupper — två oberoende spärrar i stället för en. + +locals { + namn = "felsokning-${var.miljo}" + + taggar = { + Projekt = "guidad-felsokning" + Miljo = var.miljo + Forvaltas = "terraform" + } + + zoner = slice(data.aws_availability_zones.tillgangliga.names, 0, var.antal_zoner) + + # /20 per subnät ur en /16: 4096 adresser, gott om plats för noder som + # var och en tar ett hundratal ENI-adresser. + publika_cidr = [for i in range(var.antal_zoner) : cidrsubnet(var.vpc_cidr, 4, i)] + privata_cidr = [for i in range(var.antal_zoner) : cidrsubnet(var.vpc_cidr, 4, i + 4)] + data_cidr = [for i in range(var.antal_zoner) : cidrsubnet(var.vpc_cidr, 4, i + 8)] +} + +data "aws_availability_zones" "tillgangliga" { + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +resource "aws_vpc" "denna" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true # krävs av EKS och av VPC-endpoints + + tags = { Name = local.namn } +} + +resource "aws_internet_gateway" "denna" { + vpc_id = aws_vpc.denna.id + + tags = { Name = local.namn } +} + +# ---- Subnät ------------------------------------------------------------- + +resource "aws_subnet" "publikt" { + count = var.antal_zoner + + vpc_id = aws_vpc.denna.id + cidr_block = local.publika_cidr[count.index] + availability_zone = local.zoner[count.index] + map_public_ip_on_launch = false # bara lastbalanserare här — de får sina egna + + tags = { + Name = "${local.namn}-publikt-${local.zoner[count.index]}" + # Taggarna är inte kosmetiska: lastbalanserar-kontrollern hittar + # subnäten genom dem. + "kubernetes.io/role/elb" = "1" + } +} + +resource "aws_subnet" "privat" { + count = var.antal_zoner + + vpc_id = aws_vpc.denna.id + cidr_block = local.privata_cidr[count.index] + availability_zone = local.zoner[count.index] + + tags = { + Name = "${local.namn}-privat-${local.zoner[count.index]}" + "kubernetes.io/role/internal-elb" = "1" + } +} + +resource "aws_subnet" "data" { + count = var.antal_zoner + + vpc_id = aws_vpc.denna.id + cidr_block = local.data_cidr[count.index] + availability_zone = local.zoner[count.index] + + tags = { Name = "${local.namn}-data-${local.zoner[count.index]}" } +} + +# ---- Utgående trafik ---------------------------------------------------- + +resource "aws_eip" "nat" { + count = var.nat_per_zon ? var.antal_zoner : 1 + + domain = "vpc" + + tags = { Name = "${local.namn}-nat-${count.index}" } + + depends_on = [aws_internet_gateway.denna] +} + +resource "aws_nat_gateway" "denna" { + count = var.nat_per_zon ? var.antal_zoner : 1 + + allocation_id = aws_eip.nat[count.index].id + subnet_id = aws_subnet.publikt[count.index].id + + tags = { Name = "${local.namn}-${count.index}" } + + depends_on = [aws_internet_gateway.denna] +} + +# ---- Routing ------------------------------------------------------------ + +resource "aws_route_table" "publikt" { + vpc_id = aws_vpc.denna.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.denna.id + } + + tags = { Name = "${local.namn}-publikt" } +} + +resource "aws_route_table_association" "publikt" { + count = var.antal_zoner + + subnet_id = aws_subnet.publikt[count.index].id + route_table_id = aws_route_table.publikt.id +} + +resource "aws_route_table" "privat" { + count = var.antal_zoner + + vpc_id = aws_vpc.denna.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.denna[var.nat_per_zon ? count.index : 0].id + } + + tags = { Name = "${local.namn}-privat-${local.zoner[count.index]}" } +} + +resource "aws_route_table_association" "privat" { + count = var.antal_zoner + + subnet_id = aws_subnet.privat[count.index].id + route_table_id = aws_route_table.privat[count.index].id +} + +# Datalagret har ingen väg ut. Aurora behöver ingen, och saknaden är +# skyddet: en komprometterad databas kan inte ringa hem. +resource "aws_route_table" "data" { + vpc_id = aws_vpc.denna.id + + tags = { Name = "${local.namn}-data" } +} + +resource "aws_route_table_association" "data" { + count = var.antal_zoner + + subnet_id = aws_subnet.data[count.index].id + route_table_id = aws_route_table.data.id +} + +# ---- VPC-endpoints ------------------------------------------------------ +# +# Trafik till AWS egna tjänster går inom nätet i stället för ut genom NAT. +# Två vinster som drar åt samma håll: bilder och loggar passerar aldrig +# internet, och NAT-notan sjunker rejält — bilddragningar dominerar annars. + +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.denna.id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = concat(aws_route_table.privat[*].id, [aws_route_table.data.id]) + + tags = { Name = "${local.namn}-s3" } +} + +resource "aws_security_group" "endpoints" { + name = "${local.namn}-endpoints" + description = "Interface-endpoints — tar emot HTTPS från noderna." + vpc_id = aws_vpc.denna.id + + ingress { + description = "HTTPS från noderna" + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [aws_security_group.noder.id] + } + + tags = { Name = "${local.namn}-endpoints" } +} + +resource "aws_vpc_endpoint" "granssnitt" { + for_each = toset([ + "ecr.api", + "ecr.dkr", + "logs", + "secretsmanager", + "sts", + "elasticloadbalancing", + ]) + + vpc_id = aws_vpc.denna.id + service_name = "com.amazonaws.${var.region}.${each.key}" + vpc_endpoint_type = "Interface" + subnet_ids = aws_subnet.privat[*].id + security_group_ids = [aws_security_group.endpoints.id] + private_dns_enabled = true + + tags = { Name = "${local.namn}-${replace(each.key, ".", "-")}" } +} + +# ---- Flödesloggar ------------------------------------------------------- +# +# Bara avvisad trafik. Allt hade blivit dyrt brus; avvisningarna är det +# som faktiskt säger något när en spärr slår till eller borde ha gjort det. + +resource "aws_flow_log" "denna" { + vpc_id = aws_vpc.denna.id + traffic_type = "REJECT" + iam_role_arn = aws_iam_role.flodeslogg.arn + log_destination = aws_cloudwatch_log_group.flodeslogg.arn + max_aggregation_interval = 60 + + tags = { Name = local.namn } +} + +resource "aws_iam_role" "flodeslogg" { + name = "${local.namn}-flodeslogg" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "vpc-flow-logs.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy" "flodeslogg" { + name = "skriv-loggar" + role = aws_iam_role.flodeslogg.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + ] + Resource = "${aws_cloudwatch_log_group.flodeslogg.arn}:*" + }] + }) +} diff --git a/felsokning/infra/aws/20-eks.tf b/felsokning/infra/aws/20-eks.tf new file mode 100644 index 0000000..a45ea82 --- /dev/null +++ b/felsokning/infra/aws/20-eks.tf @@ -0,0 +1,318 @@ +# Klustret. +# +# Noderna ligger i de privata subnäten och har inga publika adresser. +# API-servern har både en privat och en publik slutpunkt: den privata så +# att noderna når den utan att gå ut, den publika begränsad till +# var.tillatna_api_cidr så att drift och byggrunners kommer åt. +# +# IRSA (IAM Roles for Service Accounts) i stället för nodroller: en pod +# som behöver läsa en hemlighet får en egen roll, och grannpodden på +# samma nod får inte samma rättigheter på köpet. + +resource "aws_iam_role" "kluster" { + name = "${local.namn}-kluster" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "eks.amazonaws.com" } + Action = ["sts:AssumeRole", "sts:TagSession"] + }] + }) +} + +resource "aws_iam_role_policy_attachment" "kluster" { + for_each = toset([ + "arn:${data.aws_partition.denna.partition}:iam::aws:policy/AmazonEKSClusterPolicy", + ]) + + role = aws_iam_role.kluster.name + policy_arn = each.value +} + +resource "aws_security_group" "kluster" { + name = "${local.namn}-kluster" + description = "EKS kontrollplan." + vpc_id = aws_vpc.denna.id + + egress { + description = "Ut till noderna och AWS API" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.namn}-kluster" } +} + +resource "aws_security_group" "noder" { + name = "${local.namn}-noder" + description = "Arbetsnoder." + vpc_id = aws_vpc.denna.id + + ingress { + description = "Noder emellan (pod-till-pod, DNS, kubelet)" + from_port = 0 + to_port = 0 + protocol = "-1" + self = true + } + + ingress { + description = "Kontrollplanet till kubelet och webhooks" + from_port = 1025 + to_port = 65535 + protocol = "tcp" + security_groups = [aws_security_group.kluster.id] + } + + egress { + description = "Ut mot internet via NAT och till VPC-endpoints" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.namn}-noder" + } +} + +resource "aws_cloudwatch_log_group" "kluster" { + name = "/aws/eks/${local.namn}/cluster" + retention_in_days = 90 + kms_key_id = aws_kms_key.loggar.arn +} + +resource "aws_eks_cluster" "denna" { + name = local.namn + role_arn = aws_iam_role.kluster.arn + version = var.kubernetes_version + + vpc_config { + subnet_ids = concat(aws_subnet.privat[*].id, aws_subnet.publikt[*].id) + security_group_ids = [aws_security_group.kluster.id] + endpoint_private_access = true + endpoint_public_access = true + public_access_cidrs = var.tillatna_api_cidr + } + + # Hemligheter i etcd krypteras med vår egen nyckel, inte AWS delade. + encryption_config { + provider { + key_arn = aws_kms_key.eks.arn + } + resources = ["secrets"] + } + + # Revisionsspåret. Utan "audit" går det inte att i efterhand svara på + # vem som gjorde vad i klustret. + enabled_cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"] + + access_config { + authentication_mode = "API" + bootstrap_cluster_creator_admin_permissions = true + } + + tags = { Name = local.namn } + + depends_on = [ + aws_iam_role_policy_attachment.kluster, + aws_cloudwatch_log_group.kluster, + ] +} + +# ---- IRSA --------------------------------------------------------------- + +data "tls_certificate" "oidc" { + url = aws_eks_cluster.denna.identity[0].oidc[0].issuer +} + +resource "aws_iam_openid_connect_provider" "denna" { + url = aws_eks_cluster.denna.identity[0].oidc[0].issuer + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.oidc.certificates[0].sha1_fingerprint] +} + +# Hjälpare: förtroendepolicy som binder en roll till EN namnrymd och ETT +# tjänstekonto. Utan sub-villkoret skulle vilket tjänstekonto som helst i +# klustret kunna anta rollen. +locals { + oidc_utfardare = replace(aws_iam_openid_connect_provider.denna.url, "https://", "") + + irsa_fortroende = { for k, v in { + plattform = "guidad-felsokning:plattform" + lastbalanserare = "kube-system:aws-load-balancer-controller" + gitea = "gitea:gitea" + } : k => jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Federated = aws_iam_openid_connect_provider.denna.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "${local.oidc_utfardare}:aud" = "sts.amazonaws.com" + "${local.oidc_utfardare}:sub" = "system:serviceaccount:${v}" + } + } + }] + }) } +} + +# ---- Noder -------------------------------------------------------------- + +resource "aws_iam_role" "nod" { + name = "${local.namn}-nod" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "nod" { + for_each = toset([ + "arn:${data.aws_partition.denna.partition}:iam::aws:policy/AmazonEKSWorkerNodePolicy", + "arn:${data.aws_partition.denna.partition}:iam::aws:policy/AmazonEKS_CNI_Policy", + "arn:${data.aws_partition.denna.partition}:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + # Loggar och mätvärden från noden — inte podarnas rättigheter. + "arn:${data.aws_partition.denna.partition}:iam::aws:policy/CloudWatchAgentServerPolicy", + # SSM i stället för SSH: ingen nyckel att tappa bort, och varje + # session hamnar i revisionsloggen. + "arn:${data.aws_partition.denna.partition}:iam::aws:policy/AmazonSSMManagedInstanceCore", + ]) + + role = aws_iam_role.nod.name + policy_arn = each.value +} + +resource "aws_launch_template" "nod" { + name_prefix = "${local.namn}-nod-" + + vpc_security_group_ids = [aws_security_group.noder.id] + + # IMDSv2 obligatoriskt och hoppgräns 1: en pod kan inte nå + # instansmetadata och därmed inte låna nodens roll. + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + http_put_response_hop_limit = 1 + } + + block_device_mappings { + device_name = "/dev/xvda" + + ebs { + volume_size = 50 + volume_type = "gp3" + encrypted = true + delete_on_termination = true + } + } + + monitoring { + enabled = true + } + + tag_specifications { + resource_type = "instance" + tags = merge(local.taggar, { Name = "${local.namn}-nod" }) + } + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_eks_node_group" "denna" { + cluster_name = aws_eks_cluster.denna.name + node_group_name = "${local.namn}-noder" + node_role_arn = aws_iam_role.nod.arn + subnet_ids = aws_subnet.privat[*].id + instance_types = var.nod_typer + + # Graviton: bättre pris per prestanda. Bilderna byggs för arm64. + ami_type = "AL2023_ARM_64_STANDARD" + + scaling_config { + min_size = var.noder.minsta + desired_size = var.noder.onskad + max_size = var.noder.hogsta + } + + # En nod i taget ur drift vid uppdatering — PodDisruptionBudget i + # arbetslastlagret ser till att replikerna inte försvinner samtidigt. + update_config { + max_unavailable = 1 + } + + launch_template { + id = aws_launch_template.nod.id + version = aws_launch_template.nod.latest_version + } + + tags = { Name = "${local.namn}-noder" } + + depends_on = [aws_iam_role_policy_attachment.nod] + + lifecycle { + # Autoskalning ändrar önskat antal — Terraform ska inte dra tillbaka det. + ignore_changes = [scaling_config[0].desired_size] + } +} + +# ---- Tillägg ------------------------------------------------------------ +# +# Läggs till efter nodgruppen: de behöver noder att köra på. + +resource "aws_eks_addon" "denna" { + for_each = toset(["vpc-cni", "coredns", "kube-proxy", "eks-pod-identity-agent"]) + + cluster_name = aws_eks_cluster.denna.name + addon_name = each.key + resolve_conflicts_on_update = "PRESERVE" + + depends_on = [aws_eks_node_group.denna] +} + +# EBS-drivrutinen behöver egna rättigheter för att skapa volymer. +resource "aws_iam_role" "ebs" { + name = "${local.namn}-ebs" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Federated = aws_iam_openid_connect_provider.denna.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "${local.oidc_utfardare}:aud" = "sts.amazonaws.com" + "${local.oidc_utfardare}:sub" = "system:serviceaccount:kube-system:ebs-csi-controller-sa" + } + } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "ebs" { + role = aws_iam_role.ebs.name + policy_arn = "arn:${data.aws_partition.denna.partition}:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy" +} + +resource "aws_eks_addon" "ebs" { + cluster_name = aws_eks_cluster.denna.name + addon_name = "aws-ebs-csi-driver" + service_account_role_arn = aws_iam_role.ebs.arn + resolve_conflicts_on_update = "PRESERVE" + + depends_on = [aws_eks_node_group.denna] +} diff --git a/felsokning/infra/aws/30-data.tf b/felsokning/infra/aws/30-data.tf new file mode 100644 index 0000000..3ad62d6 --- /dev/null +++ b/felsokning/infra/aws/30-data.tf @@ -0,0 +1,302 @@ +# Data: händelseloggen, bilagorna och bilderna. + +# ---- Nycklar ------------------------------------------------------------ +# +# Egna KMS-nycklar i stället för AWS delade. Det som vinns är +# åtkomstkontroll och revisionsspår per nyckel — och möjligheten att +# rotera eller spärra utan att röra något annat. + +resource "aws_kms_key" "eks" { + description = "${local.namn} — hemligheter i etcd" + enable_key_rotation = true + deletion_window_in_days = 30 +} + +resource "aws_kms_alias" "eks" { + name = "alias/${local.namn}-eks" + target_key_id = aws_kms_key.eks.key_id +} + +resource "aws_kms_key" "data" { + description = "${local.namn} — databas och bilagor" + enable_key_rotation = true + deletion_window_in_days = 30 +} + +resource "aws_kms_alias" "data" { + name = "alias/${local.namn}-data" + target_key_id = aws_kms_key.data.key_id +} + +resource "aws_kms_key" "loggar" { + description = "${local.namn} — loggar" + enable_key_rotation = true + deletion_window_in_days = 30 + + # CloudWatch måste kunna kryptera med nyckeln. + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { AWS = "arn:${data.aws_partition.denna.partition}:iam::${data.aws_caller_identity.denna.account_id}:root" } + Action = "kms:*" + Resource = "*" + }, + { + Effect = "Allow" + Principal = { Service = "logs.${var.region}.amazonaws.com" } + Action = ["kms:Encrypt*", "kms:Decrypt*", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:Describe*"] + Resource = "*" + Condition = { + ArnLike = { + "kms:EncryptionContext:aws:logs:arn" = "arn:${data.aws_partition.denna.partition}:logs:${var.region}:${data.aws_caller_identity.denna.account_id}:log-group:*" + } + } + }, + ] + }) +} + +resource "aws_kms_alias" "loggar" { + name = "alias/${local.namn}-loggar" + target_key_id = aws_kms_key.loggar.key_id +} + +# ---- Aurora PostgreSQL -------------------------------------------------- +# +# Serverless v2 skalar med lasten. Det som gör att den här databasen +# duger till händelseloggen är inte prestandan utan +# säkerhetskopieringen: automatisk backup med PITR ned till sekunden +# inom retentionsfönstret. Går loggen förlorad är varje ärendes +# bevisvärde borta, och det går inte att återskapa i efterhand. + +resource "aws_db_subnet_group" "denna" { + name = local.namn + subnet_ids = aws_subnet.data[*].id + + tags = { Name = local.namn } +} + +resource "aws_security_group" "databas" { + name = "${local.namn}-databas" + description = "Aurora — tar bara emot från noderna." + vpc_id = aws_vpc.denna.id + + ingress { + description = "PostgreSQL från klustrets noder" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.noder.id] + } + + # Ingen egress-regel: databasen ska inte kunna ringa någonstans. + + tags = { Name = "${local.namn}-databas" } +} + +resource "random_password" "databas" { + length = 40 + special = false # undviker citeringsproblem i anslutningssträngar +} + +resource "aws_rds_cluster" "denna" { + cluster_identifier = local.namn + engine = "aurora-postgresql" + engine_mode = "provisioned" + engine_version = var.databas.version + database_name = "felsokning" + master_username = "plattform" + master_password = random_password.databas.result + + db_subnet_group_name = aws_db_subnet_group.denna.name + vpc_security_group_ids = [aws_security_group.databas.id] + + storage_encrypted = true + kms_key_id = aws_kms_key.data.arn + + backup_retention_period = var.databas.backup_dagar + preferred_backup_window = "02:00-03:00" + preferred_maintenance_window = "sun:03:30-sun:04:30" + copy_tags_to_snapshot = true + + deletion_protection = var.databas.raderingsskydd + skip_final_snapshot = false + final_snapshot_identifier = "${local.namn}-slutlig" + + # Loggarna behövs för att i efterhand kunna se vad som kördes. + enabled_cloudwatch_logs_exports = ["postgresql"] + + serverlessv2_scaling_configuration { + min_capacity = var.databas.min_kapacitet + max_capacity = var.databas.max_kapacitet + } + + tags = { Name = local.namn } + + lifecycle { + ignore_changes = [master_password] # roteras via Secrets Manager + } +} + +resource "aws_rds_cluster_instance" "denna" { + count = var.databas.instanser + + identifier = "${local.namn}-${count.index}" + cluster_identifier = aws_rds_cluster.denna.id + instance_class = "db.serverless" + engine = aws_rds_cluster.denna.engine + engine_version = aws_rds_cluster.denna.engine_version + publicly_accessible = false + + performance_insights_enabled = true + performance_insights_kms_key_id = aws_kms_key.data.arn + monitoring_interval = 30 + monitoring_role_arn = aws_iam_role.rds_overvakning.arn + + tags = { Name = "${local.namn}-${count.index}" } +} + +resource "aws_iam_role" "rds_overvakning" { + name = "${local.namn}-rds-overvakning" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "monitoring.rds.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "rds_overvakning" { + role = aws_iam_role.rds_overvakning.name + policy_arn = "arn:${data.aws_partition.denna.partition}:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" +} + +# ---- S3 för bilagor ----------------------------------------------------- +# +# Foton och videoklipp. Innehållsadresserade — nyckeln är innehållets +# SHA-256 — så versionshantering skyddar mot oavsiktlig radering snarare +# än mot överskrivning: samma nyckel har alltid samma innehåll. + +resource "aws_s3_bucket" "bilagor" { + bucket = "${local.namn}-bilagor-${data.aws_caller_identity.denna.account_id}" + + tags = { Name = "${local.namn}-bilagor" } +} + +resource "aws_s3_bucket_public_access_block" "bilagor" { + bucket = aws_s3_bucket.bilagor.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_versioning" "bilagor" { + bucket = aws_s3_bucket.bilagor.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "bilagor" { + bucket = aws_s3_bucket.bilagor.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = aws_kms_key.data.arn + } + bucket_key_enabled = true # färre KMS-anrop, lägre kostnad + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "bilagor" { + bucket = aws_s3_bucket.bilagor.id + + rule { + id = "stada-gamla-versioner" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = 90 + } + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } +} + +resource "aws_s3_bucket_policy" "bilagor" { + bucket = aws_s3_bucket.bilagor.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "KravTLS" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [aws_s3_bucket.bilagor.arn, "${aws_s3_bucket.bilagor.arn}/*"] + Condition = { Bool = { "aws:SecureTransport" = "false" } } + }] + }) + + depends_on = [aws_s3_bucket_public_access_block.bilagor] +} + +# ---- ECR ---------------------------------------------------------------- +# +# Vårt eget register. Oföränderliga taggar: en tagg som en gång pekat på +# ett bygge kan inte peka på ett annat, så "vilken kod kör i produktion" +# har ett entydigt svar. + +resource "aws_ecr_repository" "denna" { + for_each = toset([ + "guidad-felsokning-web", + "guidad-felsokning-plattform", + "guidad-felsokning-ai-orkester", + ]) + + name = each.key + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "KMS" + kms_key = aws_kms_key.data.arn + } + + tags = { Name = each.key } +} + +resource "aws_ecr_lifecycle_policy" "denna" { + for_each = aws_ecr_repository.denna + + repository = each.value.name + + policy = jsonencode({ + rules = [{ + rulePriority = 1 + description = "Behåll de 30 senaste bilderna." + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = 30 + } + action = { type = "expire" } + }] + }) +} diff --git a/felsokning/infra/aws/40-hemligheter.tf b/felsokning/infra/aws/40-hemligheter.tf new file mode 100644 index 0000000..d91dc8b --- /dev/null +++ b/felsokning/infra/aws/40-hemligheter.tf @@ -0,0 +1,308 @@ +# Hemligheterna och vem som får läsa dem. +# +# Secrets Manager i stället för Kubernetes-secrets som sanningskälla: +# rotation, revisionsspår per läsning och kryptering med vår egen nyckel. +# Klustret hämtar dem via IRSA — plattformens tjänstekonto får en egen +# roll, och grannpodden på samma nod får ingenting på köpet. + +resource "aws_secretsmanager_secret" "databas" { + name = "${local.namn}/databas" + description = "Aurora-uppgifter för plattformstjänsten." + kms_key_id = aws_kms_key.data.arn + recovery_window_in_days = 30 +} + +resource "aws_secretsmanager_secret_version" "databas" { + secret_id = aws_secretsmanager_secret.databas.id + + secret_string = jsonencode({ + username = aws_rds_cluster.denna.master_username + password = random_password.databas.result + host = aws_rds_cluster.denna.endpoint + port = 5432 + dbname = aws_rds_cluster.denna.database_name + # Färdig anslutningssträng — plattformstjänsten läser DATABASE_URL. + url = "postgresql://${aws_rds_cluster.denna.master_username}:${random_password.databas.result}@${aws_rds_cluster.denna.endpoint}:5432/${aws_rds_cluster.denna.database_name}?sslmode=require" + }) +} + +# Applikationens egna hemligheter. Värdena sätts EN gång — Terraform +# äger inte innehållet efteråt, så en rotation i Secrets Manager inte +# skrivs över av nästa apply. +resource "aws_secretsmanager_secret" "app" { + name = "${local.namn}/app" + description = "Claude-nyckel, JWT-hemlighet, krypteringsnyckel för kundernas leverantörsuppgifter." + kms_key_id = aws_kms_key.data.arn + recovery_window_in_days = 30 +} + +resource "random_password" "jwt" { + length = 64 + special = false +} + +resource "random_id" "integration" { + byte_length = 32 +} + +resource "aws_secretsmanager_secret_version" "app" { + secret_id = aws_secretsmanager_secret.app.id + + secret_string = jsonencode({ + # Fylls i efter första apply — Terraform ska inte känna Claude-nyckeln. + anthropic_api_key = "ERSATT-MIG" + jwt_secret = random_password.jwt.result + integration_nyckel = random_id.integration.hex + }) + + lifecycle { + ignore_changes = [secret_string] + } +} + +# ---- Rollen plattformstjänsten kör som ---------------------------------- + +resource "aws_iam_role" "plattform" { + name = "${local.namn}-plattform" + assume_role_policy = local.irsa_fortroende["plattform"] +} + +resource "aws_iam_role_policy" "plattform" { + name = "las-hemligheter-och-bilagor" + role = aws_iam_role.plattform.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "LasEgnaHemligheter" + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"] + Resource = [ + aws_secretsmanager_secret.databas.arn, + aws_secretsmanager_secret.app.arn, + ] + }, + { + Sid = "Bilagor" + Effect = "Allow" + # Ingen DeleteObject: bilagor hör till en append-only + # händelselogg och tjänsten har ingen anledning att radera dem. + Action = ["s3:GetObject", "s3:PutObject"] + Resource = "${aws_s3_bucket.bilagor.arn}/*" + }, + { + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = aws_s3_bucket.bilagor.arn + }, + { + Sid = "KrypteringForOvanstaende" + Effect = "Allow" + Action = ["kms:Decrypt", "kms:GenerateDataKey"] + Resource = aws_kms_key.data.arn + }, + ] + }) +} + +# ---- Lastbalanserar-kontrollern ----------------------------------------- +# +# Kör i klustret och skapar ALB:er ur Ingress-objekt. Rättigheterna är +# breda till sin natur — den måste kunna skapa och ta bort +# lastbalanserare — men begränsade till den här VPC:n där det går. + +resource "aws_iam_role" "lastbalanserare" { + name = "${local.namn}-lastbalanserare" + assume_role_policy = local.irsa_fortroende["lastbalanserare"] +} + +resource "aws_iam_role_policy" "lastbalanserare" { + name = "hantera-lastbalanserare" + role = aws_iam_role.lastbalanserare.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "elasticloadbalancing:*", + "ec2:Describe*", + "ec2:CreateSecurityGroup", + "ec2:CreateTags", + "ec2:DeleteTags", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:DeleteSecurityGroup", + "acm:DescribeCertificate", + "acm:ListCertificates", + "iam:CreateServiceLinkedRole", + "cognito-idp:DescribeUserPoolClient", + "wafv2:GetWebACL", + "wafv2:AssociateWebACL", + "shield:GetSubscriptionState", + ] + Resource = "*" + }, + ] + }) +} + +# ---- Rollerna bygget och driftsättningen antar -------------------------- +# +# Byggrunnern får publicera till ECR men inte röra klustret. +# Driftsättningen får röra klustret men inte publicera bilder. Två +# separata roller så ett komprometterat bygge inte kan driftsätta. + +resource "aws_iam_role" "bygg" { + name = "${local.namn}-bygg" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Federated = aws_iam_openid_connect_provider.denna.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "${local.oidc_utfardare}:aud" = "sts.amazonaws.com" + "${local.oidc_utfardare}:sub" = "system:serviceaccount:gitea:runner" + } + } + }] + }) +} + +resource "aws_iam_role_policy" "bygg" { + name = "publicera-till-ecr" + role = aws_iam_role.bygg.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "ecr:GetAuthorizationToken" + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "ecr:BatchCheckLayerAvailability", + "ecr:CompleteLayerUpload", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart", + "ecr:BatchGetImage", + ] + Resource = [for r in aws_ecr_repository.denna : r.arn] + }, + { + Effect = "Allow" + Action = ["kms:Encrypt", "kms:GenerateDataKey", "kms:Decrypt"] + Resource = aws_kms_key.data.arn + }, + ] + }) +} + +resource "aws_iam_role" "drift" { + name = "${local.namn}-drift" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Federated = aws_iam_openid_connect_provider.denna.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "${local.oidc_utfardare}:aud" = "sts.amazonaws.com" + "${local.oidc_utfardare}:sub" = "system:serviceaccount:gitea:drift" + } + } + }] + }) +} + +resource "aws_iam_role_policy" "drift" { + name = "driftsatt" + role = aws_iam_role.drift.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["eks:DescribeCluster", "eks:ListClusters"] + Resource = aws_eks_cluster.denna.arn + }, + { + # Terraform-tillståndet. + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"] + Resource = [aws_s3_bucket.tillstand.arn, "${aws_s3_bucket.tillstand.arn}/*"] + }, + ] + }) +} + +# Driftsättningen behöver också rättigheter i klustret, inte bara i IAM. +resource "aws_eks_access_entry" "drift" { + cluster_name = aws_eks_cluster.denna.name + principal_arn = aws_iam_role.drift.arn + type = "STANDARD" +} + +resource "aws_eks_access_policy_association" "drift" { + cluster_name = aws_eks_cluster.denna.name + principal_arn = aws_iam_role.drift.arn + policy_arn = "arn:${data.aws_partition.denna.partition}:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy" + + access_scope { + type = "cluster" + } + + depends_on = [aws_eks_access_entry.drift] +} + +# ---- Terraform-tillståndet ---------------------------------------------- + +resource "aws_s3_bucket" "tillstand" { + bucket = "${local.namn}-tfstate-${data.aws_caller_identity.denna.account_id}" + + tags = { Name = "${local.namn}-tfstate" } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "tillstand" { + bucket = aws_s3_bucket.tillstand.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_versioning" "tillstand" { + bucket = aws_s3_bucket.tillstand.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "tillstand" { + bucket = aws_s3_bucket.tillstand.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = aws_kms_key.data.arn + } + } +} diff --git a/felsokning/infra/aws/50-doman-observation.tf b/felsokning/infra/aws/50-doman-observation.tf new file mode 100644 index 0000000..9607579 --- /dev/null +++ b/felsokning/infra/aws/50-doman-observation.tf @@ -0,0 +1,217 @@ +# Domän, certifikat och det som gör driften synlig. + +# ---- Route 53 och ACM --------------------------------------------------- + +data "aws_route53_zone" "befintlig" { + count = var.zon_id != "" ? 1 : 0 + + zone_id = var.zon_id +} + +resource "aws_route53_zone" "ny" { + count = var.zon_id == "" ? 1 : 0 + + name = join(".", slice(split(".", var.doman), 1, length(split(".", var.doman)))) + + tags = { Name = local.namn } +} + +locals { + zon = var.zon_id != "" ? data.aws_route53_zone.befintlig[0].zone_id : aws_route53_zone.ny[0].zone_id +} + +resource "aws_acm_certificate" "denna" { + domain_name = var.doman + validation_method = "DNS" + + subject_alternative_names = compact([ + var.gitea.aktiv && var.gitea.doman != "" ? var.gitea.doman : null, + ]) + + tags = { Name = local.namn } + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "validering" { + for_each = { + for d in aws_acm_certificate.denna.domain_validation_options : d.domain_name => { + namn = d.resource_record_name + typ = d.resource_record_type + post = d.resource_record_value + } + } + + zone_id = local.zon + name = each.value.namn + type = each.value.typ + records = [each.value.post] + ttl = 60 + allow_overwrite = true +} + +resource "aws_acm_certificate_validation" "denna" { + certificate_arn = aws_acm_certificate.denna.arn + validation_record_fqdns = [for r in aws_route53_record.validering : r.fqdn] +} + +# ---- Loggar ------------------------------------------------------------- + +resource "aws_cloudwatch_log_group" "flodeslogg" { + name = "/aws/vpc/${local.namn}/avvisad" + retention_in_days = 30 + kms_key_id = aws_kms_key.loggar.arn +} + +resource "aws_cloudwatch_log_group" "applikation" { + name = "/felsokning/${var.miljo}/applikation" + retention_in_days = 90 + kms_key_id = aws_kms_key.loggar.arn +} + +# ---- Larm --------------------------------------------------------------- +# +# Få larm, men de som finns betyder något. Ett larm som ingen agerar på +# lär folk att ignorera larm. + +resource "aws_sns_topic" "larm" { + name = "${local.namn}-larm" + kms_master_key_id = aws_kms_key.loggar.id +} + +resource "aws_sns_topic_subscription" "larm" { + count = var.larm_epost != "" ? 1 : 0 + + topic_arn = aws_sns_topic.larm.arn + protocol = "email" + endpoint = var.larm_epost +} + +resource "aws_cloudwatch_metric_alarm" "databas_cpu" { + alarm_name = "${local.namn}-databas-cpu" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "CPUUtilization" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 85 + alarm_description = "Aurora ligger högt i tre perioder — skalningstaket kan vara nått." + alarm_actions = [aws_sns_topic.larm.arn] + ok_actions = [aws_sns_topic.larm.arn] + treat_missing_data = "notBreaching" + + dimensions = { + DBClusterIdentifier = aws_rds_cluster.denna.cluster_identifier + } +} + +resource "aws_cloudwatch_metric_alarm" "databas_lagring" { + alarm_name = "${local.namn}-databas-fri-lagring" + comparison_operator = "LessThanThreshold" + evaluation_periods = 2 + metric_name = "FreeLocalStorage" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 5 * 1024 * 1024 * 1024 # 5 GiB + alarm_description = "Lokal lagring håller på att ta slut." + alarm_actions = [aws_sns_topic.larm.arn] + treat_missing_data = "notBreaching" + + dimensions = { + DBClusterIdentifier = aws_rds_cluster.denna.cluster_identifier + } +} + +# Det viktigaste larmet av alla: att säkerhetskopieringen faktiskt sker. +# En backup man tror finns är värre än ingen. +resource "aws_cloudwatch_metric_alarm" "backup_alder" { + alarm_name = "${local.namn}-backup-alder" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "BackupRetentionPeriodStorageUsed" + namespace = "AWS/RDS" + period = 3600 + statistic = "Average" + threshold = 0 + alarm_description = "Saknas mätvärdet finns ingen säkerhetskopiering — larmar på saknad data, inte på överskridet tröskelvärde." + alarm_actions = [aws_sns_topic.larm.arn] + treat_missing_data = "breaching" + + dimensions = { + DBClusterIdentifier = aws_rds_cluster.denna.cluster_identifier + } +} + +resource "aws_cloudwatch_metric_alarm" "noder" { + alarm_name = "${local.namn}-noder-otillrackliga" + comparison_operator = "LessThanThreshold" + evaluation_periods = 2 + metric_name = "cluster_node_count" + namespace = "ContainerInsights" + period = 300 + statistic = "Minimum" + threshold = var.noder.minsta + alarm_description = "Färre noder än minsta önskade — kapacitet saknas eller en zon är nere." + alarm_actions = [aws_sns_topic.larm.arn] + treat_missing_data = "notBreaching" + + dimensions = { + ClusterName = aws_eks_cluster.denna.name + } +} + +# ---- Instrumentpanel ---------------------------------------------------- + +resource "aws_cloudwatch_dashboard" "denna" { + dashboard_name = local.namn + + dashboard_body = jsonencode({ + widgets = [ + { + type = "metric", x = 0, y = 0, width = 12, height = 6 + properties = { + title = "Databas — belastning" + region = var.region + metrics = [ + ["AWS/RDS", "CPUUtilization", "DBClusterIdentifier", aws_rds_cluster.denna.cluster_identifier], + [".", "DatabaseConnections", ".", "."], + [".", "ServerlessDatabaseCapacity", ".", "."], + ] + period = 300 + stat = "Average" + } + }, + { + type = "metric", x = 12, y = 0, width = 12, height = 6 + properties = { + title = "Klustret" + region = var.region + metrics = [ + ["ContainerInsights", "cluster_node_count", "ClusterName", aws_eks_cluster.denna.name], + [".", "cluster_failed_node_count", ".", "."], + [".", "pod_cpu_utilization", ".", "."], + ] + period = 300 + stat = "Average" + } + }, + { + type = "metric", x = 0, y = 6, width = 12, height = 6 + properties = { + title = "Bilagor i objektlagringen" + region = var.region + metrics = [ + ["AWS/S3", "BucketSizeBytes", "BucketName", aws_s3_bucket.bilagor.id, "StorageType", "StandardStorage"], + [".", "NumberOfObjects", ".", ".", ".", "AllStorageTypes"], + ] + period = 86400 + stat = "Average" + } + }, + ] + }) +} diff --git a/felsokning/infra/aws/README.md b/felsokning/infra/aws/README.md new file mode 100644 index 0000000..d1328c3 --- /dev/null +++ b/felsokning/infra/aws/README.md @@ -0,0 +1,106 @@ +# AWS-basen + +Allt Guidad Felsökning behöver, i eget AWS-konto. Inget GitHub, inget +GHCR, ingen extern byggtjänst. + +| Fil | Vad | +| --- | --- | +| `versions.tf` | Leverantörer och tillståndsbackend | +| `variables.tf` | Allt som skiljer en installation från en annan | +| `10-natverk.tf` | VPC, tre subnätlager, NAT, VPC-endpoints, flödesloggar | +| `20-eks.tf` | Klustret, noderna, IRSA, tillägg | +| `30-data.tf` | KMS, Aurora PostgreSQL, S3 för bilagor, ECR | +| `40-hemligheter.tf` | Secrets Manager och rollerna som får läsa dem | +| `50-doman-observation.tf` | Route 53, ACM, CloudWatch, larm | +| `outputs.tf` | Kartan i klartext + värdena arbetslastlagret behöver | + +## Två lager, i ordning + +```sh +cd felsokning/infra/aws +terraform init && terraform apply # basen +terraform output karta # hela AWS-sidan i klartext + +cd ../terraform +terraform init && terraform apply # arbetslasten i klustret +``` + +Anledningen till uppdelningen är inte smak. En enda apply som både +skapar ett EKS-kluster och schemalägger in i det är en känd fälla: +kubernetes-leverantören måste konfigureras med uppgifter som inte finns +förrän klustret är skapat, och planen blir därför opålitlig. + +## Vad som gör det till "efter bästa praxis" + +**Nätet i tre lager.** Publikt bara för lastbalanserare och NAT, privat +för noderna, och ett eget datalager för Aurora *utan routing ut alls*. +Att databasen inte kan nå internet är ett skydd som inte hänger på att +en säkerhetsgrupp är rätt konfigurerad. + +**VPC-endpoints** för S3, ECR, loggar, Secrets Manager, STS och ELB. +Bilddragningar och loggar lämnar aldrig nätet — och NAT-notan sjunker +rejält, eftersom bilddragningar annars dominerar den. + +**IRSA i stället för nodroller.** Plattformens tjänstekonto har en egen +roll bunden till exakt en namnrymd och ett tjänstekonto. Grannpodden på +samma nod får ingenting på köpet. `IMDSv2` obligatoriskt med hoppgräns 1 +gör dessutom att en pod inte kan låna nodens roll via metadatatjänsten. + +**Delade rättigheter mellan bygge och drift.** Byggrollen får publicera +till ECR men inte röra klustret. Driftrollen får röra klustret men inte +publicera bilder. Ett komprometterat bygge kan därför inte driftsätta. + +**Egna KMS-nycklar**, tre stycken: en för hemligheter i etcd, en för +databas och bilagor, en för loggar. Åtkomstkontroll och revisionsspår per +nyckel, och möjligheten att rotera en utan att röra de andra. + +**Oföränderliga ECR-taggar.** En tagg som pekat på ett bygge kan inte +peka på ett annat, så "vilken kod kör i produktion" har ett entydigt +svar. + +**Ingen DeleteObject** för bilagorna. De hör till en append-only +händelselogg, och tjänsten har ingen anledning att kunna radera dem. + +**Flödesloggar bara för avvisad trafik.** Allt hade blivit dyrt brus; +avvisningarna är det som säger något när en spärr slår till — eller +borde ha gjort det. + +**Larm som betyder något.** Fyra stycken, varav ett larmar på *saknad* +data: uteblir mätvärdet för säkerhetskopiering finns ingen backup, och +en backup man tror finns är värre än ingen. + +## Efter första apply + +`terraform output karta` listar det under `kvar_att_gora`: + +1. **Claude-nyckeln.** Terraform ska inte känna den. + ```sh + aws secretsmanager put-secret-value \ + --secret-id felsokning-produktion/app \ + --secret-string "$(aws secretsmanager get-secret-value --secret-id felsokning-produktion/app \ + --query SecretString --output text | jq '.anthropic_api_key = "sk-ant-…"')" + ``` +2. **Schemat.** Kör `felsokning/infra/postgres-init.sql` mot Aurora — det + är den som lägger in append-only-triggarna. Samma fil som + integrationstestet kör, så de kan inte glida isär. +3. **Begränsa `tillatna_api_cidr`.** Standard är öppet mot internet + eftersom klustret annars inte går att nå från en ny miljö. Snäva in + till kontorets och runnernas adresser. +4. **Sätt `larm_epost`.** Utan den går larmen ingenstans. + +## Kostnad — det som faktiskt kostar + +NAT-gateways (en per zon), Auroras minsta kapacitet och EKS +kontrollplan är golvet och löper oavsett last. `nat_per_zon = false` +halverar nätkostnaden i test men gör en zon till enskild felkälla för +all utgående trafik — fel avvägning i produktion. + +## Verifiering + +`terraform validate` kunde inte köras i utvecklingsmiljön — +registry.terraform.io är blockerad av sessionens egress-policy, så +leverantörerna gick inte att hämta. Det som kontrollerats är +`terraform fmt` och en statisk referenskontroll (88 resurser, 5 +datakällor, inga dinglande referenser, alla `count`-resurser indexerade). +Kör `terraform init -backend=false && terraform validate` innan första +apply. diff --git a/felsokning/infra/aws/outputs.tf b/felsokning/infra/aws/outputs.tf new file mode 100644 index 0000000..efb5c32 --- /dev/null +++ b/felsokning/infra/aws/outputs.tf @@ -0,0 +1,108 @@ +# Det arbetslastlagret behöver, och kartan i klartext. +# +# terraform output karta hela AWS-basen +# terraform output -json for_arbetslast matas in i felsokning/infra/terraform + +output "karta" { + description = "AWS-basen: nät, kluster, data, hemligheter och gränser." + + value = { + region = var.region + miljo = var.miljo + doman = "https://${var.doman}" + + nat = { + vpc = "${aws_vpc.denna.id} (${var.vpc_cidr})" + zoner = local.zoner + publikt = "bara lastbalanserare och NAT" + privat = "noderna — inga publika adresser" + data = "Aurora — ingen väg ut alls" + nat_gateways = var.nat_per_zon ? "en per zon" : "en delad (ej produktion)" + endpoints = "S3 via gateway; ECR, loggar, Secrets Manager, STS och ELB via gränssnitt — trafiken lämnar aldrig nätet" + } + + kluster = { + namn = aws_eks_cluster.denna.name + version = aws_eks_cluster.denna.version + slutpunkt = aws_eks_cluster.denna.endpoint + noder = "${var.noder.minsta}–${var.noder.hogsta} × ${join(", ", var.nod_typer)} (arm64)" + auth = "IRSA — varje tjänstekonto har egen roll; noderna delar inga rättigheter" + loggar = "api, audit, authenticator, controllerManager, scheduler" + metadata = "IMDSv2 obligatoriskt, hoppgräns 1 — podar kan inte låna nodens roll" + } + + databas = { + motor = "Aurora PostgreSQL ${aws_rds_cluster.denna.engine_version} Serverless v2" + kapacitet = "${var.databas.min_kapacitet}–${var.databas.max_kapacitet} ACU × ${var.databas.instanser} instanser" + slutpunkt = aws_rds_cluster.denna.endpoint + backup = "${var.databas.backup_dagar} dagar med PITR ned till sekunden" + kryptering = "KMS med egen nyckel, i vila och över nätet (sslmode=require)" + atkomst = "endast från klustrets noder, i ett subnätlager utan routing ut" + } + + bilagor = { + hink = aws_s3_bucket.bilagor.id + kryptering = "SSE-KMS med egen nyckel" + skydd = "publik åtkomst blockerad, TLS obligatoriskt, versionshantering på" + atkomst = "plattformens roll får läsa och skriva — men aldrig radera" + } + + register = { + for namn, r in aws_ecr_repository.denna : namn => "${r.repository_url} (oföränderliga taggar, sårbarhetsskanning)" + } + + hemligheter = { + databas = aws_secretsmanager_secret.databas.name + app = "${aws_secretsmanager_secret.app.name} — Claude-nyckeln fylls i för hand efter första apply" + lasare = "endast rollen ${aws_iam_role.plattform.name}, via IRSA" + } + + roller = { + plattform = aws_iam_role.plattform.arn + lastbalanserare = aws_iam_role.lastbalanserare.arn + bygg = "${aws_iam_role.bygg.arn} — får publicera till ECR, får inte röra klustret" + drift = "${aws_iam_role.drift.arn} — får röra klustret, får inte publicera bilder" + } + + observation = { + instrumentpanel = "https://${var.region}.console.aws.amazon.com/cloudwatch/home?region=${var.region}#dashboards:name=${aws_cloudwatch_dashboard.denna.dashboard_name}" + larm = var.larm_epost != "" ? "till ${var.larm_epost}" : "INGEN mottagare satt — larm går ingenstans" + flodesloggar = "avvisad trafik, 30 dagar" + } + + kvar_att_gora = compact([ + "Fyll i Claude-nyckeln: aws secretsmanager put-secret-value --secret-id ${aws_secretsmanager_secret.app.name}", + "Kör felsokning/infra/postgres-init.sql mot databasen — schemat med append-only-triggarna.", + "Kör arbetslastlagret: cd ../terraform && terraform apply", + var.larm_epost == "" ? "Sätt larm_epost — annars larmar ingenting till någon." : "", + var.tillatna_api_cidr == ["0.0.0.0/0"] ? "Begränsa tillatna_api_cidr — API-servern är öppen mot hela internet." : "", + ]) + } +} + +# Matas in i arbetslastlagret så det inte behöver upprepa någonting. +output "for_arbetslast" { + description = "Värden som felsokning/infra/terraform behöver." + + value = { + region = var.region + kluster = aws_eks_cluster.denna.name + kluster_slutpunkt = aws_eks_cluster.denna.endpoint + kluster_ca = aws_eks_cluster.denna.certificate_authority[0].data + doman = var.doman + certifikat_arn = aws_acm_certificate_validation.denna.certificate_arn + register = split("/", aws_ecr_repository.denna["guidad-felsokning-web"].repository_url)[0] + plattform_roll = aws_iam_role.plattform.arn + lastbalanserar_roll = aws_iam_role.lastbalanserare.arn + bilage_hink = aws_s3_bucket.bilagor.id + hemlighet_databas = aws_secretsmanager_secret.databas.name + hemlighet_app = aws_secretsmanager_secret.app.name + zon_id = local.zon + } +} + +output "databas_url" { + description = "Anslutningssträng. Hämtas normalt ur Secrets Manager — den här är för engångskörning av schemat." + sensitive = true + value = "postgresql://${aws_rds_cluster.denna.master_username}:${random_password.databas.result}@${aws_rds_cluster.denna.endpoint}:5432/${aws_rds_cluster.denna.database_name}?sslmode=require" +} diff --git a/felsokning/infra/aws/variables.tf b/felsokning/infra/aws/variables.tf index f36ce3c..e71250a 100644 --- a/felsokning/infra/aws/variables.tf +++ b/felsokning/infra/aws/variables.tf @@ -76,9 +76,9 @@ variable "nod_typer" { variable "noder" { description = "Nodgruppens storlek." type = object({ - minsta = number - onskad = number - hogsta = number + minsta = number + onskad = number + hogsta = number }) default = { minsta = 3 @@ -134,9 +134,9 @@ variable "gitea" { någon annanstans. TEXT type = object({ - aktiv = optional(bool, true) - doman = optional(string, "") - runners = optional(number, 2) + aktiv = optional(bool, true) + doman = optional(string, "") + runners = optional(number, 2) }) default = {} }