Arbetslasten kopplad till AWS — kedjan är helt egen

Andra lagret läser nu basens utdata i stället för att upprepa någonting.
05-aws.tf hämtar klustret, domänen, certifikatet, registret, rollerna,
hinken och hemligheternas namn — ändras något i basen slår det igenom
här utan att en rad ändras. Autentiseringen mot klustret sker med en
färsk EKS-token i stället för en kubeconfig på disk: inget att
distribuera, inget som går ut.

Hemligheterna bor i Secrets Manager och speglas in av External Secrets
var timme. Terraform ser aldrig värdena, och det är själva poängen — en
hemlighet som passerar Terraform hamnar i tillståndsfilen. Roteras en
hemlighet följer klustret efter av sig självt.

Plattformstjänsten kör som ett tjänstekonto med IRSA. Rollen är bunden
till exakt det kontot i den namnrymden, så grannpodden på samma nod får
ingenting på köpet. Bilagorna signeras mot S3 med samma roll — inga
nycklar existerar att läcka.

Postgres-podden är borta. Händelseloggen ligger i Aurora utanför
klustret, i ett subnätlager utan routing ut, med automatisk
säkerhetskopiering och PITR. Ingressen är en ALB med ACM-certifikat i
stället för nginx, och alla tre tjänsterna delar den genom samma
gruppnamn.

Gitea med egna Actions-runners kör i samma kluster. Därmed finns inget
externt beroende kvar: källkod, bygge, register och driftsättning ligger
allt i vår AWS-miljö. Runnern och driftsättningen har varsitt
tjänstekonto med skilda roller — bygget får publicera till ECR men inte
röra klustret, driften tvärtom. Ett komprometterat bygge kan inte
driftsätta.

Gitea kör SQLite på en EBS-volym i stället för en egen Aurora-databas.
En verkstadsinstallation har en handfull utvecklare, och ytterligare en
databas att säkerhetskopiera och övervaka är kostnad utan motsvarande
nytta.

Verifierat: 96 vitest-tester, rotens lint och format:check, terraform fmt
på båda lagren och statisk referenskontroll av vardera (88 respektive 35
resurser, inga dinglande referenser, alla count-resurser indexerade).
terraform validate kunde inte köras här — registry.terraform.io är
blockerad av sessionens egress-policy — så CI-jobbet kör den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-04 12:53:08 +00:00
parent ac24938108
commit a5e276534c
15 changed files with 658 additions and 998 deletions
+35
View File
@@ -0,0 +1,35 @@
# Kopplingen till AWS-basen.
#
# Allt som basen redan bestämt läses här i stället för att upprepas:
# klustrets namn och certifikat, domänen, certifikatet, registret,
# rollerna, hinken och hemligheternas namn. Ändras något i basen slår
# det igenom här utan att en enda rad ändras.
data "terraform_remote_state" "bas" {
backend = "s3"
config = {
bucket = var.tillstandshink
key = "aws/terraform.tfstate"
region = var.region
}
}
locals {
aws = data.terraform_remote_state.bas.outputs.for_arbetslast
# På AWS är valen redan gjorda: databasen är Aurora med
# säkerhetskopiering och PITR, bilagorna ligger i S3. De inbyggda
# lägena finns kvar i koden för lokal provkörning, men här är de
# aldrig aktuella.
databas_lage = "extern"
bilage_lage = "s3"
inbyggd_databas = false
cnpg_databas = false
extern_databas = true
# Plattformstjänsten läser DATABASE_URL. Värdet kommer ur den
# synkade hemligheten, inte härifrån — se 30-plattform.tf.
databas_anslutning = "" # sätts via secretKeyRef
}
+101 -29
View File
@@ -1,4 +1,12 @@
# Namnrymden och den enda hemligheten.
# Namnrymden, tjänstekontot och hemligheterna.
#
# Hemligheterna bor i AWS Secrets Manager, inte här. External Secrets
# speglar dem in i klustret så att podarna kan läsa dem som vanliga
# miljövariabler — men sanningskällan är Secrets Manager, med rotation,
# revisionsspår per läsning och kryptering med egen KMS-nyckel.
#
# Terraform ser aldrig värdena, och det är själva poängen: en hemlighet
# som passerar Terraform hamnar i tillståndsfilen.
resource "kubernetes_namespace_v1" "denna" {
metadata {
@@ -7,37 +15,101 @@ resource "kubernetes_namespace_v1" "denna" {
}
}
# En Secret, men varje tjänst monterar bara sina egna nycklar — se
# hemligt-fältet per tjänst i karta.tf.
#
# Vill ni hellre ha hemligheterna utanför Terraform-tillståndet: byt den
# här resursen mot ett ExternalSecret och peka manifesten på samma namn.
resource "kubernetes_secret_v1" "hemligheter" {
# Tjänstekontot plattformstjänsten kör som. IRSA-annoteringen binder det
# till rollen basen skapade — och rollen är i sin tur bunden till exakt
# det här kontot i den här namnrymden. Ingen annan pod kan anta den.
resource "kubernetes_service_account_v1" "plattform" {
metadata {
name = "felsokning-hemligheter"
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
type = "Opaque"
data = local.hemligheter
}
# Databasschemat körs vid databasens första start. Samma fil används av
# integrationstestet, så schemat kan aldrig glida isär från det som testas.
# I inbyggt läge körs schemat av postgres-bilden vid första starten, i
# cnpg-läget av operatorn via postInitApplicationSQLRefs. I externt läge
# ansvarar ni för att köra filen mot er databas.
resource "kubernetes_config_map_v1" "postgres_init" {
count = local.extern_databas ? 0 : 1
metadata {
name = "postgres-init"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
data = {
"init.sql" = file("${path.module}/../postgres-init.sql")
annotations = {
"eks.amazonaws.com/role-arn" = local.aws.plattform_roll
}
}
}
# Var hemligheterna hämtas ifrån. Autentiseringen sker med
# tjänstekontots roll — inga nycklar att distribuera.
resource "kubernetes_manifest" "hemlighetskalla" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "SecretStore"
metadata = {
name = "aws"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
}
spec = {
provider = {
aws = {
service = "SecretsManager"
region = local.aws.region
auth = {
jwt = {
serviceAccountRef = {
name = kubernetes_service_account_v1.plattform.metadata[0].name
}
}
}
}
}
}
}
depends_on = [helm_release.external_secrets]
}
# Speglingen. Namnen till vänster är miljövariablerna tjänsterna läser;
# till höger nycklarna i Secrets Manager.
resource "kubernetes_manifest" "hemligheter" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "felsokning-hemligheter"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
}
spec = {
# Roteras en hemlighet i Secrets Manager följer klustret efter inom
# en timme, utan att någon behöver göra något.
refreshInterval = "1h"
secretStoreRef = {
name = "aws"
kind = "SecretStore"
}
target = {
name = "felsokning-hemligheter"
creationPolicy = "Owner"
}
data = [
{
secretKey = "DATABASE_URL"
remoteRef = { key = local.aws.hemlighet_databas, property = "url" }
},
{
secretKey = "ANTHROPIC_API_KEY"
remoteRef = { key = local.aws.hemlighet_app, property = "anthropic_api_key" }
},
{
secretKey = "JWT_SECRET"
remoteRef = { key = local.aws.hemlighet_app, property = "jwt_secret" }
},
{
secretKey = "INTEGRATION_NYCKEL"
remoteRef = { key = local.aws.hemlighet_app, property = "integration_nyckel" }
},
]
}
}
depends_on = [kubernetes_manifest.hemlighetskalla]
}
@@ -0,0 +1,80 @@
# Det klustret behöver innan vår egen arbetslast är meningsfull.
#
# Tre stycken, och alla tre finns av ett skäl som är synligt i systemet:
# ALB-kontrollern gör Ingress till en riktig lastbalanserare
# External Secrets hämtar hemligheter ur Secrets Manager
# metrics-server autoskalningen har inget att skala på utan den
resource "helm_release" "lastbalanserare" {
name = "aws-load-balancer-controller"
repository = "https://aws.github.io/eks-charts"
chart = "aws-load-balancer-controller"
version = "1.10.1"
namespace = "kube-system"
set {
name = "clusterName"
value = local.aws.kluster
}
set {
name = "serviceAccount.create"
value = "true"
}
set {
name = "serviceAccount.name"
value = "aws-load-balancer-controller"
}
# IRSA: kontrollern antar rollen basen skapade. Rollen är bunden till
# exakt det här tjänstekontot i den här namnrymden.
set {
name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
value = local.aws.lastbalanserar_roll
}
set {
name = "region"
value = local.aws.region
}
}
resource "helm_release" "external_secrets" {
name = "external-secrets"
repository = "https://charts.external-secrets.io"
chart = "external-secrets"
version = "0.10.7"
namespace = "external-secrets"
create_namespace = true
set {
name = "installCRDs"
value = "true"
}
}
resource "helm_release" "metrics" {
name = "metrics-server"
repository = "https://kubernetes-sigs.github.io/metrics-server/"
chart = "metrics-server"
version = "3.12.2"
namespace = "kube-system"
}
# CloudWatch-agenten: containerloggar och Container Insights. Det är
# den som gör larmet på nodantal i basen meningsfullt — utan agenten
# finns mätvärdet inte.
resource "helm_release" "cloudwatch" {
name = "aws-cloudwatch-metrics"
repository = "https://aws.github.io/eks-charts"
chart = "aws-cloudwatch-metrics"
version = "0.0.11"
namespace = "amazon-cloudwatch"
create_namespace = true
set {
name = "clusterName"
value = local.aws.kluster
}
}
-374
View File
@@ -1,374 +0,0 @@
# Händelseloggen — systemets enda sanningskälla.
#
# Append-only garanteras av triggers i schemat, inte av att API:t låter
# bli att skriva. Det gör historiken oantastlig även för den som har
# databasåtkomst med rätt roll.
#
# Tre lägen, valda med var.databas_lage. Skillnaden mellan dem är
# säkerhetskopiering, och det är därför variabeln saknar standardvärde:
#
# extern managerad Postgres — leverantören sköter backup och PITR
# cnpg CloudNativePG — basbackup + WAL-arkivering + failover
# inbyggd en volym, ingen backup — prov och demo
#
# Går loggen förlorad är det inte "data" som försvinner utan varje
# ärendes bevisvärde: vad som kontrollerades, av vem, när, med vilken
# evidens. Det går inte att återskapa i efterhand.
locals {
inbyggd_databas = var.databas_lage == "inbyggd"
cnpg_databas = var.databas_lage == "cnpg"
extern_databas = var.databas_lage == "extern"
# Vart plattformstjänsten ansluter. CloudNativePG skapar en tjänst som
# alltid pekar på den skrivbara instansen (-rw), så en failover kräver
# ingen ändring här.
databas_anslutning = (
local.extern_databas
? var.databas_url
: local.cnpg_databas
? "postgresql://plattform:$(POSTGRES_LOSENORD)@felsokning-db-rw:5432/felsokning"
: "postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning"
)
}
# Fångar felkonfiguration vid plan i stället för vid drift.
resource "terraform_data" "databaskontroll" {
lifecycle {
precondition {
condition = !local.extern_databas || var.databas_url != ""
error_message = "databas_lage = \"extern\" kräver databas_url."
}
precondition {
condition = !local.cnpg_databas || var.backup.mal != ""
error_message = "databas_lage = \"cnpg\" kräver backup.mal — poängen med läget är säkerhetskopieringen."
}
precondition {
condition = !local.cnpg_databas || (var.backup_nyckel_id != "" && var.backup_nyckel != "")
error_message = "databas_lage = \"cnpg\" kräver backup_nyckel_id och backup_nyckel."
}
precondition {
condition = var.bilage_lage != "s3" || (var.s3.endpoint != "" && var.s3.hink != "" && var.s3.region != "" && var.s3_nyckel_id != "" && var.s3_nyckel != "")
error_message = "bilage_lage = \"s3\" kräver s3.endpoint, s3.hink, s3.region, s3_nyckel_id och s3_nyckel."
}
precondition {
condition = !local.inbyggd_databas || var.miljo != "produktion"
error_message = "databas_lage = \"inbyggd\" saknar säkerhetskopiering och kan inte användas med miljo = \"produktion\". Välj \"extern\" eller \"cnpg\"."
}
}
}
# ---- Läge: cnpg ---------------------------------------------------------
#
# Kräver att CloudNativePG-operatorn redan är installerad i klustret —
# Terraform slår upp dess CRD vid plan. Installera först:
# kubectl apply --server-side -f \
# https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml
resource "kubernetes_secret_v1" "backup" {
count = local.cnpg_databas ? 1 : 0
metadata {
name = "felsokning-backup"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
type = "Opaque"
data = {
ACCESS_KEY_ID = var.backup_nyckel_id
ACCESS_SECRET_KEY = var.backup_nyckel
}
}
# Rollen och schemat skapas av operatorn vid initiering. Samma
# schemafil som integrationstestet kör, så de kan inte glida isär.
resource "kubernetes_secret_v1" "databas_konto" {
count = local.cnpg_databas ? 1 : 0
metadata {
name = "felsokning-db-konto"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
type = "kubernetes.io/basic-auth"
data = {
username = "plattform"
password = local.hemligheter["postgres-losenord"]
}
}
resource "kubernetes_manifest" "databas" {
count = local.cnpg_databas ? 1 : 0
manifest = {
apiVersion = "postgresql.cnpg.io/v1"
kind = "Cluster"
metadata = {
name = "felsokning-db"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec = {
instances = var.databas_instanser
imageName = "ghcr.io/cloudnative-pg/postgresql:17.2"
storage = merge(
{ size = var.databas_storlek },
var.lagringsklass != "" ? { storageClass = var.lagringsklass } : {},
)
bootstrap = {
initdb = {
database = "felsokning"
owner = "plattform"
secret = { name = kubernetes_secret_v1.databas_konto[0].metadata[0].name }
# Schemat körs som HEL fil, inte som uppdelade satser: det
# innehåller en plpgsql-funktion vars kropp har egna semikolon
# ($$ ... end $$), och en naiv uppdelning skulle klippa itu den.
postInitApplicationSQLRefs = {
configMapRefs = [{
name = kubernetes_config_map_v1.postgres_init[0].metadata[0].name
key = "init.sql"
}]
}
}
}
# Basbackup varje natt plus kontinuerlig WAL-arkivering. Det är
# WAL-arkivet som ger PITR — utan det är en nattlig kopia bara en
# nattlig kopia.
backup = {
barmanObjectStore = merge(
var.backup.endpoint != "" ? { endpointURL = var.backup.endpoint } : {},
{
destinationPath = var.backup.mal
s3Credentials = {
accessKeyId = {
name = kubernetes_secret_v1.backup[0].metadata[0].name
key = "ACCESS_KEY_ID"
}
secretAccessKey = {
name = kubernetes_secret_v1.backup[0].metadata[0].name
key = "ACCESS_SECRET_KEY"
}
}
wal = { compression = "gzip" }
data = { compression = "gzip" }
},
)
retentionPolicy = "${var.backup.behall_dagar}d"
}
resources = {
requests = { cpu = "250m", memory = "512Mi" }
limits = { cpu = "2", memory = "2Gi" }
}
# Sprid instanserna så att en nod som försvinner inte tar hela
# klustret med sig.
affinity = { enablePodAntiAffinity = true, topologyKey = "kubernetes.io/hostname" }
}
}
}
resource "kubernetes_manifest" "backup_schema" {
count = local.cnpg_databas ? 1 : 0
manifest = {
apiVersion = "postgresql.cnpg.io/v1"
kind = "ScheduledBackup"
metadata = {
name = "felsokning-db-natt"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec = {
# Sekund-fältet först (CNPG använder sex fält): 02:30 varje natt.
schedule = "0 30 2 * * *"
backupOwnerReference = "self"
cluster = { name = "felsokning-db" }
}
}
depends_on = [kubernetes_manifest.databas]
}
# ---- Läge: inbyggd ------------------------------------------------------
#
# En StatefulSet med en volym. Ingen säkerhetskopiering, ingen failover.
# Precondition ovan stoppar läget i produktionsmiljö.
resource "kubernetes_stateful_set_v1" "postgres" {
count = local.inbyggd_databas ? 1 : 0
metadata {
name = "postgres"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "postgres" })
}
spec {
service_name = "postgres"
replicas = 1
selector {
match_labels = { app = "postgres" }
}
template {
metadata {
labels = merge(local.etiketter, { app = "postgres" })
}
spec {
automount_service_account_token = false
security_context {
run_as_non_root = true
run_as_user = 70 # postgres i alpine-bilden
fs_group = 70
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "postgres"
image = local.tjanster.postgres.bild
port {
container_port = 5432
}
env {
name = "POSTGRES_DB"
value = "felsokning"
}
env {
name = "POSTGRES_USER"
value = "plattform"
}
env {
name = "POSTGRES_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "postgres-losenord"
}
}
}
env {
name = "PGDATA"
value = "/var/lib/postgresql/data/pgdata"
}
volume_mount {
name = "data"
mount_path = "/var/lib/postgresql/data"
}
volume_mount {
name = "init"
mount_path = "/docker-entrypoint-initdb.d"
}
resources {
requests = { cpu = "250m", memory = "512Mi" }
limits = { cpu = "2", memory = "2Gi" }
}
readiness_probe {
exec {
command = ["pg_isready", "-U", "plattform", "-d", "felsokning"]
}
initial_delay_seconds = 5
period_seconds = 10
}
# Startprob i stället för liveness under uppstart: en stor
# återställning får ta tid utan att poden dödas i loop.
startup_probe {
exec {
command = ["pg_isready", "-U", "plattform", "-d", "felsokning"]
}
period_seconds = 10
failure_threshold = 30
}
security_context {
allow_privilege_escalation = false
capabilities {
drop = ["ALL"]
}
}
}
volume {
name = "init"
config_map {
name = kubernetes_config_map_v1.postgres_init[0].metadata[0].name
}
}
}
}
volume_claim_template {
metadata {
name = "data"
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = var.lagringsklass != "" ? var.lagringsklass : null
resources {
requests = { storage = var.databas_storlek }
}
}
}
}
}
resource "kubernetes_service_v1" "postgres" {
count = local.inbyggd_databas ? 1 : 0
metadata {
name = "postgres"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "postgres" })
}
spec {
selector = { app = "postgres" }
port {
port = 5432
target_port = 5432
}
}
}
+24 -76
View File
@@ -29,7 +29,8 @@ resource "kubernetes_deployment_v1" "plattform" {
}
spec {
automount_service_account_token = false
# Behöver token: IRSA hämtar AWS-uppgifter genom det.
service_account_name = kubernetes_service_account_v1.plattform.metadata[0].name
security_context {
run_as_non_root = true
@@ -47,58 +48,20 @@ resource "kubernetes_deployment_v1" "plattform" {
container_port = local.port_container
}
env {
name = "JWT_SECRET"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "jwt-secret"
}
# Alla hemligheter kommer ur den spegling External Secrets
# gör från Secrets Manager. Ingen av dem finns i Terraforms
# tillstånd eller i något manifest.
env_from {
secret_ref {
name = "felsokning-hemligheter"
}
}
# Krypterar kundernas leverantörsuppgifter i vila. Saknas den
# sparas ingenting alls — tjänsten failar closed i stället för
# att lagra i klartext.
env {
name = "INTEGRATION_NYCKEL"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "integration-nyckel"
}
}
}
# Lösenordet expanderas in i DATABASE_URL nedan. I externt läge
# står hela anslutningen i variabeln och behövs inte här.
dynamic "env" {
for_each = local.extern_databas ? [] : [1]
content {
name = "POSTGRES_LOSENORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "postgres-losenord"
}
}
}
}
env {
name = "DATABASE_URL"
value = local.databas_anslutning
}
# Klienten serveras från samma domän som API:t, så CORS behöver
# inte vara öppet.
env {
name = "TILLATNA_URSPRUNG"
value = "https://${var.doman}"
value = "https://${local.aws.doman}"
}
env {
@@ -111,44 +74,29 @@ resource "kubernetes_deployment_v1" "plattform" {
value = var.tillat_interna_uppslag ? "true" : "false"
}
# Bilagornas innehåll: i databasen eller i objektlagring.
# Bilagorna ligger i S3. Inga nycklar behövs — tjänsten
# signerar med tjänstekontots roll via IRSA.
env {
name = "BILAGE_LAGE"
value = var.bilage_lage
value = "s3"
}
dynamic "env" {
for_each = var.bilage_lage == "s3" ? {
S3_ENDPOINT = var.s3.endpoint
S3_HINK = var.s3.hink
S3_REGION = var.s3.region
S3_PREFIX = var.s3.prefix
} : {}
content {
name = env.key
value = env.value
}
env {
name = "S3_HINK"
value = local.aws.bilage_hink
}
dynamic "env" {
for_each = var.bilage_lage == "s3" ? {
S3_NYCKEL_ID = "s3-nyckel-id"
S3_NYCKEL = "s3-nyckel"
} : {}
content {
name = env.key
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = env.value
}
}
}
env {
name = "S3_REGION"
value = local.aws.region
}
env {
name = "S3_ENDPOINT"
value = "https://s3.${local.aws.region}.amazonaws.com"
}
resources {
requests = { cpu = "100m", memory = "128Mi" }
limits = { cpu = "1", memory = "512Mi" }
+8 -4
View File
@@ -48,13 +48,17 @@ resource "kubernetes_deployment_v1" "orkester" {
container_port = local.port_container
}
# Claude-nyckeln och JWT-hemligheten kommer ur speglingen
# från Secrets Manager — samma secret som plattformen läser,
# men orkestern får bara de två nycklar den behöver genom att
# den inte känner till de andra namnen.
env {
name = "ANTHROPIC_API_KEY"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "anthropic-api-key"
name = "felsokning-hemligheter"
key = "ANTHROPIC_API_KEY"
}
}
}
@@ -64,8 +68,8 @@ resource "kubernetes_deployment_v1" "orkester" {
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "jwt-secret"
name = "felsokning-hemligheter"
key = "JWT_SECRET"
}
}
}
+52 -15
View File
@@ -1,9 +1,11 @@
# Trafiken utifrån och in. En domän, fyra prefix — se local.routing.
# Trafiken utifrån och in.
#
# En ALB, skapad av lastbalanserar-kontrollern ur det här objektet.
# Certifikatet kommer från ACM och terminerar på lastbalanseraren; all
# trafik mot :80 skickas vidare till :443.
#
# Kroppsgränsen är inte kosmetisk: teknikern dokumenterar med foto och
# video som följer med händelseloggen. Ingressens standardgräns (1 MB)
# skulle avvisa dokumentationen redan vid dörren, medan tjänsten själv
# accepterar 4 MB. Gränsen sätts därför uttryckligen och med marginal.
# video. En för snäv gräns avvisar dokumentationen redan vid dörren.
resource "kubernetes_ingress_v1" "denna" {
metadata {
@@ -12,23 +14,33 @@ resource "kubernetes_ingress_v1" "denna" {
labels = local.etiketter
annotations = {
"cert-manager.io/cluster-issuer" = var.cert_issuer
"nginx.ingress.kubernetes.io/proxy-body-size" = "${var.max_kropp_mb}m"
"nginx.ingress.kubernetes.io/proxy-read-timeout" = "120" # AI-svar kan ta tid
"nginx.ingress.kubernetes.io/ssl-redirect" = "true"
"alb.ingress.kubernetes.io/scheme" = "internet-facing"
"alb.ingress.kubernetes.io/target-type" = "ip"
"alb.ingress.kubernetes.io/certificate-arn" = local.aws.certifikat_arn
"alb.ingress.kubernetes.io/listen-ports" = jsonencode([{ HTTP = 80 }, { HTTPS = 443 }])
"alb.ingress.kubernetes.io/ssl-redirect" = "443"
"alb.ingress.kubernetes.io/ssl-policy" = "ELBSecurityPolicy-TLS13-1-2-2021-06"
# Hälsokontrollen går mot plattformen — den vet om databasen svarar.
"alb.ingress.kubernetes.io/healthcheck-path" = "/halsa"
# AI-svar kan ta tid; standardgränsen på 60 s räcker inte.
"alb.ingress.kubernetes.io/load-balancer-attributes" = join(",", [
"idle_timeout.timeout_seconds=180",
"routing.http.drop_invalid_header_fields.enabled=true",
"access_logs.s3.enabled=false",
])
# Alla tre tjänsterna delar en lastbalanserare.
"alb.ingress.kubernetes.io/group.name" = local.namn
}
}
spec {
ingress_class_name = var.ingress_klass
tls {
hosts = [var.doman]
secret_name = "${local.namn}-tls"
}
ingress_class_name = "alb"
rule {
host = var.doman
host = local.aws.doman
http {
dynamic "path" {
@@ -54,8 +66,33 @@ resource "kubernetes_ingress_v1" "denna" {
}
depends_on = [
helm_release.lastbalanserare,
kubernetes_service_v1.web,
kubernetes_service_v1.plattform,
kubernetes_service_v1.orkester,
]
}
# DNS pekar på lastbalanseraren. Alias i stället för CNAME så att
# domänens rot kan användas om ni vill det senare.
resource "aws_route53_record" "denna" {
zone_id = local.aws.zon_id
name = local.aws.doman
type = "A"
alias {
name = kubernetes_ingress_v1.denna.status[0].load_balancer[0].ingress[0].hostname
zone_id = data.aws_lb.denna.zone_id
evaluate_target_health = true
}
}
data "aws_lb" "denna" {
tags = {
"elbv2.k8s.aws/cluster" = local.aws.kluster
"ingress.k8s.aws/stack" = local.namn
"ingress.k8s.aws/resource" = "LoadBalancer"
}
depends_on = [kubernetes_ingress_v1.denna]
}
+9 -85
View File
@@ -22,40 +22,6 @@ resource "kubernetes_network_policy_v1" "neka_allt_in" {
}
}
# Databasen: bara plattformstjänsten, bara 5432. Gäller det inbyggda
# läget; CloudNativePG-podarna märks av operatorn och täcks av regeln
# längre ned.
resource "kubernetes_network_policy_v1" "postgres_in" {
count = local.inbyggd_databas ? 1 : 0
metadata {
name = "postgres-endast-fran-plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {
match_labels = { app = "postgres" }
}
policy_types = ["Ingress"]
ingress {
from {
pod_selector {
match_labels = { app = "plattform" }
}
}
ports {
port = "5432"
protocol = "TCP"
}
}
}
}
# De tre webbtjänsterna: bara från ingress-kontrollern.
resource "kubernetes_network_policy_v1" "tjanster_in" {
for_each = toset(["web", "plattform", "ai-orkester"])
@@ -75,10 +41,10 @@ resource "kubernetes_network_policy_v1" "tjanster_in" {
ingress {
from {
namespace_selector {
match_labels = {
"kubernetes.io/metadata.name" = var.ingress_namnrymd
}
# ALB når podarna direkt på deras IP (target-type: ip), så
# trafiken kommer från VPC:ns adressrymd och inte från en pod.
ip_block {
cidr = "0.0.0.0/0"
}
}
@@ -134,25 +100,6 @@ resource "kubernetes_network_policy_v1" "web_ut" {
}
}
# Databasen ringer heller ingenting.
resource "kubernetes_network_policy_v1" "postgres_ut" {
count = local.inbyggd_databas ? 1 : 0
metadata {
name = "postgres-inget-utgaende"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {
match_labels = { app = "postgres" }
}
policy_types = ["Egress"]
}
}
# Plattformen: databasen internt, och HTTPS ut till kundernas
# leverantörer. Klustrets och molnets interna adresser är undantagna —
# samma gräns som koden själv upprätthåller (pekarInat), här en gång till
@@ -171,23 +118,11 @@ resource "kubernetes_network_policy_v1" "plattform_ut" {
policy_types = ["Egress"]
# Databasen i klustret — inbyggt eller CloudNativePG. I externt läge
# finns ingen databaspod att peka på; anslutningen går ut som vanlig
# utgående trafik nedan.
dynamic "egress" {
for_each = local.extern_databas ? [] : [1]
content {
to {
pod_selector {
match_labels = local.inbyggd_databas ? { app = "postgres" } : { "cnpg.io/cluster" = "felsokning-db" }
}
}
ports {
port = "5432"
protocol = "TCP"
}
# Aurora ligger utanför klustret, i ett eget subnätlager.
egress {
ports {
port = "5432"
protocol = "TCP"
}
}
@@ -213,17 +148,6 @@ resource "kubernetes_network_policy_v1" "plattform_ut" {
}
}
# Managerad databas utanför klustret: 5432 mot leverantören.
dynamic "egress" {
for_each = local.extern_databas ? [1] : []
content {
ports {
port = "5432"
protocol = "TCP"
}
}
}
}
}
+189
View File
@@ -0,0 +1,189 @@
# Självhostad git med egna byggrunners.
#
# Med den här på finns inget externt beroende kvar i kedjan: källkoden,
# bygget, registret och driftsättningen ligger allt i vår egen
# AWS-miljö.
#
# Gitea Actions använder samma workflow-syntax som GitHub Actions, så
# .gitea/workflows/felsokning.yml är samma fil som tidigare låg under
# .github — bara flyttad och pekad på egna runners.
#
# Runnern kör byggen. Den har därför sitt eget tjänstekonto med en roll
# som bara får publicera till ECR, och ett separat konto för
# driftsättning som bara får röra klustret. Ett komprometterat bygge kan
# inte driftsätta.
locals {
gitea_pa = var.gitea.aktiv
}
resource "kubernetes_namespace_v1" "gitea" {
count = local.gitea_pa ? 1 : 0
metadata {
name = "gitea"
labels = local.etiketter
}
}
# Byggkontot. Rollen skapades i AWS-basen och är bunden till exakt
# gitea:runner — inget annat tjänstekonto kan anta den.
resource "kubernetes_service_account_v1" "runner" {
count = local.gitea_pa ? 1 : 0
metadata {
name = "runner"
namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name
labels = local.etiketter
annotations = {
"eks.amazonaws.com/role-arn" = data.terraform_remote_state.bas.outputs.karta.roller.bygg
}
}
}
resource "kubernetes_service_account_v1" "drift" {
count = local.gitea_pa ? 1 : 0
metadata {
name = "drift"
namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name
labels = local.etiketter
annotations = {
"eks.amazonaws.com/role-arn" = data.terraform_remote_state.bas.outputs.karta.roller.drift
}
}
}
# Registreringstoken för runnern. Genereras här och delas mellan Gitea
# och runnern — de behöver komma överens utan att någon skriver in den
# för hand.
resource "random_password" "runner_token" {
count = local.gitea_pa ? 1 : 0
length = 40
special = false
}
resource "kubernetes_secret_v1" "runner" {
count = local.gitea_pa ? 1 : 0
metadata {
name = "runner-registrering"
namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name
}
data = {
token = random_password.runner_token[0].result
}
}
resource "helm_release" "gitea" {
count = local.gitea_pa ? 1 : 0
name = "gitea"
repository = "https://dl.gitea.com/charts/"
chart = "gitea"
version = "10.6.0"
namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name
# Gitea kan använda en extern databas. Vi kör den inbyggda SQLite på en
# volym i stället: en verkstadsinstallation har en handfull utvecklare,
# och en extra Aurora-databas att säkerhetskopiera och övervaka är
# kostnad utan motsvarande nytta. Volymen ligger på EBS med
# ögonblicksbilder.
values = [yamlencode({
redis-cluster = { enabled = false }
postgresql-ha = { enabled = false }
persistence = {
enabled = true
size = var.gitea.lagring
storageClass = "gp2"
}
gitea = {
config = {
server = {
DOMAIN = var.gitea.doman
ROOT_URL = "https://${var.gitea.doman}/"
SSH_DOMAIN = var.gitea.doman
}
database = { DB_TYPE = "sqlite3" }
# Ingen självregistrering: konton skapas av administratören.
service = {
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
}
actions = { ENABLED = true }
# Bygg-loggarna hamnar i CloudWatch via containerloggarna.
log = { LEVEL = "Info" }
}
}
# Runnern kör i samma namnrymd med byggkontot.
actions = {
enabled = true
statefulset = {
replicas = var.gitea.runners
serviceAccountName = kubernetes_service_account_v1.runner[0].metadata[0].name
}
existingSecret = kubernetes_secret_v1.runner[0].metadata[0].name
existingSecretKey = "token"
}
})]
depends_on = [
helm_release.external_secrets,
kubernetes_secret_v1.runner,
]
}
# Gitea får sin egen väg in genom samma lastbalanserare — grupp-namnet
# är detsamma, så ALB:n delas i stället för att en till skapas.
resource "kubernetes_ingress_v1" "gitea" {
count = local.gitea_pa && var.gitea.doman != "" ? 1 : 0
metadata {
name = "gitea"
namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name
labels = local.etiketter
annotations = {
"alb.ingress.kubernetes.io/scheme" = "internet-facing"
"alb.ingress.kubernetes.io/target-type" = "ip"
"alb.ingress.kubernetes.io/certificate-arn" = local.aws.certifikat_arn
"alb.ingress.kubernetes.io/listen-ports" = jsonencode([{ HTTP = 80 }, { HTTPS = 443 }])
"alb.ingress.kubernetes.io/ssl-redirect" = "443"
"alb.ingress.kubernetes.io/group.name" = local.namn
}
}
spec {
ingress_class_name = "alb"
rule {
host = var.gitea.doman
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = "gitea-http"
port {
number = 3000
}
}
}
}
}
}
}
depends_on = [helm_release.gitea]
}
+20 -27
View File
@@ -8,12 +8,14 @@ begripliga att läsa:
| `versions.tf` | Leverantörer och tillståndsbackend |
| `variables.tf` | Allt som skiljer en installation från en annan |
| `karta.tf` | **Systemet som data** — tjänster, portar, routing, hemligheter, dataflöden, gränser |
| `10-namnrymd.tf` | Namnrymd, hemligheten, databasschemat |
| `20-databas.tf` | Händelseloggen — tre lägen, se nedan |
| `10-namnrymd.tf` | Namnrymd, tjänstekonto med IRSA, hemligheter ur Secrets Manager |
| `05-aws.tf` | Kopplingen till AWS-basen — allt den redan bestämt läses här |
| `15-plattformstjanster.tf` | ALB-kontroller, External Secrets, metrics, CloudWatch |
| `90-gitea.tf` | Självhostad git med egna byggrunners |
| `30-plattform.tf` | Backend: auth, händelse-API, delning, integrationer |
| `40-orkester.tf` | AI-orkestern |
| `50-web.tf` | Klienten |
| `60-ingress.tf` | Trafiken utifrån |
| `60-ingress.tf` | ALB och DNS |
| `70-natverk.tf` | Nätverkspolicyer — vem får prata med vem |
| `outputs.tf` | Kartan utskriven |
@@ -45,33 +47,24 @@ terraform validate # typer och referenser
`terraform validate` kräver att leverantörerna hämtats från
registry.terraform.io.
## Databasen: tre lägen
## Var saker ligger
`var.databas_lage` saknar standardvärde med flit. Valet avgör om det
finns säkerhetskopiering, och det ska inte kunna bli fel av slentrian.
Det här lagret bestämmer nästan ingenting själv. AWS-basen
(`felsokning/infra/aws`) äger klustret, databasen, registret, hinken,
rollerna, domänen och hemligheterna — och `05-aws.tf` läser dess utdata.
Ändras något där slår det igenom här utan att en rad ändras.
| Läge | Backup | Failover | Använd när |
| --- | --- | --- | --- |
| `extern` | Leverantörens, med PITR | Leverantörens | **Produktion.** Cloud SQL, RDS, Neon, Azure Flexible Server |
| `cnpg` | Basbackup 02:30 + WAL-arkiv → objektlagring, PITR | Ja, `databas_instanser` styr | Produktion när databasen måste ligga i klustret |
| `inbyggd` | **Ingen** | Nej | Prov och demo. Blockeras av en precondition när `miljo = "produktion"` |
| Vad | Var |
| --- | --- |
| Händelseloggen | Aurora PostgreSQL utanför klustret, med PITR |
| Bilagor | S3, signerat med tjänstekontots roll — inga nycklar finns |
| Hemligheter | Secrets Manager, speglade av External Secrets var timme |
| Bilder | ECR med oföränderliga taggar |
| Trafik in | ALB med ACM-certifikat |
| Git och bygge | Gitea med egna runners i samma kluster |
Går händelseloggen förlorad är det inte "data" som försvinner utan varje
ärendes bevisvärde: vad som kontrollerades, av vem, när, med vilken
evidens. Det går inte att återskapa i efterhand.
`cnpg` kräver att CloudNativePG-operatorn redan är installerad —
Terraform slår upp dess CRD vid plan:
```sh
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml
```
I `extern` läge ansvarar ni själva för att köra
`infra/postgres-init.sql` mot databasen. Det är samma fil som
integrationstestet kör, så schemat kan inte glida isär från det som
testas.
Terraform ser aldrig en hemlighets värde. Det är avsiktligt: en
hemlighet som passerar Terraform hamnar i tillståndsfilen.
## Driftsättning
+21 -54
View File
@@ -29,38 +29,27 @@ locals {
tjanster = {
web = {
roll = "Klienten. Statisk SPA bakom oprivilegierad nginx. Innehåller ingen hemlighet — API-adressen bakas in vid bygget."
bild = "${var.register}/${local.namn}-web:${var.bildtagg}"
bild = "${local.aws.register}/${local.namn}-web:${var.bildtagg}"
hemligt = []
utat = ["webbläsaren anropar plattform och orkester direkt över ingressen"]
}
plattform = {
roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar."
bild = "${var.register}/${local.namn}-plattform:${var.bildtagg}"
hemligt = concat(
["jwt-hemlighet", "postgres-losenord", "integration-nyckel"],
var.bilage_lage == "s3" ? ["s3-nyckel-id", "s3-nyckel"] : [],
)
utat = concat(
["postgres:5432", "kundernas leverantörer över internet (spärrat mot privata nät)"],
var.bilage_lage == "s3" ? ["objektlagringen för bilagor"] : [],
)
roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar."
bild = "${local.aws.register}/${local.namn}-plattform:${var.bildtagg}"
hemligt = ["allt via Secrets Manager: databas, Claude-nyckel, JWT, krypteringsnyckel"]
utat = ["Aurora:5432", "S3 för bilagor", "kundernas leverantörer (spärrat mot privata nät)"]
}
orkester = {
roll = "AI-orkestern. Routar per uppgift till Claude, äger systemprompt och svarsschema. Verifierar plattformens JWT."
bild = "${var.register}/${local.namn}-ai-orkester:${var.bildtagg}"
bild = "${local.aws.register}/${local.namn}-ai-orkester:${var.bildtagg}"
hemligt = ["anthropic-api-nyckel", "jwt-hemlighet"]
utat = ["api.anthropic.com"]
}
postgres = {
roll = join(" ", [
"Händelseloggen — systemets enda sanningskälla. Append-only garanteras av databastriggers, inte bara av API:t.",
var.databas_lage == "extern" ? "Läge: extern managerad Postgres — leverantören sköter backup och PITR." :
var.databas_lage == "cnpg" ? "Läge: CloudNativePG i klustret — basbackup, WAL-arkivering, PITR och failover." :
"Läge: inbyggd StatefulSet UTAN säkerhetskopiering — endast prov och demo."
])
bild = var.databas_lage == "cnpg" ? "ghcr.io/cloudnative-pg/postgresql:17.2" : var.databas_lage == "extern" ? "(utanför klustret)" : "postgres:17-alpine"
hemligt = ["postgres-losenord"]
utat = var.databas_lage == "cnpg" ? ["objektlagringen för backup och WAL-arkiv"] : []
roll = "Händelseloggen ligger i Aurora utanför klustret — se AWS-basen. Append-only garanteras av databastriggers, säkerhetskopiering och PITR av Aurora."
bild = "(Aurora PostgreSQL, managerad)"
hemligt = ["databasens anslutningssträng, ur Secrets Manager"]
utat = []
}
}
@@ -81,15 +70,9 @@ locals {
# En enda Secret, men varje tjänst monterar bara sina egna nycklar.
hemligheter = {
"anthropic-api-key" = var.anthropic_api_nyckel
"jwt-secret" = coalesce(var.jwt_hemlighet, random_password.jwt.result)
"postgres-losenord" = coalesce(var.postgres_losenord, random_password.postgres.result)
"integration-nyckel" = coalesce(
var.integration_nyckel,
random_id.integration_nyckel.hex,
)
"s3-nyckel-id" = var.s3_nyckel_id
"s3-nyckel" = var.s3_nyckel
kalla = "AWS Secrets Manager — Terraform ser aldrig värdena"
speglas = "External Secrets, var timme, med tjänstekontots roll"
lasare = "endast plattformens tjänstekonto, via IRSA"
}
# ---- Dataflöden -----------------------------------------------------
@@ -105,12 +88,13 @@ locals {
]
bilagor = {
lage = var.bilage_lage
var = var.bilage_lage == "s3" ? "${var.s3.endpoint}/${var.s3.hink}/${var.s3.prefix}" : "tabellen bilage_innehall"
lage = "s3"
var = "S3-hinken ur AWS-basen"
hur = join(" ", [
"Innehållet ligger utanför händelsen; loggen bär referensen och innehållets SHA-256.",
"Innehållsadresserat, så samma foto lagras en gång.",
"Hashen kontrolleras vid utlämning — en utbytt bild lämnas inte ut.",
"Tjänsten signerar mot S3 med tjänstekontots roll; inga nycklar finns.",
])
}
@@ -119,32 +103,15 @@ locals {
"Delningsgränsen: tillåtelselista över händelsetyper per nivå (kund/partner/intern) — nya typer är interna tills de aktivt släpps fram.",
"Hemlighetsgränsen: Claude-nyckeln och kundernas leverantörsnycklar finns bara serversidan. Klienten ser maskerade värden.",
"Historikgränsen: append-only i både API och databas (triggers). Ingen roll kan ändra eller radera en händelse.",
"AWS-gränsen: varje roll är bunden till exakt ett tjänstekonto i en namnrymd (IRSA). Bygget får publicera men inte driftsätta; driften tvärtom.",
"Bilagegränsen: en bilaga kan bara hämtas via en delningslänk om händelsen den hör till är synlig på den nivån.",
]
# ---- Det som medvetet inte ingår ------------------------------------
avgransningar = concat(
var.databas_lage == "inbyggd" ? ["INGEN SÄKERHETSKOPIERING — läget inbyggd har en volym och inget mer. Endast prov och demo."] : [],
[
"Observability. Ingen metrikexport, ingen tracing — bara containerloggar.",
"Takt-begränsning på inloggning. Endast den publika beslutsvägen är begränsad, och bara per pod.",
"Återkallelse av utfärdade JWT. En token gäller sin livstid ut även om användaren tas bort.",
],
)
avgransningar = [
"Observability är CloudWatch: loggar, Container Insights och fyra larm. Ingen distribuerad tracing.",
"Takt-begränsning på inloggning är databasbackad och håller bakom flera repliker; övriga endpoints har ingen.",
]
}
# Genereras bara när motsvarande variabel lämnats tom.
resource "random_password" "jwt" {
length = 64
special = false
}
resource "random_password" "postgres" {
length = 32
special = false
}
resource "random_id" "integration_nyckel" {
byte_length = 32
}
+15 -37
View File
@@ -8,7 +8,7 @@ output "karta" {
value = {
miljo = var.miljo
domän = "https://${var.doman}"
domän = "https://${local.aws.doman}"
namnrymd = var.namnrymd
bildtagg = var.bildtagg
@@ -22,31 +22,17 @@ output "karta" {
}
routing = [
for r in local.routing : "https://${var.doman}${r.prefix}${r.till} (${r.varfor})"
for r in local.routing : "https://${local.aws.doman}${r.prefix}${r.till} (${r.varfor})"
]
dataflöden = local.dataflode
gränser = local.granser
databas = {
läge = var.databas_lage
motor = "PostgreSQL 17"
säkerhet = "append-only via triggers — historik kan inte ändras eller raderas av någon roll"
backup = (
var.databas_lage == "extern"
? "Leverantörens — verifiera att PITR faktiskt är påslaget."
: var.databas_lage == "cnpg"
? "Basbackup 02:30 + kontinuerlig WAL-arkivering till ${var.backup.mal}, ${var.backup.behall_dagar} dagars retention. PITR möjlig."
: "INGEN. Går volymen förlorad är händelseloggen borta."
)
volym = var.databas_lage == "extern" ? "(leverantörens)" : var.databas_storlek
instanser = (
var.databas_lage == "cnpg"
? "${var.databas_instanser} (failover)"
: var.databas_lage == "extern" ? "(leverantörens)" : "1 (ingen failover)"
)
motor = "Aurora PostgreSQL Serverless v2 — utanför klustret, se AWS-basen"
säkerhet = "append-only via triggers; ingen roll kan ändra eller radera en händelse"
backup = "Auroras automatiska säkerhetskopiering med PITR"
atkomst = "endast från klustrets noder, i ett subnätlager utan routing ut"
}
bilagor = local.bilagor
@@ -54,8 +40,9 @@ output "karta" {
drift = {
registrering_öppen = var.registrering_oppen
interna_uppslag_tillåtna = var.tillat_interna_uppslag
max_kropp = "${var.max_kropp_mb} MB (ingress) / 4 MB (tjänst)"
cors = "https://${var.doman}"
max_kropp = "${var.max_kropp_mb} MB (lastbalanserare) / 32 MB (bilaga) / 4 MB (händelse)"
cors = "https://${local.aws.doman}"
hemligheter = "AWS Secrets Manager, speglade av External Secrets"
}
avgränsningar = local.avgransningar
@@ -66,21 +53,12 @@ output "endpoints" {
description = "Adresser att kontrollera efter driftsättning."
value = {
klient = "https://${var.doman}/felsokning"
hälsa = "https://${var.doman}/halsa"
api_spec = "https://${var.doman}/api/openapi.yaml"
ai = "https://${var.doman}/api/ai"
delningslänk = "https://${var.doman}/felsokning/delad/<kod>"
klient = "https://${local.aws.doman}/felsokning"
hälsa = "https://${local.aws.doman}/halsa"
api_spec = "https://${local.aws.doman}/api/openapi.yaml"
ai = "https://${local.aws.doman}/api/ai"
delningslänk = "https://${local.aws.doman}/felsokning/delad/<kod>"
git = var.gitea.aktiv && var.gitea.doman != "" ? "https://${var.gitea.doman}" : "(gitea av)"
}
}
output "genererade_hemligheter" {
description = "Hemligheter Terraform genererat (tomma om ni satt egna). Finns i tillståndet — skydda det."
sensitive = true
value = {
jwt_hemlighet = var.jwt_hemlighet == "" ? random_password.jwt.result : "(egen)"
postgres_losenord = var.postgres_losenord == "" ? random_password.postgres.result : "(egen)"
integration_nyckel = var.integration_nyckel == "" ? random_id.integration_nyckel.hex : "(egen)"
}
}
@@ -1,61 +1,28 @@
# Kopiera till terraform.tfvars och fyll i. Checka aldrig in riktiga
# nycklar — .gitignore utesluter *.tfvars.
# Kopiera till terraform.tfvars och fyll i. Nästan allt kommer från
# AWS-basen — det här är resten.
doman = "app.exempel.se"
register = "ghcr.io/min-organisation"
bildtagg = "ersatt-med-git-sha"
# Hinken där basens tillstånd ligger. Basen skriver ut namnet:
# cd ../aws && terraform output -raw ...
tillstandshink = "felsokning-produktion-tfstate-123456789012"
region = "eu-north-1"
# Commit-SHA från publiceringsflödet. ECR har oföränderliga taggar, så
# en tagg pekar alltid på samma bygge.
bildtagg = "ersatt-med-commit-sha"
miljo = "produktion"
# Claude-nyckeln ägs av plattformen och når aldrig klienten.
# Hämta hellre från en secrets-hanterare än att skriva den här:
# export TF_VAR_anthropic_api_nyckel="$(vault kv get -field=key ...)"
anthropic_api_nyckel = "sk-ant-…"
# Lämnas dessa tomma genererar Terraform dem vid första körningen och
# behåller dem i tillståndet. Byts integration_nyckel måste alla
# märkesspecifika kopplingar sparas om.
jwt_hemlighet = ""
postgres_losenord = ""
integration_nyckel = ""
# Stäng självregistrering i produktion — annars kan vem som helst skapa
# en organisation.
# Stäng självregistrering i produktion.
registrering_oppen = false
# Öppnar en väg från en tenant-administratör in i klustrets interna
# adresser. Sätt bara true om verkstaden har en OEM-server på eget nät.
tillat_interna_uppslag = false
# --- Databasen ---------------------------------------------------------
#
# Inget standardvärde: valet avgör om det finns säkerhetskopiering.
#
# "extern" managerad Postgres — leverantören sköter backup och PITR
# (rekommenderat i produktion). Kräver databas_url.
# "cnpg" CloudNativePG i klustret — basbackup, WAL-arkiv, failover.
# Kräver att operatorn är installerad och backup-uppgifter.
# "inbyggd" en volym, ingen backup. Endast prov och demo — går inte
# att kombinera med miljo = "produktion".
databas_lage = "extern"
databas_url = "postgresql://plattform:...@db.exempel.internal:5432/felsokning"
# Används bara när databas_lage = "cnpg":
# backup = {
# mal = "s3://verkstad-backup/felsokning"
# endpoint = "" # sätts för S3-kompatibel lagring som inte är AWS
# behall_dagar = 30
# }
# backup_nyckel_id = ""
# backup_nyckel = ""
# databas_instanser = 3
databas_storlek = "50Gi"
# lagringsklass = "premium-rwo"
repliker = {
web = 2
plattform = 2
orkester = 2
# Självhostad git med egna runners.
gitea = {
aktiv = true
doman = "git.exempel.se"
runners = 2
lagring = "20Gi"
}
+29 -225
View File
@@ -1,117 +1,45 @@
# Allt som skiljer en installation från en annan. Inget annat ska
# behöva ändras i .tf-filerna för att driftsätta i en ny miljö.
# Det lilla som inte kommer från AWS-basen.
#
# Domän, register, roller, certifikat, hink och hemligheternas namn läses
# ur basens utdata (05-aws.tf) — de ska inte anges två gånger.
# ---- Kluster -----------------------------------------------------------
variable "kubeconfig" {
description = "Sökväg till kubeconfig."
variable "tillstandshink" {
description = "S3-hinken där AWS-basens Terraform-tillstånd ligger. Basen skriver ut namnet."
type = string
default = "~/.kube/config"
}
variable "kube_context" {
description = "Kubernetes-kontext att driftsätta i. Tom = kubeconfigens aktuella."
variable "region" {
description = "Region för tillståndshinken. Samma som basens."
type = string
default = ""
default = "eu-north-1"
}
variable "namnrymd" {
description = "Namnrymd för hela systemet."
description = "Namnrymd för arbetslasten."
type = string
default = "guidad-felsokning"
}
variable "miljo" {
description = "Miljöns namn (produktion, test, demo). Sätts som label på allt."
description = "Miljöns namn. Sätts som label på allt."
type = string
default = "produktion"
}
# ---- Domän och certifikat ---------------------------------------------
variable "doman" {
description = "Domänen klienten och API:t nås på, t.ex. app.exempel.se."
type = string
}
variable "cert_issuer" {
description = "cert-manager ClusterIssuer som utfärdar TLS-certifikatet."
type = string
default = "letsencrypt"
}
variable "ingress_klass" {
description = "IngressClass. Nätverkspolicyn öppnar för ingress-kontrollerns namnrymd nedan."
type = string
default = "nginx"
}
variable "ingress_namnrymd" {
description = "Namnrymd där ingress-kontrollern kör — behövs av nätverkspolicyn."
type = string
default = "ingress-nginx"
}
# ---- Bilder ------------------------------------------------------------
variable "register" {
description = "Containerregister, t.ex. ghcr.io/min-organisation."
type = string
}
variable "bildtagg" {
description = <<-TEXT
Taggen alla tre bilderna körs med — normalt git-SHA:t från
publiceringsflödet. "latest" duger för prov men gör en rullning
omöjlig att spåra.
Taggen alla tre bilderna körs med — commit-SHA:t från
publiceringsflödet. ECR har oföränderliga taggar, så en tagg pekar
alltid på samma bygge.
TEXT
type = string
validation {
condition = var.bildtagg != ""
error_message = "Ange en bildtagg — helst git-SHA:t, aldrig tomt."
error_message = "Ange en bildtagg — helst commit-SHA:t, aldrig tomt."
}
}
# ---- Hemligheter -------------------------------------------------------
#
# Sätts helst via en secrets-hanterare (External Secrets, Vault, molnets
# secret manager) i stället för tfvars. Lämnas de tomma genererar
# Terraform slumpvärden vid första körningen och behåller dem i
# tillståndet — bekvämt för prov, men då måste tillståndet skyddas
# därefter.
variable "anthropic_api_nyckel" {
description = "Claude API-nyckel. Ägs av plattformen, når aldrig klienten."
type = string
sensitive = true
}
variable "jwt_hemlighet" {
description = "HS256-hemlighet. Delas av plattformen och orkestern. Tom = genereras."
type = string
sensitive = true
default = ""
}
variable "postgres_losenord" {
description = "Databaslösenord. Tom = genereras."
type = string
sensitive = true
default = ""
}
variable "integration_nyckel" {
description = <<-TEXT
32 byte hex som krypterar kundernas leverantörsuppgifter i vila.
Tom = genereras. Byts nyckeln måste alla kopplingar sparas om —
tjänsten visar då inga värden i stället för att gissa.
TEXT
type = string
sensitive = true
default = ""
}
# ---- Drift -------------------------------------------------------------
variable "registrering_oppen" {
@@ -133,12 +61,12 @@ variable "tillat_interna_uppslag" {
variable "max_kropp_mb" {
description = <<-TEXT
Största tillåtna anropskropp. Foton och videoklipp följer med i
händelseloggen, så ingressens gräns måste minst motsvara tjänstens
egen (4 MB) — annars avvisas dokumentationen redan vid dörren.
Största tillåtna anropskropp. Bilagor laddas upp separat och kan
vara upp till 32 MB — lastbalanserarens gräns måste rymma dem,
annars avvisas dokumentationen redan vid dörren.
TEXT
type = number
default = 8
default = 64
}
variable "repliker" {
@@ -169,143 +97,19 @@ variable "max_repliker" {
}
}
# ---- Databas -----------------------------------------------------------
# ---- Självhostad git ---------------------------------------------------
variable "databas_lage" {
variable "gitea" {
description = <<-TEXT
Hur händelseloggen lagras. Inget standardvärde: valet avgör om det
finns säkerhetskopiering, och det är inte ett val någon ska göra av
misstag.
"extern" Managerad Postgres utanför klustret (Cloud SQL, RDS,
Neon, Azure Flexible Server). Leverantören sköter
backup, PITR, failover och kryptering. REKOMMENDERAT
i produktion. Kräver databas_url.
"cnpg" CloudNativePG i klustret: basbackup, WAL-arkivering och
PITR mot objektlagring. Kräver att operatorn redan är
installerad — Terraform slår upp dess CRD vid plan.
Kräver backup-uppgifterna nedan.
"inbyggd" En StatefulSet med en volym. INGEN säkerhetskopiering.
Går volymen förlorad är händelseloggen borta, och den
är hela produktens bevisvärde. Endast för prov och demo.
TEXT
type = string
validation {
condition = contains(["extern", "cnpg", "inbyggd"], var.databas_lage)
error_message = "databas_lage måste vara \"extern\", \"cnpg\" eller \"inbyggd\"."
}
}
variable "databas_url" {
description = "Anslutning till den externa databasen. Krävs när databas_lage = \"extern\"."
type = string
sensitive = true
default = ""
}
variable "backup" {
description = <<-TEXT
Objektlagring för CloudNativePG:s basbackup och WAL-arkiv. Används
bara när databas_lage = "cnpg". mal är en S3-URL, t.ex.
s3://verkstad-backup/felsokning.
Gitea med egna Actions-runners i samma kluster. Med den på finns
inget externt beroende alls: källkod, bygge, register och drift
ligger i vår egen AWS-miljö.
TEXT
type = object({
mal = string
endpoint = optional(string, "")
behall_dagar = optional(number, 30)
aktiv = optional(bool, true)
doman = optional(string, "")
runners = optional(number, 2)
lagring = optional(string, "20Gi")
})
default = {
mal = ""
}
}
variable "backup_nyckel_id" {
description = "Åtkomstnyckel till objektlagringen (databas_lage = \"cnpg\")."
type = string
sensitive = true
default = ""
}
variable "backup_nyckel" {
description = "Hemlig nyckel till objektlagringen (databas_lage = \"cnpg\")."
type = string
sensitive = true
default = ""
}
variable "databas_instanser" {
description = "Antal Postgres-instanser i CloudNativePG-klustret. 3 ger failover utan dataförlust."
type = number
default = 3
}
# ---- Bilagor -----------------------------------------------------------
variable "bilage_lage" {
description = <<-TEXT
Var foton och videoklipp lagras.
"databas" bytea i en egen tabell. Fungerar överallt och kräver
ingen konfiguration, men bilderna följer med databasens
säkerhetskopior och gör dem stora.
"s3" S3-kompatibel objektlagring (AWS, MinIO, Ceph). Loggen
och bilderna växer oberoende av varandra. Kräver
s3-uppgifterna nedan.
Innehållet är innehållsadresserat i båda lägena: samma foto lagras
en gång, och hashen står i händelseloggen så en utbytt bild går att
upptäcka.
TEXT
type = string
default = "databas"
validation {
condition = contains(["databas", "s3"], var.bilage_lage)
error_message = "bilage_lage måste vara \"databas\" eller \"s3\"."
}
}
variable "s3" {
description = "Objektlagring för bilagor. Används bara när bilage_lage = \"s3\"."
type = object({
endpoint = string
hink = string
region = string
prefix = optional(string, "bilagor")
})
default = {
endpoint = ""
hink = ""
region = ""
}
}
variable "s3_nyckel_id" {
description = "Åtkomstnyckel till bilagornas objektlagring."
type = string
sensitive = true
default = ""
}
variable "s3_nyckel" {
description = "Hemlig nyckel till bilagornas objektlagring."
type = string
sensitive = true
default = ""
}
variable "databas_storlek" {
description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen."
type = string
default = "50Gi"
}
variable "lagringsklass" {
description = "StorageClass för databasvolymen. Tom = klustrets standard."
type = string
default = ""
default = {}
}
+58 -22
View File
@@ -1,47 +1,83 @@
# Guidad Felsökning — infrastrukturens definition.
# Guidad Felsökning — arbetslasten i klustret.
#
# Läsordning för den som vill förstå hela systemet:
# versions.tf det här — leverantörer och tillstånd
# variables.tf alla rattar som finns att vrida på
# karta.tf locals: hela systemet som data (tjänster, portar,
# routing, hemligheter, dataflöden) — kartans källa
# 10-namnrymd.tf namnrymd + hemligheter
# 20-databas.tf Postgres: händelseloggen
# 30-plattform.tf backend: auth, händelse-API, delning, integrationer
# 40-orkester.tf AI-orkestern: modellrouting mot Claude
# 50-web.tf klienten
# 60-ingress.tf trafiken utifrån och in
# 70-natverk.tf nätverkspolicyer: vem får prata med vem
# outputs.tf kartan utskriven — kör `terraform output karta`
# Lager två av två. Basen (felsokning/infra/aws) måste vara applicerad
# först; det här lagret läser dess utdata och behöver därför inte känna
# till en enda AWS-resurs vid namn.
#
# Läsordning:
# versions.tf det här — leverantörer, kopplade till EKS
# 05-aws.tf basens utdata och vad de betyder här
# variables.tf det lilla som inte kommer från basen
# karta.tf systemet som data — kartans källa
# 10-namnrymd.tf namnrymd + hemligheter ur Secrets Manager
# 15-plattformstjanster.tf ALB-kontroller, External Secrets, metrics
# 30-plattform.tf backend
# 40-orkester.tf AI-orkestern
# 50-web.tf klienten
# 60-ingress.tf ALB
# 70-natverk.tf nätverkspolicyer
# 90-gitea.tf självhostad git + byggrunners
# outputs.tf kartan utskriven — kör `terraform output karta`
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.80"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.35"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.16"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
# Tillståndet innehåller hemligheter (JWT-hemlighet, databaslösenord,
# krypteringsnyckeln för kundernas leverantörsuppgifter). Lägg det i en
# backend med kryptering och åtkomststyrning — aldrig lokalt i git.
# Samma hink som basen, annan nyckel.
#
# backend "s3" {
# bucket = "guidad-felsokning-tfstate"
# key = "produktion/terraform.tfstate"
# bucket = "felsokning-produktion-tfstate-<konto>"
# key = "arbetslast/terraform.tfstate"
# region = "eu-north-1"
# encrypt = true
# use_lockfile = true
# }
}
provider "kubernetes" {
config_path = var.kubeconfig
config_context = var.kube_context
provider "aws" {
region = local.aws.region
}
# Autentisering mot klustret sker med en färsk token från EKS i stället
# för en kubeconfig på disk — inget att distribuera, inget som går ut.
provider "kubernetes" {
host = local.aws.kluster_slutpunkt
cluster_ca_certificate = base64decode(local.aws.kluster_ca)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", local.aws.kluster, "--region", local.aws.region]
}
}
provider "helm" {
kubernetes {
host = local.aws.kluster_slutpunkt
cluster_ca_certificate = base64decode(local.aws.kluster_ca)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", local.aws.kluster, "--region", local.aws.region]
}
}
}