diff --git a/k8s/apps/paperless/backup-cronjob.yaml b/k8s/apps/paperless/backup-cronjob.yaml new file mode 100644 index 0000000..eabda8f --- /dev/null +++ b/k8s/apps/paperless/backup-cronjob.yaml @@ -0,0 +1,94 @@ +# Nightly: pg_dump the paperless DB + mirror the media PVC into the scoped +# `paperless` MinIO bucket (see minio-provision-paperless-job.yaml). This is a +# BACKUP target, not live storage - paperless-ngx has no native S3 backend, it +# only ever reads/writes the local media PVC directly. +# +# Pinned to talos-cp-3, same as deployment.yaml: media is a ReadWriteOnce +# Longhorn volume with a single replica physically on that node's disk - +# mounting it read-only here from a different node would conflict with the +# live webserver's attachment. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: paperless-backup +spec: + schedule: "0 3 * * *" # 03:00 daily, low-traffic window + jobTemplate: + spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + nodeSelector: + kubernetes.io/hostname: talos-cp-3 + initContainers: + - name: pg-dump + image: postgres:16-alpine + env: + - name: PGHOST + value: paperless-db-rw + - name: PGDATABASE + value: paperless + - name: PGUSER + valueFrom: + secretKeyRef: + name: paperless-db-app + key: username + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: paperless-db-app + key: password + command: + - sh + - -c + - pg_dump --format=custom --file=/backup/paperless-db.dump + volumeMounts: + - name: backup + mountPath: /backup + containers: + - name: mc-mirror + image: minio/mc:latest + env: + - name: ACCESS_KEY + valueFrom: + secretKeyRef: + name: paperless-minio-creds + key: ACCESS_KEY + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: paperless-minio-creds + key: SECRET_KEY + - name: BUCKET + valueFrom: + secretKeyRef: + name: paperless-minio-creds + key: BUCKET + - name: ENDPOINT + valueFrom: + secretKeyRef: + name: paperless-minio-creds + key: ENDPOINT + command: + - /bin/sh + - -c + - | + set -e + mc alias set b "$ENDPOINT" "$ACCESS_KEY" "$SECRET_KEY" + mc cp /backup/paperless-db.dump "b/$BUCKET/db/paperless-db-$(date +%Y%m%d).dump" + mc mirror --overwrite /media "b/$BUCKET/media" + echo "Backup done." + volumeMounts: + - name: backup + mountPath: /backup + - name: media + mountPath: /media + readOnly: true + volumes: + - name: backup + emptyDir: {} + - name: media + persistentVolumeClaim: + claimName: paperless-media + readOnly: true diff --git a/k8s/apps/paperless/configmap.yaml b/k8s/apps/paperless/configmap.yaml new file mode 100644 index 0000000..5dbaa87 --- /dev/null +++ b/k8s/apps/paperless/configmap.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: paperless-config +data: + PAPERLESS_URL: "https://paperless.riotpiao.com" + PAPERLESS_TIME_ZONE: "America/Los_Angeles" + PAPERLESS_OCR_LANGUAGE: "eng" + PAPERLESS_DBHOST: "paperless-db-rw" + PAPERLESS_DBNAME: "paperless" + PAPERLESS_REDIS: "redis://paperless-redis:6379" + # django-allauth generic OIDC provider. The client_id/secret/server_url + # bundle itself lives in the paperless-oidc Secret + # (SOCIALACCOUNT_PROVIDERS_JSON key, composed by authentik-provision.py) - + # env vars can't be split across a ConfigMap + Secret for the same key, so + # this whole value is sourced from the Secret in deployment.yaml instead. + PAPERLESS_APPS: "allauth.socialaccount.providers.openid_connect" diff --git a/k8s/apps/paperless/deployment.yaml b/k8s/apps/paperless/deployment.yaml new file mode 100644 index 0000000..41b34bc --- /dev/null +++ b/k8s/apps/paperless/deployment.yaml @@ -0,0 +1,86 @@ +# Single container runs webserver + consumer + scheduler (paperless-ngx's +# stock entrypoint does this internally) - no need to split into separate +# Deployments. replicas: 1 only: paperless-media is ReadWriteOnce, and the +# consumer polling the media dir doesn't benefit from horizontal scaling here. +# +# Pinned to talos-cp-3: paperless-media's disk physically lives there. Longhorn +# RWO volumes can only be attached from one node at a time, and the nightly +# backup-cronjob.yaml also mounts this same PVC (read-only) to mirror it into +# MinIO - pinning both to the same node avoids a cross-node attach conflict, +# and keeps the 3.5Ti read/write path off the network entirely. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: paperless +spec: + replicas: 1 + strategy: + type: Recreate # ReadWriteOnce media PVC - avoid two pods fighting over it + selector: + matchLabels: + app: paperless + template: + metadata: + labels: + app: paperless + spec: + nodeSelector: + kubernetes.io/hostname: talos-cp-3 + containers: + - name: paperless + image: ghcr.io/paperless-ngx/paperless-ngx:2.13 + ports: + - containerPort: 8000 + envFrom: + - configMapRef: + name: paperless-config + env: + - name: PAPERLESS_DBUSER + valueFrom: + secretKeyRef: + name: paperless-db-app + key: username + - name: PAPERLESS_DBPASS + valueFrom: + secretKeyRef: + name: paperless-db-app + key: password + - name: PAPERLESS_SECRET_KEY + valueFrom: + secretKeyRef: + name: paperless-secrets + key: PAPERLESS_SECRET_KEY + - name: PAPERLESS_ADMIN_USER + valueFrom: + secretKeyRef: + name: paperless-secrets + key: PAPERLESS_ADMIN_USER + - name: PAPERLESS_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: paperless-secrets + key: PAPERLESS_ADMIN_PASSWORD + - name: PAPERLESS_SOCIALACCOUNT_PROVIDERS + valueFrom: + secretKeyRef: + name: paperless-oidc + key: SOCIALACCOUNT_PROVIDERS_JSON + resources: + requests: { cpu: "500m", memory: "1Gi" } + limits: { cpu: "2", memory: "4Gi" } + volumeMounts: + - name: media + mountPath: /usr/src/paperless/media + - name: data + mountPath: /usr/src/paperless/data + - name: consume + mountPath: /usr/src/paperless/consume + volumes: + - name: media + persistentVolumeClaim: + claimName: paperless-media + - name: data + persistentVolumeClaim: + claimName: paperless-data + - name: consume + emptyDir: {} diff --git a/k8s/apps/paperless/ingress.yaml b/k8s/apps/paperless/ingress.yaml new file mode 100644 index 0000000..beee31e --- /dev/null +++ b/k8s/apps/paperless/ingress.yaml @@ -0,0 +1,24 @@ +# Direct nginx ingress to the paperless Service - not routed via the Go +# api-gateway (api.riotpiao.com), which has no WebSocket upgrade support and +# paperless-ngx keeps a long-lived /ws/ connection open for live task status. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: paperless + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "0" # large scanned PDF uploads + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" +spec: + ingressClassName: nginx + rules: + - host: paperless.riotpiao.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: paperless + port: + number: 8000 diff --git a/k8s/apps/paperless/kustomization.yaml b/k8s/apps/paperless/kustomization.yaml new file mode 100644 index 0000000..f7712bc --- /dev/null +++ b/k8s/apps/paperless/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: paperless +resources: + - pvc.yaml + - configmap.yaml + - redis.yaml + - deployment.yaml + - service.yaml + - ingress.yaml + - backup-cronjob.yaml +# postgres: paperless-db CNPG Cluster, deployed by k8s/infra/databases (wave 2, +# before this app at wave 8) - not duplicated here. Same for the paperless-oidc +# and paperless-minio-creds Secrets, written by PostSync provisioning Jobs in +# k8s/infra/iam and k8s/infra/minio respectively. diff --git a/k8s/apps/paperless/pvc.yaml b/k8s/apps/paperless/pvc.yaml new file mode 100644 index 0000000..4fcfb87 --- /dev/null +++ b/k8s/apps/paperless/pvc.yaml @@ -0,0 +1,33 @@ +# Two volumes, deliberately separate storage classes: +# +# - media: the actual documents (originals + OCR'd archive PDFs + thumbnails). +# Grows to multi-TB, lives on the cp-3 USB HDD, single replica (see +# k8s/infra/longhorn/longhorn-paperless-storageclass.yaml). Sized 3500Gi to +# leave headroom on the 4TB disk rather than claiming it to 100%. +# - data: the SQLite classification model + search index. Small (low GB), +# frequently rewritten, and disposable (rebuilds from the DB + media on +# next consume) - stays on the default 3-replica pool instead of the +# single-disk HDD. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: paperless-media +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn-paperless-media + resources: + requests: + storage: 3500Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: paperless-data +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 5Gi diff --git a/k8s/apps/paperless/redis.yaml b/k8s/apps/paperless/redis.yaml new file mode 100644 index 0000000..b95c83e --- /dev/null +++ b/k8s/apps/paperless/redis.yaml @@ -0,0 +1,37 @@ +# Task queue broker + websocket channel layer for paperless-ngx. No PVC: +# queued/scheduled task state is disposable - a lost queue on restart just +# means re-triggering consumption, not data loss (documents themselves live +# on paperless-media). +apiVersion: apps/v1 +kind: Deployment +metadata: + name: paperless-redis +spec: + replicas: 1 + selector: + matchLabels: + app: paperless-redis + template: + metadata: + labels: + app: paperless-redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "250m", memory: "256Mi" } +--- +apiVersion: v1 +kind: Service +metadata: + name: paperless-redis +spec: + selector: + app: paperless-redis + ports: + - port: 6379 + targetPort: 6379 diff --git a/k8s/apps/paperless/service.yaml b/k8s/apps/paperless/service.yaml new file mode 100644 index 0000000..fca138b --- /dev/null +++ b/k8s/apps/paperless/service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: paperless +spec: + selector: + app: paperless + ports: + - port: 8000 + targetPort: 8000 diff --git a/k8s/argocd/apps/60-applications.yaml b/k8s/argocd/apps/60-applications.yaml index b4fcc84..940e845 100644 --- a/k8s/argocd/apps/60-applications.yaml +++ b/k8s/argocd/apps/60-applications.yaml @@ -141,6 +141,35 @@ spec: prune: true selfHeal: true --- +# Document management. Raw manifests (no Helm): postgres is the dedicated +# paperless-db CNPG cluster in k8s/infra/databases (wave 2), redis is +# in-cluster only (no PVC), media lives on the cp-3 USB HDD (see +# k8s/infra/longhorn/longhorn-paperless-storageclass.yaml). OIDC via +# Authentik provisioned by k8s/infra/iam's PostSync job; MinIO backup bucket +# creds provisioned by k8s/infra/minio's PostSync job. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: paperless + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + project: homelab + source: + repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git + targetRevision: main + path: k8s/apps/paperless + destination: + server: https://kubernetes.default.svc + namespace: paperless + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +--- # Consolidated: homarr + homarr-patches → homarr # Helm chart + values + PostSync hook patch (fix-probes-job.yaml) apiVersion: argoproj.io/v1alpha1 diff --git a/k8s/argocd/secrets/paperless-secrets.enc.yaml b/k8s/argocd/secrets/paperless-secrets.enc.yaml new file mode 100644 index 0000000..76bc950 --- /dev/null +++ b/k8s/argocd/secrets/paperless-secrets.enc.yaml @@ -0,0 +1,25 @@ +apiVersion: ENC[AES256_GCM,data:wGA=,iv:Z2Gfzq3aJ9j4fYaeLQolgLb/XELTHrKX9at3vUsMLIw=,tag:yZLZFnltJln5jFoVsyyZ2A==,type:str] +kind: ENC[AES256_GCM,data:bJkV4pNj,iv:0ZT7l0kw9qSoiEMZioRw1aBzzlxBkoXR+hOxoPI7zPU=,tag:EQczPq508Qw1vi/oLCeQpw==,type:str] +metadata: + name: ENC[AES256_GCM,data:sZwTVM41PPaidMwFNRo5RvU=,iv:BHfuwIHng7rkeLK3a69t8cI9QeSfD/3FEXqxby+gBxM=,tag:3zQx8APnE2ZpBKf/ZYCxOA==,type:str] + namespace: ENC[AES256_GCM,data:i7lpoEaZ1oXS,iv:jUYyDPYhf1TV51he/S5MlKPD19Vmz6wfE98y8qFEg3U=,tag:zgHDVlplmW/XPUAKdncfYQ==,type:str] +type: ENC[AES256_GCM,data:2khs1uIg,iv:ET6HcBGyv33fGFlFAl3dkJQB83naHGeaYuWvW1IFhvw=,tag:qIfMeaSwv//7cNWvy8O5dg==,type:str] +stringData: + PAPERLESS_SECRET_KEY: ENC[AES256_GCM,data:kj9DrQYL3cQGJz87FHYlFKy6Muu84Oy/6wnsWAh0w/3MmcTAsCzQvUtKLKKf1U61JTQ=,iv:UffVv78vMHDEWHRFdSKZ/6qyrVD02Nlk0CNxwWd1jTo=,tag:LNMKnFWw7QCjfcKzI1s8ig==,type:str] + PAPERLESS_ADMIN_USER: ENC[AES256_GCM,data:a3bx7z0=,iv:nrU6VXZE74PNXM+Lgg4K/3mhusaQhA2GAgl0GzqAQt8=,tag:x8nswTfkFHR6Ou+JSI9P3Q==,type:str] + PAPERLESS_ADMIN_PASSWORD: ENC[AES256_GCM,data:+SWyAq7D1mNt1TlFOlo2Z0biK8kEn097,iv:an2ZZuXNSig+7mJP7ogHFc5+QvUb+z9pzqcV4xvFLbA=,tag:OaRiVZsnajEjAlc9mDWo9w==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1Q2V6bVBUYmVRR1N5SCtF + eXdTV2dmWjhtMS9lRFEzS0wzbkd6Y3JLMVRVCncydFBjdmJkRCtVUXphR2w0SDlJ + cng2bi9MWlJzTEN2amJrYjRJN2VFcEEKLS0tIEY5cmw2RGhnbzUxZW9FaFJjQmVN + WWcvNlNiYWdwbnNSR1Q4alpDZmFqTFkKh9TOw8ERP9fpx2pKi/Q0b7+OkEv0UC7o + aAIK4Tzvi5dp6y9IWcu9l6PjDLeYWOJ5wr7QABaFNOz82hngxUleDA== + -----END AGE ENCRYPTED FILE----- + recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla + lastmodified: "2026-08-25T16:04:48Z" + mac: ENC[AES256_GCM,data:OXPQXUyn/SDftKH5nRzhqEtnaOb4Gp7etmGojqV4Z01kHnABZ42PPIyiBb8z997eLNZsK6/1bi7nUNyVp4fIuXG4F52aEdMewofOocCHokRnNmz7jzhooK1gScJb2u0eHG3FL5iLONMaGgVpk7BLYO3e0xiDytWGe8BxcuDukPg=,iv:kh0jUwFvO6AUICy2Us1E7YOTEcp3L+ptrGdDWBSpWyc=,tag:qUPFmZ/rpljln37f/NRjjw==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.2 diff --git a/k8s/argocd/secrets/secret-generator.yaml b/k8s/argocd/secrets/secret-generator.yaml index d4741ca..095af74 100644 --- a/k8s/argocd/secrets/secret-generator.yaml +++ b/k8s/argocd/secrets/secret-generator.yaml @@ -23,5 +23,6 @@ files: - homelab-ca-secrets.enc.yaml - loki-secrets.enc.yaml - minio-secrets.enc.yaml + - paperless-secrets.enc.yaml - vault-secrets.enc.yaml - vault-unseal-keys.enc.yaml diff --git a/k8s/infra/databases/kustomization.yaml b/k8s/infra/databases/kustomization.yaml index ebb3151..c32c72e 100644 --- a/k8s/infra/databases/kustomization.yaml +++ b/k8s/infra/databases/kustomization.yaml @@ -1,10 +1,12 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization # Dedicated per-app CNPG clusters. NO top-level `namespace:` — each Cluster -# carries its own ns (iam / temporal / poimen); a transformer would wrongly collapse them. -# poimen ns created by poimen-root app, memory-db deployed into it. +# carries its own ns (iam / temporal / poimen / paperless); a transformer would +# wrongly collapse them. poimen ns created by poimen-root app, memory-db +# deployed into it. paperless ns declared in namespaces.yaml above. resources: - namespaces.yaml - authentik-db.yaml - temporal-db.yaml - memory-db.yaml + - paperless-db.yaml diff --git a/k8s/infra/databases/namespaces.yaml b/k8s/infra/databases/namespaces.yaml index e9b0e7a..97a661b 100644 --- a/k8s/infra/databases/namespaces.yaml +++ b/k8s/infra/databases/namespaces.yaml @@ -1,7 +1,7 @@ # DB clusters are wave 2 — their namespaces must exist first (their apps that # would CreateNamespace run later, w3/w8). Declared here so the databases App -# creates them. authentik/vault/temporal CreateNamespace=true then no-ops. -# poimen namespace created by poimen-root app (wave 7). +# creates them. authentik/vault/temporal/paperless CreateNamespace=true then +# no-ops. poimen namespace created by poimen-root app (wave 7). apiVersion: v1 kind: Namespace metadata: @@ -11,3 +11,8 @@ apiVersion: v1 kind: Namespace metadata: name: temporal +--- +apiVersion: v1 +kind: Namespace +metadata: + name: paperless diff --git a/k8s/infra/databases/paperless-db.yaml b/k8s/infra/databases/paperless-db.yaml new file mode 100644 index 0000000..961ecdc --- /dev/null +++ b/k8s/infra/databases/paperless-db.yaml @@ -0,0 +1,36 @@ +# Dedicated CNPG Postgres for paperless-ngx (GitOps, wave 2 — before the +# paperless app at w8). Same recipe as memory-db: default longhorn storage +# class (3 replicas), 2 instances, 20Gi. +# CNPG generates secret `paperless-db-app` + service `paperless-db-rw` in ns +# paperless; the paperless Deployment reads them locally. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: paperless-db + namespace: paperless + annotations: + argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true +spec: + instances: 2 + imageName: ghcr.io/cloudnative-pg/postgresql:16.2 + bootstrap: + initdb: + database: paperless + owner: app + encoding: UTF8 + localeCollate: C + localeCType: C + enableSuperuserAccess: false + resources: + requests: { memory: "512Mi", cpu: "250m" } + limits: { memory: "2Gi", cpu: "1" } + storage: + size: 20Gi + storageClass: longhorn + affinity: + podAntiAffinityType: preferred + topologyKey: kubernetes.io/hostname + tolerations: + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule diff --git a/k8s/infra/iam/authentik-provision-job.yaml b/k8s/infra/iam/authentik-provision-job.yaml index ca65c8e..5e19754 100644 --- a/k8s/infra/iam/authentik-provision-job.yaml +++ b/k8s/infra/iam/authentik-provision-job.yaml @@ -101,6 +101,20 @@ roleRef: name: authentik-provisioner apiGroup: rbac.authorization.k8s.io --- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: authentik-provisioner + namespace: paperless +subjects: + - kind: ServiceAccount + name: authentik-provisioner + namespace: iam +roleRef: + kind: ClusterRole + name: authentik-provisioner + apiGroup: rbac.authorization.k8s.io +--- apiVersion: batch/v1 kind: Job metadata: diff --git a/k8s/infra/iam/scripts/authentik-provision.py b/k8s/infra/iam/scripts/authentik-provision.py index b1aba19..3e540c7 100644 --- a/k8s/infra/iam/scripts/authentik-provision.py +++ b/k8s/infra/iam/scripts/authentik-provision.py @@ -290,6 +290,17 @@ SERVICES = { "launch_url": "https://homarr.riotpiao.com", "display_name": "Homarr", }, + "paperless": { + # No secret exists yet for paperless - generate + store on first run. + # django-allauth's generic openid_connect provider callback path is + # /accounts/oidc//login/callback/ - provider_id "authentik" + # is set in PAPERLESS_SOCIALACCOUNT_PROVIDERS (see configmap.yaml). + "client_secret_source": ("paperless", "paperless-oidc", "CLIENT_SECRET"), + "generate_if_missing": True, + "redirect_uris": ["https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"], + "launch_url": "https://paperless.riotpiao.com", + "display_name": "Paperless-ngx", + }, } app_pks_for_binding = [] @@ -314,6 +325,32 @@ for name, cfg in SERVICES.items(): else: print(f" {name}: using existing client secret from {ns}/{secret_name}") + if name == "paperless": + # paperless-ngx's django-allauth OIDC config takes client_id/secret + # bundled inside one JSON blob (PAPERLESS_SOCIALACCOUNT_PROVIDERS), not + # discrete env vars - compose it here and store it alongside + # CLIENT_SECRET so the Deployment can source it directly via + # secretKeyRef, no shell wrapper needed. Runs every time (not just on + # generate), so it stays in sync if the client_secret is ever rotated + # by hand. + providers_json = json.dumps({ + "openid_connect": { + "APPS": [{ + "provider_id": "authentik", + "name": "Authentik", + "client_id": "paperless", + "secret": client_secret, + "settings": { + "server_url": "https://authentik.riotpiao.com/application/o/paperless/.well-known/openid-configuration", + }, + }], + }, + }) + kubectl_create_secret("paperless", "paperless-oidc", { + "CLIENT_SECRET": client_secret, + "SOCIALACCOUNT_PROVIDERS_JSON": providers_json, + }) + provider = get_or_create( "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", f"name={name}", diff --git a/k8s/infra/longhorn/kustomization.yaml b/k8s/infra/longhorn/kustomization.yaml index 44be139..bba17bf 100644 --- a/k8s/infra/longhorn/kustomization.yaml +++ b/k8s/infra/longhorn/kustomization.yaml @@ -4,6 +4,7 @@ namespace: longhorn-system resources: - longhorn-storageclass.yaml - longhorn-cnpg-storageclass.yaml # CNPG-specific with postgres UID/GID + - longhorn-paperless-storageclass.yaml # single-replica, cp-3 USB HDD only - longhorn-servicemonitor.yaml - longhorn-taint-toleration.yaml - longhorn-nodes.yaml diff --git a/k8s/infra/longhorn/longhorn-add-disks-job.yaml b/k8s/infra/longhorn/longhorn-add-disks-job.yaml index 2abd800..c94a769 100644 --- a/k8s/infra/longhorn/longhorn-add-disks-job.yaml +++ b/k8s/infra/longhorn/longhorn-add-disks-job.yaml @@ -1,6 +1,15 @@ # PostSync hook Job that adds extra disks to Longhorn nodes. # talos-cp-2 has 4 extra disks mounted at /var/lib/longhorn-disk{1,2,3,4} # that are NOT auto-discovered by Longhorn. +# +# talos-cp-3 additionally gets a tagged disk for paperless-ngx media, backed by +# the 4TB USB HDD (/dev/sdg) — tagged "paperless-media" so only the dedicated +# longhorn-paperless-media StorageClass (diskSelector match) can place replicas +# there, keeping it out of the default 3-replica pool. This patch is inert +# until the Terraform machine-config change mounts the disk at +# /var/lib/longhorn-paperless-media (pending — see terraform.tfvars.local, +# not present in this checkout); Longhorn just reports the disk not-ready +# until the path exists, no harm in applying it early. apiVersion: batch/v1 kind: Job metadata: @@ -83,6 +92,31 @@ spec: echo " ✓ talos-cp-2 already has $CURRENT_DISKS disks configured" fi + echo "Checking talos-cp-3..." + CP3_DISKS=$(kubectl -n longhorn-system get nodes.longhorn.io talos-cp-3 -o json | jq -r '.spec.disks | keys | length') + echo " Current disk count: $CP3_DISKS" + + if [ "$CP3_DISKS" -lt 2 ]; then + echo " Adding paperless-media disk to talos-cp-3..." + kubectl -n longhorn-system patch nodes.longhorn.io talos-cp-3 --type merge -p '{ + "spec": { + "disks": { + "paperless-media": { + "allowScheduling": true, + "diskType": "filesystem", + "evictionRequested": false, + "path": "/var/lib/longhorn-paperless-media", + "storageReserved": 0, + "tags": ["paperless-media"] + } + } + } + }' + echo " ✓ paperless-media disk added to talos-cp-3" + else + echo " ✓ talos-cp-3 already has $CP3_DISKS disks configured" + fi + echo echo "Waiting for disks to be ready..." sleep 10 diff --git a/k8s/infra/longhorn/longhorn-paperless-storageclass.yaml b/k8s/infra/longhorn/longhorn-paperless-storageclass.yaml new file mode 100644 index 0000000..df37642 --- /dev/null +++ b/k8s/infra/longhorn/longhorn-paperless-storageclass.yaml @@ -0,0 +1,22 @@ +# Dedicated StorageClass for paperless-ngx document media, backed by the 4TB +# USB HDD on talos-cp-3 (see longhorn-add-disks-job.yaml). Single disk, single +# node — no Longhorn replica is possible, so numberOfReplicas is 1 by +# necessity, not choice. diskSelector pins placement to the tagged disk only, +# so a volume from this class never lands on cp-3's regular (already +# DiskPressure) default pool. reclaimPolicy is Retain, not Delete: a PVC +# accident here has no replica to fall back on, so an accidental delete must +# not also take the underlying volume with it. +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: longhorn-paperless-media +provisioner: driver.longhorn.io +allowVolumeExpansion: true +reclaimPolicy: Retain +volumeBindingMode: WaitForFirstConsumer +parameters: + numberOfReplicas: "1" + diskSelector: "paperless-media" + nodeSelector: "talos-cp-3" + staleReplicaTimeout: "30" + fsType: "ext4" diff --git a/k8s/infra/minio/kustomization.yaml b/k8s/infra/minio/kustomization.yaml index 1622999..c7f7989 100644 --- a/k8s/infra/minio/kustomization.yaml +++ b/k8s/infra/minio/kustomization.yaml @@ -1,8 +1,14 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: storage +# NOTE: no top-level `namespace:` transformer (see iam/kustomization.yaml for +# the same fix) - minio-provision-paperless-job.yaml's RoleBinding +# deliberately targets namespace paperless (least-privilege access for the +# provisioner ServiceAccount to write Secrets there); a namespace transformer +# would silently rewrite it back to storage, breaking the RBAC. Every resource +# here already sets its own explicit metadata.namespace. resources: - minio-tenant.yaml + - minio-provision-paperless-job.yaml # The operator creates the minio S3/console/headless Services and the # declarative bucket + user from the Tenant spec — no hand-rolled Service or # Bucket/User CRs (those kinds don't exist in the operator CRD set). diff --git a/k8s/infra/minio/minio-provision-paperless-job.yaml b/k8s/infra/minio/minio-provision-paperless-job.yaml new file mode 100644 index 0000000..aacd57b --- /dev/null +++ b/k8s/infra/minio/minio-provision-paperless-job.yaml @@ -0,0 +1,120 @@ +# PostSync hook Job: creates a MinIO IAM user + policy scoped to only the +# `paperless` bucket (least-privilege — reuses root creds nowhere else in the +# cluster), then writes the generated access/secret key into a Secret in the +# `paperless` namespace for the nightly backup CronJob to consume. +# +# Idempotent: re-running never rotates existing credentials — if +# paperless-minio-creds already exists in ns paperless, the script reuses the +# access key it already wrote and just re-asserts the policy/user exist. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: minio-paperless-provisioner + namespace: storage +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: minio-paperless-provisioner +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "create", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: minio-paperless-provisioner + namespace: paperless +subjects: + - kind: ServiceAccount + name: minio-paperless-provisioner + namespace: storage +roleRef: + kind: ClusterRole + name: minio-paperless-provisioner + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: minio-provision-paperless + namespace: storage + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + ttlSecondsAfterFinished: 600 + backoffLimit: 3 + template: + spec: + serviceAccountName: minio-paperless-provisioner + restartPolicy: Never + initContainers: + - name: kubectl-copy + image: bitnami/kubectl:latest + command: ["sh", "-c", "cp $(which kubectl) /shared/kubectl"] + volumeMounts: + - name: shared + mountPath: /shared + containers: + - name: provision + image: minio/mc:latest + volumeMounts: + - name: shared + mountPath: /shared + - name: minio-creds + mountPath: /minio-creds + readOnly: true + command: + - /bin/sh + - -c + - | + set -e + export PATH="/shared:$PATH" + + # minio-creds ships as a shell-sourceable config.env blob + # (`export MINIO_ROOT_USER=... / MINIO_ROOT_PASSWORD=...`), not + # discrete keys — source it directly rather than re-parsing. + . /minio-creds/config.env + + mc alias set m http://minio-cluster-hl.storage.svc.cluster.local:9000 \ + "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" + + echo "Checking for existing paperless-minio-creds secret..." + if kubectl -n paperless get secret paperless-minio-creds >/dev/null 2>&1; then + ACCESS_KEY=$(kubectl -n paperless get secret paperless-minio-creds -o jsonpath='{.data.ACCESS_KEY}' | base64 -d) + SECRET_KEY=$(kubectl -n paperless get secret paperless-minio-creds -o jsonpath='{.data.SECRET_KEY}' | base64 -d) + echo " reusing existing credentials" + else + ACCESS_KEY="paperless" + SECRET_KEY=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 40) + echo " generated new credentials" + fi + + echo "Ensuring MinIO user 'paperless' exists..." + if ! mc admin user info m "$ACCESS_KEY" >/dev/null 2>&1; then + mc admin user add m "$ACCESS_KEY" "$SECRET_KEY" + fi + + echo "Writing scoped policy (paperless bucket only)..." + printf '%s' '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::paperless"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::paperless/*"]}]}' > /tmp/paperless-rw-policy.json + mc admin policy create m paperless-rw /tmp/paperless-rw-policy.json || \ + mc admin policy update m paperless-rw /tmp/paperless-rw-policy.json + mc admin policy attach m paperless-rw --user "$ACCESS_KEY" + + echo "Writing paperless-minio-creds secret (ns paperless)..." + kubectl -n paperless create secret generic paperless-minio-creds \ + --from-literal=ACCESS_KEY="$ACCESS_KEY" \ + --from-literal=SECRET_KEY="$SECRET_KEY" \ + --from-literal=BUCKET=paperless \ + --from-literal=ENDPOINT=http://minio-cluster-hl.storage.svc.cluster.local:9000 \ + --dry-run=client -o yaml | kubectl apply -f - + + echo "Done." + volumes: + - name: shared + emptyDir: {} + - name: minio-creds + secret: + secretName: minio-creds diff --git a/k8s/infra/minio/minio-tenant.yaml b/k8s/infra/minio/minio-tenant.yaml index 9a06d68..b4dcbbf 100644 --- a/k8s/infra/minio/minio-tenant.yaml +++ b/k8s/infra/minio/minio-tenant.yaml @@ -76,6 +76,7 @@ spec: - name: loki-ruler - name: loki-admin - name: vault + - name: paperless # Metrics are exposed at /minio/v2/metrics; scrape via a hand-rolled # ServiceMonitor in the monitoring stack rather than operator auto-wiring diff --git a/terraform/files/coredns/Corefile b/terraform/files/coredns/Corefile index 8d63987..c1300e8 100644 --- a/terraform/files/coredns/Corefile +++ b/terraform/files/coredns/Corefile @@ -33,11 +33,6 @@ rewrite name homarr.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name portainer.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name longhorn.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local - # Kong API gateway. Points at nginx, not kong-proxy, for the same reason as - # the rest: a direct rewrite would skip TLS termination. Pods that don't - # need TLS should call kong-proxy.api.svc.cluster.local instead of using - # this name at all. - rewrite name api.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local kubernetes cluster.local in-addr.arpa ip6.arpa { pods insecure diff --git a/terraform/templates/controlplane.tftpl b/terraform/templates/controlplane.tftpl index 8669820..d8e49fa 100644 --- a/terraform/templates/controlplane.tftpl +++ b/terraform/templates/controlplane.tftpl @@ -47,7 +47,12 @@ machine: install: disk: ${install_disk} image: factory.talos.dev/installer/613e1592b2da41ae5e265e8789429f22e121aab91cb4deb6bc3c0b6262961245:${talos_version} - wipe: true + # false: these are already-installed, running control-plane nodes. wipe: + # true here would only be correct for a first-boot/bare-metal install + # flow; applying it to a live node via apply-config risks the install + # controller treating this as a reinstall request. Live config on all 3 + # CPs already reports wipe: false - keep template and reality in sync. + wipe: false grubUseUKICmdline: true %{ if length(longhorn_disks) > 0 ~} disks: