variable "image_tag" { type = string default = "latest" description = "tasks image tag. Running tag is set by the Woodpecker deploy (kubectl set image)." } variable "postgresql_host" { type = string } variable "tls_secret_name" { type = string sensitive = true } locals { namespace = "tasks" # ADR-0002: built on GHA from the public GitHub mirror, pushed to ghcr # (public package — anonymous pulls). Running tag is managed by the # Woodpecker deploy (kubectl set image); the image ref below is # ignore_changes'd (KEEL_IGNORE_IMAGE), so this base only matters on # (re)create. image = "ghcr.io/viktorbarzin/tasks:${var.image_tag}" labels = { app = "tasks" } } resource "kubernetes_namespace" "tasks" { metadata { name = local.namespace labels = { tier = local.tiers.aux "istio-injection" = "disabled" # Opt into Keel auto-update (inject-keel-annotations ClusterPolicy). "keel.sh/enrolled" = "true" } } lifecycle { # KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label. ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]] } } # App secrets — seed these in Vault before applying: # secret/tasks # fernet_key — Fernet key encrypting the per-user Nextcloud app passwords # stored in the Connected Accounts table (tasks ADR-0002). # # DB: CNPG database `tasks` (created in dbaas, null_resource.pg_tasks_db); # role password managed via the Vault database engine — see # static-creds/pg-tasks. Alembic runs migrations on app startup (no init # container needed). resource "kubernetes_manifest" "external_secret" { field_manager { force_conflicts = true } manifest = { apiVersion = "external-secrets.io/v1" kind = "ExternalSecret" metadata = { name = "tasks-secrets" namespace = local.namespace } spec = { refreshInterval = "15m" secretStoreRef = { name = "vault-kv" kind = "ClusterSecretStore" } target = { name = "tasks-secrets" template = { metadata = { annotations = { "reloader.stakater.com/match" = "true" } } } } data = [ { secretKey = "TASKS_FERNET_KEY", remoteRef = { key = "tasks", property = "fernet_key" } }, ] } } depends_on = [kubernetes_namespace.tasks] } # DB credentials from Vault database engine (7-day rotation). # Builds the asyncpg DSN consumed by the FastAPI app as TASKS_DB_DSN. # Pre-req in dbaas: CNPG cluster has DB `tasks`, role `tasks`, and Vault # role `static-creds/pg-tasks`. resource "kubernetes_manifest" "db_external_secret" { field_manager { force_conflicts = true } manifest = { apiVersion = "external-secrets.io/v1" kind = "ExternalSecret" metadata = { name = "tasks-db-creds" namespace = local.namespace } spec = { refreshInterval = "15m" secretStoreRef = { name = "vault-database" kind = "ClusterSecretStore" } target = { name = "tasks-db-creds" template = { metadata = { annotations = { "reloader.stakater.com/match" = "true" } } data = { TASKS_DB_DSN = "postgresql+asyncpg://tasks:{{ .password }}@${var.postgresql_host}:5432/tasks" DB_PASSWORD = "{{ .password }}" } } } data = [{ secretKey = "password" remoteRef = { key = "static-creds/pg-tasks" property = "password" } }] } } depends_on = [kubernetes_namespace.tasks] } resource "kubernetes_deployment" "tasks" { metadata { name = "tasks" namespace = kubernetes_namespace.tasks.metadata[0].name labels = merge(local.labels, { tier = local.tiers.aux }) annotations = { # Reloader restarts the pod when tasks-secrets / tasks-db-creds change # (both carry reloader.stakater.com/match=true) — required because the # DB password rotates every 7 days and is read only at startup. "reloader.stakater.com/search" = "true" } } spec { # Single leader: the CalDAV sync engine wants one writer per user's # sync-token cursor; the SPA is served by the same process. replicas = 1 strategy { type = "Recreate" } selector { match_labels = local.labels } template { metadata { labels = local.labels annotations = { # Prometheus scrapes the service-endpoints (annotations live on the # Service below); the pod annotations here let the kubernetes-pods # SD job also discover /metrics directly. "prometheus.io/scrape" = "true" "prometheus.io/path" = "/metrics" "prometheus.io/port" = "8000" } } spec { image_pull_secrets { name = "registry-credentials" } container { name = "tasks" image = local.image port { container_port = 8000 } # TASKS_FERNET_KEY via tasks-secrets; TASKS_DB_DSN via tasks-db-creds. env_from { secret_ref { name = "tasks-secrets" } } env_from { secret_ref { name = "tasks-db-creds" } } # Wall-clock zone for all-day due dates (DUE;VALUE=DATE) and the # Today/Scheduled smart views. env { name = "TASKS_LOCAL_TZ" value = "Europe/Sofia" } # SECURITY INVARIANT — DEV_USER must NEVER be set here. It is the # dev-only identity fallback: when present the backend treats every # request as that user, bypassing the Authentik forward-auth # identity (X-authentik-username) entirely. Production identity # comes ONLY from the header Traefik/Authentik injects. readiness_probe { http_get { path = "/healthz" port = 8000 } initial_delay_seconds = 5 period_seconds = 10 } liveness_probe { http_get { path = "/healthz" port = 8000 } initial_delay_seconds = 30 period_seconds = 30 } resources { requests = { cpu = "100m", memory = "384Mi" } limits = { memory = "384Mi" } } } } } } lifecycle { ignore_changes = [ spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1 metadata[0].annotations["keel.sh/policy"], metadata[0].annotations["keel.sh/trigger"], metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2 metadata[0].annotations["keel.sh/match-tag"], spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE — Woodpecker deploy sets the running tag metadata[0].annotations["kubernetes.io/change-cause"], metadata[0].annotations["deployment.kubernetes.io/revision"], spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1 ] } depends_on = [ kubernetes_manifest.external_secret, kubernetes_manifest.db_external_secret, ] } resource "kubernetes_service" "tasks" { metadata { name = "tasks" namespace = kubernetes_namespace.tasks.metadata[0].name labels = local.labels annotations = { # Prometheus kubernetes-service-endpoints SD scrapes /metrics here. "prometheus.io/scrape" = "true" "prometheus.io/path" = "/metrics" "prometheus.io/port" = "8000" } } spec { type = "ClusterIP" selector = local.labels port { name = "http" port = 8000 target_port = 8000 } } } # Kyverno ClusterPolicy `sync-tls-secret` auto-clones the wildcard TLS # secret into every namespace, so we don't need a setup_tls_secret module. module "ingress" { source = "../../modules/kubernetes/ingress_factory" # auth = "required": Authentik forward-auth gates EVERY request — the app # has no login of its own and blindly trusts the X-authentik-username # header the outpost injects, so Authentik is the only thing standing # between strangers and everyone's tasks. Do NOT relax this tier (tasks # design decision #3; pairs with the NetworkPolicy below, SEC-1). auth = "required" dns_type = "proxied" namespace = kubernetes_namespace.tasks.metadata[0].name name = "tasks" port = 8000 tls_secret_name = var.tls_secret_name } # --- NetworkPolicy: scoped pod ingress (security-review finding SEC-1). --- # The app trusts X-authentik-username unconditionally, so its ENTIRE auth # model depends on requests only ever arriving through Traefik (where the # Authentik forward-auth middleware sets that header). Any pod that could # reach the pod IP directly could spoof the header and read/write anyone's # tasks — hence ingress is restricted to: # - TCP/8000 from the traefik namespace (user traffic, post-forward-auth); # - TCP/8000 from the monitoring namespace (Prometheus /metrics scrape). # The cluster has no default-deny, so this NP only takes effect inside the # tasks ns — pods elsewhere remain unaffected. (Same shape as # chrome-service's chrome-service-ws-ingress.) resource "kubernetes_network_policy_v1" "tasks_ingress" { metadata { name = "tasks-ingress" namespace = kubernetes_namespace.tasks.metadata[0].name } spec { pod_selector { match_labels = local.labels } policy_types = ["Ingress"] ingress { from { namespace_selector { match_labels = { "kubernetes.io/metadata.name" = "traefik" } } } ports { port = "8000" protocol = "TCP" } } ingress { from { namespace_selector { match_labels = { "kubernetes.io/metadata.name" = "monitoring" } } } ports { port = "8000" protocol = "TCP" } } } }