AWS-basen: allt eget, inget GitHub
Guidad Felsökning får en egen AWS-grund i felsokning/infra/aws — nät, kluster, databas, objektlagring, register, hemligheter, domän och observation. Inget GHCR, ingen extern byggtjänst. Två lager i ordning, och 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, eftersom kubernetes-leverantören måste konfigureras med uppgifter som inte finns förrän klustret existerar. Nätet ligger 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 hänger då inte på att en säkerhetsgrupp är rätt konfigurerad. VPC-endpoints för S3, ECR, loggar, Secrets Manager, STS och ELB gör att bilddragningar och loggar aldrig lämnar nätet, vilket både är säkrare och sänker NAT-notan rejält. IRSA i stället för nodroller: plattformens tjänstekonto har en roll bunden till exakt en namnrymd och ett tjänstekonto, så grannpodden på samma nod får ingenting på köpet. IMDSv2 med hoppgräns 1 hindrar dessutom en pod från att låna nodens roll via metadatatjänsten. Bygge och drift har delade rättigheter. Byggrollen får publicera till ECR men inte röra klustret; driftrollen tvärtom. Ett komprometterat bygge kan därför inte driftsätta. Aurora PostgreSQL Serverless v2 med 30 dagars säkerhetskopiering och PITR ned till sekunden — det som gör den duglig för händelseloggen är inte prestandan utan att förlorad logg annars hade tagit varje ärendes bevisvärde med sig. S3-hinken för bilagor har versionshantering, SSE-KMS, TLS-krav och blockerad publik åtkomst; plattformens roll får läsa och skriva men aldrig radera, eftersom bilagorna hör till en append-only logg. Larmen är fyra och ett av dem 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. Verifierat: terraform fmt och en statisk referenskontroll (88 resurser, 5 datakällor, inga dinglande referenser). terraform validate kunde inte köras här — registry.terraform.io är blockerad av sessionens egress-policy — så CI-jobbet kör den i stället. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -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}:*"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user