feat: add paperless-ngx with OIDC, CNPG db, cp-3 HDD media, MinIO backup
Fixes controlplane.tftpl's install.wipe:true (should be false, live CPs already run false) and syncs coredns Corefile back to what's actually deployed (drops an unrolled-out, stale Kong-era rewrite).
This commit is contained in:
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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: {}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: paperless
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: paperless
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
@@ -141,6 +141,35 @@ spec:
|
|||||||
prune: true
|
prune: true
|
||||||
selfHeal: 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
|
# Consolidated: homarr + homarr-patches → homarr
|
||||||
# Helm chart + values + PostSync hook patch (fix-probes-job.yaml)
|
# Helm chart + values + PostSync hook patch (fix-probes-job.yaml)
|
||||||
apiVersion: argoproj.io/v1alpha1
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,5 +23,6 @@ files:
|
|||||||
- homelab-ca-secrets.enc.yaml
|
- homelab-ca-secrets.enc.yaml
|
||||||
- loki-secrets.enc.yaml
|
- loki-secrets.enc.yaml
|
||||||
- minio-secrets.enc.yaml
|
- minio-secrets.enc.yaml
|
||||||
|
- paperless-secrets.enc.yaml
|
||||||
- vault-secrets.enc.yaml
|
- vault-secrets.enc.yaml
|
||||||
- vault-unseal-keys.enc.yaml
|
- vault-unseal-keys.enc.yaml
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
# Dedicated per-app CNPG clusters. NO top-level `namespace:` — each Cluster
|
# Dedicated per-app CNPG clusters. NO top-level `namespace:` — each Cluster
|
||||||
# carries its own ns (iam / temporal / poimen); a transformer would wrongly collapse them.
|
# carries its own ns (iam / temporal / poimen / paperless); a transformer would
|
||||||
# poimen ns created by poimen-root app, memory-db deployed into it.
|
# wrongly collapse them. poimen ns created by poimen-root app, memory-db
|
||||||
|
# deployed into it. paperless ns declared in namespaces.yaml above.
|
||||||
resources:
|
resources:
|
||||||
- namespaces.yaml
|
- namespaces.yaml
|
||||||
- authentik-db.yaml
|
- authentik-db.yaml
|
||||||
- temporal-db.yaml
|
- temporal-db.yaml
|
||||||
- memory-db.yaml
|
- memory-db.yaml
|
||||||
|
- paperless-db.yaml
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# DB clusters are wave 2 — their namespaces must exist first (their apps that
|
# 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
|
# would CreateNamespace run later, w3/w8). Declared here so the databases App
|
||||||
# creates them. authentik/vault/temporal CreateNamespace=true then no-ops.
|
# creates them. authentik/vault/temporal/paperless CreateNamespace=true then
|
||||||
# poimen namespace created by poimen-root app (wave 7).
|
# no-ops. poimen namespace created by poimen-root app (wave 7).
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
@@ -11,3 +11,8 @@ apiVersion: v1
|
|||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
name: temporal
|
name: temporal
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: paperless
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -101,6 +101,20 @@ roleRef:
|
|||||||
name: authentik-provisioner
|
name: authentik-provisioner
|
||||||
apiGroup: rbac.authorization.k8s.io
|
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
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -290,6 +290,17 @@ SERVICES = {
|
|||||||
"launch_url": "https://homarr.riotpiao.com",
|
"launch_url": "https://homarr.riotpiao.com",
|
||||||
"display_name": "Homarr",
|
"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/<provider_id>/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 = []
|
app_pks_for_binding = []
|
||||||
@@ -314,6 +325,32 @@ for name, cfg in SERVICES.items():
|
|||||||
else:
|
else:
|
||||||
print(f" {name}: using existing client secret from {ns}/{secret_name}")
|
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(
|
provider = get_or_create(
|
||||||
"/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
|
"/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
|
||||||
f"name={name}",
|
f"name={name}",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace: longhorn-system
|
|||||||
resources:
|
resources:
|
||||||
- longhorn-storageclass.yaml
|
- longhorn-storageclass.yaml
|
||||||
- longhorn-cnpg-storageclass.yaml # CNPG-specific with postgres UID/GID
|
- 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-servicemonitor.yaml
|
||||||
- longhorn-taint-toleration.yaml
|
- longhorn-taint-toleration.yaml
|
||||||
- longhorn-nodes.yaml
|
- longhorn-nodes.yaml
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
# PostSync hook Job that adds extra disks to Longhorn nodes.
|
# 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}
|
# talos-cp-2 has 4 extra disks mounted at /var/lib/longhorn-disk{1,2,3,4}
|
||||||
# that are NOT auto-discovered by Longhorn.
|
# 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
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
@@ -83,6 +92,31 @@ spec:
|
|||||||
echo " ✓ talos-cp-2 already has $CURRENT_DISKS disks configured"
|
echo " ✓ talos-cp-2 already has $CURRENT_DISKS disks configured"
|
||||||
fi
|
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
|
||||||
echo "Waiting for disks to be ready..."
|
echo "Waiting for disks to be ready..."
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
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:
|
resources:
|
||||||
- minio-tenant.yaml
|
- minio-tenant.yaml
|
||||||
|
- minio-provision-paperless-job.yaml
|
||||||
# The operator creates the minio S3/console/headless Services and the
|
# The operator creates the minio S3/console/headless Services and the
|
||||||
# declarative bucket + user from the Tenant spec — no hand-rolled Service or
|
# 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).
|
# Bucket/User CRs (those kinds don't exist in the operator CRD set).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -76,6 +76,7 @@ spec:
|
|||||||
- name: loki-ruler
|
- name: loki-ruler
|
||||||
- name: loki-admin
|
- name: loki-admin
|
||||||
- name: vault
|
- name: vault
|
||||||
|
- name: paperless
|
||||||
|
|
||||||
# Metrics are exposed at /minio/v2/metrics; scrape via a hand-rolled
|
# Metrics are exposed at /minio/v2/metrics; scrape via a hand-rolled
|
||||||
# ServiceMonitor in the monitoring stack rather than operator auto-wiring
|
# ServiceMonitor in the monitoring stack rather than operator auto-wiring
|
||||||
|
|||||||
@@ -33,11 +33,6 @@
|
|||||||
rewrite name homarr.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
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 portainer.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||||
rewrite name longhorn.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 {
|
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
||||||
pods insecure
|
pods insecure
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ machine:
|
|||||||
install:
|
install:
|
||||||
disk: ${install_disk}
|
disk: ${install_disk}
|
||||||
image: factory.talos.dev/installer/613e1592b2da41ae5e265e8789429f22e121aab91cb4deb6bc3c0b6262961245:${talos_version}
|
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
|
grubUseUKICmdline: true
|
||||||
%{ if length(longhorn_disks) > 0 ~}
|
%{ if length(longhorn_disks) > 0 ~}
|
||||||
disks:
|
disks:
|
||||||
|
|||||||
Reference in New Issue
Block a user