From 91791b791fb3828414cb38e33e6a7e3483780185 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 28 Sep 2026 01:12:26 +0200 Subject: [PATCH] refactor(k8s): minimale, flache Manifeste zum Kopieren Kustomize-Struktur (base/overlays) entfernt. Stattdessen nummerierte Einzeldateien, ausrollbar mit "kubectl apply -f k8s/": 00-namespace, 01-config, 02-storage (SMB-PVs/PVCs Postgres + Medien), 03-postgres (StatefulSet + Service), 04-app (Deployment mit Migrations-initContainer + Service), 05-ingress, 06-worker, 07-networkpolicy. Secrets als secrets.yaml.example (echte secrets.yaml ist gitignored). Images mit Tag latest aus der Gitea-Pipeline. Kurzanleitung in k8s/README.md, Betriebsdoku angepasst. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 3 + docs/BETRIEB.md | 40 ++---- k8s/00-namespace.yaml | 6 + k8s/01-config.yaml | 15 +++ k8s/02-storage.yaml | 74 +++++++++++ k8s/{base/postgres.yaml => 03-postgres.yaml} | 29 +--- k8s/04-app.yaml | 85 ++++++++++++ k8s/05-ingress.yaml | 24 ++++ k8s/{base/worker.yaml => 06-worker.yaml} | 21 +-- k8s/07-networkpolicy.yaml | 35 +++++ k8s/README.md | 37 ++++++ k8s/base/app.yaml | 133 ------------------- k8s/base/config.yaml | 39 ------ k8s/base/kustomization.yaml | 15 --- k8s/base/storage.yaml | 27 ---- k8s/overlays/production/ingress.yaml | 34 ----- k8s/overlays/production/kustomization.yaml | 47 ------- k8s/overlays/production/smb-volumes.yaml | 89 ------------- k8s/secret.example.yaml | 45 ------- k8s/secrets.yaml.example | 28 ++++ 20 files changed, 332 insertions(+), 494 deletions(-) create mode 100644 k8s/00-namespace.yaml create mode 100644 k8s/01-config.yaml create mode 100644 k8s/02-storage.yaml rename k8s/{base/postgres.yaml => 03-postgres.yaml} (55%) create mode 100644 k8s/04-app.yaml create mode 100644 k8s/05-ingress.yaml rename k8s/{base/worker.yaml => 06-worker.yaml} (53%) create mode 100644 k8s/07-networkpolicy.yaml create mode 100644 k8s/README.md delete mode 100644 k8s/base/app.yaml delete mode 100644 k8s/base/config.yaml delete mode 100644 k8s/base/kustomization.yaml delete mode 100644 k8s/base/storage.yaml delete mode 100644 k8s/overlays/production/ingress.yaml delete mode 100644 k8s/overlays/production/kustomization.yaml delete mode 100644 k8s/overlays/production/smb-volumes.yaml delete mode 100644 k8s/secret.example.yaml create mode 100644 k8s/secrets.yaml.example diff --git a/.gitignore b/.gitignore index e4297db..a0519ac 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ npm-debug.log* # Laufzeit-Cache des Design-Hooks (impeccable) .impeccable/hook.cache.json + +# Echte Kubernetes-Secrets (Vorlage: k8s/secrets.yaml.example) +k8s/secrets.yaml diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index 9ea643e..4c8fc96 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -11,13 +11,13 @@ Einmalig einrichten: - ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets) - einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain) -Ein Release entsteht dann so: `git tag v0.1.0 && git push origin v0.1.0`, anschließend den Tag im Overlay setzen. +Die Manifeste in `k8s/` nutzen den Tag `latest`. Für feste Versionen dort `:latest` durch z. B. `:0.1.0` ersetzen (`git tag v0.1.0 && git push origin v0.1.0`). **Manuell:** ```bash -docker build --target runner -t git.encry.de/nick.rosenloecher/artista-modelcamp:0.1.0 . -docker build --target tools -t git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0 . +docker build --target runner -t git.encry.de/nick.rosenloecher/artista-modelcamp:latest . +docker build --target tools -t git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest . docker push … # beide Images, immer mit identischem Tag ``` @@ -32,39 +32,23 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert. -1. **Namespace:** `kubectl create namespace artista` und `kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted` -2. **Overlay anpassen** (`k8s/overlays/production/`): Image-Tag, Domain in `kustomization.yaml` (APP_URL) und `ingress.yaml`, `ingressClassName` (`kubectl get ingressclass`), ClusterIssuer (Beispiel `letsencrypt-prod`) und TLS-Secret-Name. Die HTTP→HTTPS-Umleitung erwartet das Manifest in Traefik selbst. -3. **SMB-Speicher** (`k8s/overlays/production/smb-volumes.yaml`): - 1. csi-driver-smb installieren, Befehl steht im Kopf der Datei. - 2. Auf dem NAS die Ordner `modelcamp-postgres` (leer) und `modelcamp-media` anlegen und `source` in der Datei anpassen. - 3. SMB-Secret anlegen: `kubectl -n artista create secret generic artista-modelcamp-smb --from-literal=username='…' --from-literal=password='…'`. Empfohlen ist ein eigener NAS-Benutzer, der nur diese Ordner sieht. - 4. Das NAS muss SMB 3.1.1 mit Verschlüsselung erlauben (`seal`), z. B. Synology unter „SMB-Verschlüsselung für Übertragung“, Windows mit `Set-SmbShare -EncryptData $true`. +**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-…` bis `07-…`) und werden mit `kubectl apply -f k8s/` ausgerollt. Die Secrets kommen aus `k8s/secrets.yaml.example` und werden nicht eingecheckt. - Die Rechte setzen die `mountOptions`, auf SMB wirkt `fsGroup` nicht: Postgres `uid=70`, `0700`/`0600`; Medien `uid=1001`, für die App nur lesbar (`0550`/`0440`). - - **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden. Gemildert wird das durch `hard`, `cache=strict`, Verschlüsselung und ein 120 Sekunden langes, sauberes Herunterfahren. Die NAS-Sicherung ist nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Ein Dateikopie-Backup eines laufenden Datenverzeichnisses lässt sich nicht zuverlässig wiederherstellen. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7). -4. **Secret anlegen:** Befehl steht in `k8s/secret.example.yaml`, besser über Sealed Secrets oder den External Secrets Operator. -5. **Anwenden:** `kubectl apply -k k8s/overlays/production`. Der initContainer migriert die Datenbank vor dem App-Start. -6. **Erstes Admin-Konto:** - ```bash - kubectl -n artista run admin-create --rm -it --restart=Never --labels=artista/db-client=true \ - --image=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0 \ - --overrides='{"spec":{"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0","command":["tsx","scripts/create-admin.ts","--email","du@example.org","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-modelcamp-config"}},{"secretRef":{"name":"artista-modelcamp-secrets"}}]}]}}' - ``` - Das einmalige Passwort wird ausgegeben. Bei der ersten Anmeldung unter `/admin` wird die 2FA eingerichtet. +Zu PostgreSQL: +- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden. +- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar. +- **Sicherung:** Die NAS-Sicherung ist für die laufende Datenbank nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7). ## 3. Update ausrollen ```bash -# Neuen Tag im Overlay setzen und anwenden. Jeder neue App-Pod migriert per -# initContainer "migrate" zuerst die Datenbank; scheitert das, startet er nicht -# und die alten Pods bleiben aktiv (maxUnavailable: 0). -kubectl apply -k k8s/overlays/production +kubectl -n artista rollout restart deploy/artista-modelcamp-app kubectl -n artista rollout status deploy/artista-modelcamp-app -# Migrationsausgabe bei Bedarf: -kubectl -n artista logs deploy/artista-modelcamp-app -c migrate +kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe ``` +Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter. + Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können. ## 4. Stripe diff --git a/k8s/00-namespace.yaml b/k8s/00-namespace.yaml new file mode 100644 index 0000000..1903cf7 --- /dev/null +++ b/k8s/00-namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: artista + labels: + pod-security.kubernetes.io/enforce: restricted diff --git a/k8s/01-config.yaml b/k8s/01-config.yaml new file mode 100644 index 0000000..a327cd7 --- /dev/null +++ b/k8s/01-config.yaml @@ -0,0 +1,15 @@ +# Nicht geheime Einstellungen. ANPASSEN: APP_URL, MAIL_FROM, ADMIN_NOTIFY_EMAIL. +apiVersion: v1 +kind: ConfigMap +metadata: + name: artista-modelcamp-config + namespace: artista +data: + APP_ENV: "production" + APP_URL: "https://modelcamp.example.org" + MAIL_TRANSPORT: "smtp" + MAIL_FROM: "ARTISTA MODELCAMP " + ADMIN_NOTIFY_EMAIL: "admin@example.org" + SMTP_PORT: "587" + SMTP_SECURE: "false" + MEDIA_DIR: "/app/media" diff --git a/k8s/02-storage.yaml b/k8s/02-storage.yaml new file mode 100644 index 0000000..5ecb6ee --- /dev/null +++ b/k8s/02-storage.yaml @@ -0,0 +1,74 @@ +# Speicher auf der SMB-Freigabe (benoetigt csi-driver-smb, siehe README). +# ANPASSEN: "source" (////). Ordner vorher anlegen. +# +# PostgreSQL auf SMB ist eine bewusste Entscheidung (Risiko siehe +# docs/BETRIEB.md). NAS-Backups sind fuer die laufende Datenbank nur mit +# atomaren Snapshots (Btrfs/ZFS) konsistent. + +# --- PostgreSQL-Daten --- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: artista-modelcamp-postgres +spec: + capacity: + storage: 10Gi + accessModes: ["ReadWriteOnce"] + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + mountOptions: [vers=3.1.1, seal, hard, cache=strict, uid=70, gid=70, dir_mode=0700, file_mode=0600, mfsymlinks, nobrl] + csi: + driver: smb.csi.k8s.io + volumeHandle: artista-modelcamp-postgres + volumeAttributes: + source: //nas.example.lan/artista/modelcamp-postgres + nodeStageSecretRef: + name: artista-modelcamp-smb + namespace: artista +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: artista-modelcamp-postgres + namespace: artista +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: "" + volumeName: artista-modelcamp-postgres + resources: + requests: + storage: 10Gi +--- +# --- Medien (Bilder/Videos; App liest nur, Dateien direkt aufs NAS legen) --- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: artista-modelcamp-media +spec: + capacity: + storage: 5Gi + accessModes: ["ReadWriteMany"] + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + mountOptions: [vers=3.1.1, seal, uid=1001, gid=1001, dir_mode=0550, file_mode=0440, mfsymlinks, nobrl] + csi: + driver: smb.csi.k8s.io + volumeHandle: artista-modelcamp-media + volumeAttributes: + source: //nas.example.lan/artista/modelcamp-media + nodeStageSecretRef: + name: artista-modelcamp-smb + namespace: artista +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: artista-modelcamp-media + namespace: artista +spec: + accessModes: ["ReadWriteMany"] + storageClassName: "" + volumeName: artista-modelcamp-media + resources: + requests: + storage: 5Gi diff --git a/k8s/base/postgres.yaml b/k8s/03-postgres.yaml similarity index 55% rename from k8s/base/postgres.yaml rename to k8s/03-postgres.yaml index 200eb73..305d7d4 100644 --- a/k8s/base/postgres.yaml +++ b/k8s/03-postgres.yaml @@ -1,30 +1,20 @@ -# PostgreSQL fuer Buchungen, Zahlungen, Gutscheine und Protokoll. -# -# Datenverzeichnis: PVC "artista-modelcamp-postgres-data" (storage.yaml), im -# Produktions-Overlay auf SMB. Das ist eine BEWUSSTE, vom Betreiber -# akzeptierte Ausnahme von der Empfehlung "Blockspeicher": SMB garantiert -# Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Die -# Mount-Optionen in smb-volumes.yaml sind darauf ausgelegt; Risiken und -# Hinweise zur Sicherung stehen in docs/BETRIEB.md. apiVersion: apps/v1 kind: StatefulSet metadata: name: artista-modelcamp-postgres + namespace: artista spec: serviceName: artista-modelcamp-postgres replicas: 1 selector: matchLabels: - app.kubernetes.io/name: artista-modelcamp-postgres + app: artista-modelcamp-postgres template: metadata: labels: - app.kubernetes.io/name: artista-modelcamp-postgres + app: artista-modelcamp-postgres spec: - automountServiceAccountToken: false - # Beim Beenden sauber herunterfahren (Checkpoint), bevor das Volume - # ausgehaengt wird — auf Netzwerkspeicher besonders wichtig. - terminationGracePeriodSeconds: 120 + terminationGracePeriodSeconds: 120 # sauber herunterfahren vor dem Aushaengen securityContext: runAsNonRoot: true runAsUser: 70 @@ -44,17 +34,11 @@ spec: valueFrom: secretKeyRef: { name: artista-modelcamp-secrets, key: DB_PASSWORD } volumeMounts: - # Ab PostgreSQL 18 liegt PGDATA unter /var/lib/postgresql/18/docker. - name: data mountPath: /var/lib/postgresql readinessProbe: exec: { command: ["pg_isready", "-U", "artista"] } periodSeconds: 5 - livenessProbe: - exec: { command: ["pg_isready", "-U", "artista"] } - initialDelaySeconds: 60 - periodSeconds: 20 - failureThreshold: 6 resources: requests: { cpu: 100m, memory: 256Mi } limits: { memory: 1Gi } @@ -63,15 +47,16 @@ spec: capabilities: { drop: ["ALL"] } volumes: - name: data - persistentVolumeClaim: { claimName: artista-modelcamp-postgres-data } + persistentVolumeClaim: { claimName: artista-modelcamp-postgres } --- apiVersion: v1 kind: Service metadata: name: artista-modelcamp-postgres + namespace: artista spec: clusterIP: None selector: - app.kubernetes.io/name: artista-modelcamp-postgres + app: artista-modelcamp-postgres ports: - port: 5432 diff --git a/k8s/04-app.yaml b/k8s/04-app.yaml new file mode 100644 index 0000000..fa74870 --- /dev/null +++ b/k8s/04-app.yaml @@ -0,0 +1,85 @@ +# Website. Der initContainer migriert vor jedem Start die Datenbank. +# Update nach neuem Image: kubectl -n artista rollout restart deploy/artista-modelcamp-app +apiVersion: apps/v1 +kind: Deployment +metadata: + name: artista-modelcamp-app + namespace: artista +spec: + replicas: 2 + selector: + matchLabels: + app: artista-modelcamp-app + template: + metadata: + labels: + app: artista-modelcamp-app + db-client: "true" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + seccompProfile: { type: RuntimeDefault } + initContainers: + - name: migrate + image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest + imagePullPolicy: Always + command: ["prisma", "migrate", "deploy"] + envFrom: + - secretRef: { name: artista-modelcamp-secrets } + securityContext: + allowPrivilegeEscalation: false + capabilities: { drop: ["ALL"] } + containers: + - name: app + image: git.encry.de/nick.rosenloecher/artista-modelcamp:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 3000 + envFrom: + - configMapRef: { name: artista-modelcamp-config } + - secretRef: { name: artista-modelcamp-secrets } + readinessProbe: + httpGet: { path: /api/health/ready, port: http } + periodSeconds: 10 + livenessProbe: + httpGet: { path: /api/health, port: http } + initialDelaySeconds: 20 + periodSeconds: 20 + resources: + requests: { cpu: 200m, memory: 256Mi } + limits: { memory: 768Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } + volumeMounts: + - name: media + mountPath: /app/media + readOnly: true + - name: cache + mountPath: /app/.next/cache + - name: tmp + mountPath: /tmp + volumes: + - name: media + persistentVolumeClaim: { claimName: artista-modelcamp-media } + - name: cache + emptyDir: {} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: artista-modelcamp-app + namespace: artista +spec: + selector: + app: artista-modelcamp-app + ports: + - name: http + port: 80 + targetPort: http diff --git a/k8s/05-ingress.yaml b/k8s/05-ingress.yaml new file mode 100644 index 0000000..53ba72b --- /dev/null +++ b/k8s/05-ingress.yaml @@ -0,0 +1,24 @@ +# ANPASSEN: Domain (2x, auch APP_URL in 01-config.yaml), ClusterIssuer. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: artista-modelcamp + namespace: artista + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + tls: + - hosts: [modelcamp.example.org] + secretName: artista-modelcamp-tls + rules: + - host: modelcamp.example.org + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: artista-modelcamp-app + port: + name: http diff --git a/k8s/base/worker.yaml b/k8s/06-worker.yaml similarity index 53% rename from k8s/base/worker.yaml rename to k8s/06-worker.yaml index 6757e67..7e74f54 100644 --- a/k8s/base/worker.yaml +++ b/k8s/06-worker.yaml @@ -1,43 +1,34 @@ -# Worker: gibt abgelaufene Reservierungen frei (nach Rueckfrage bei Stripe), -# zieht faellige Raten ein, trennt Zahlungsmethoden nach der letzten Rate und -# versendet den E-Mail-Ausgang. Ohne ihn laufen Ratenzahlung und -# Reservierungsfreigabe nicht. Idempotent; Forbid verhindert Ueberlappung. +# Alle 10 Minuten: faellige Raten einziehen, abgelaufene Reservierungen +# freigeben, E-Mails nachsenden. Ohne ihn funktioniert Ratenzahlung nicht. apiVersion: batch/v1 kind: CronJob metadata: name: artista-modelcamp-worker + namespace: artista spec: schedule: "*/10 * * * *" - timeZone: "Europe/Berlin" concurrencyPolicy: Forbid - startingDeadlineSeconds: 300 - successfulJobsHistoryLimit: 3 - failedJobsHistoryLimit: 5 jobTemplate: spec: backoffLimit: 1 - activeDeadlineSeconds: 900 template: metadata: labels: - artista/db-client: "true" + db-client: "true" spec: restartPolicy: Never - automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 1001 seccompProfile: { type: RuntimeDefault } containers: - name: worker - image: artista-modelcamp-tools + image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest + imagePullPolicy: Always command: ["tsx", "scripts/worker.ts"] envFrom: - configMapRef: { name: artista-modelcamp-config } - secretRef: { name: artista-modelcamp-secrets } - resources: - requests: { cpu: 50m, memory: 192Mi } - limits: { memory: 512Mi } securityContext: allowPrivilegeEscalation: false capabilities: { drop: ["ALL"] } diff --git a/k8s/07-networkpolicy.yaml b/k8s/07-networkpolicy.yaml new file mode 100644 index 0000000..62667ff --- /dev/null +++ b/k8s/07-networkpolicy.yaml @@ -0,0 +1,35 @@ +# Datenbank nur fuer App und Worker erreichbar; App nur ueber Traefik. +# ANPASSEN: Namespace deines Traefik ("kubectl get pods -A | grep traefik"). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: artista-modelcamp-postgres + namespace: artista +spec: + podSelector: + matchLabels: + app: artista-modelcamp-postgres + ingress: + - from: + - podSelector: + matchLabels: + db-client: "true" + ports: + - port: 5432 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: artista-modelcamp-app + namespace: artista +spec: + podSelector: + matchLabels: + app: artista-modelcamp-app + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: traefik + ports: + - port: 3000 diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..e2014f2 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,37 @@ +# Kubernetes (RKE2, Traefik, cert-manager) + +## Einmalig + +1. **csi-driver-smb installieren:** + ```bash + helm repo add csi-driver-smb https://raw.githubusercontent.com/kubernetes-csi/csi-driver-smb/master/charts + helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system + ``` +2. **Auf dem NAS** die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. +3. **Anpassen:** + - `nas.example.lan` in `02-storage.yaml` + - `modelcamp.example.org` in `01-config.yaml` und `05-ingress.yaml` + - den Traefik-Namespace in `07-networkpolicy.yaml` +4. **Secrets:** `cp k8s/secrets.yaml.example k8s/secrets.yaml`, die Werte eintragen. +5. **Ausrollen:** + ```bash + kubectl apply -f k8s/00-namespace.yaml + kubectl apply -f k8s/secrets.yaml + kubectl apply -f k8s/ + ``` +6. **Admin anlegen** (E-Mail und Name ersetzen; das einmalige Passwort wird ausgegeben, die 2FA richtest du bei der ersten Anmeldung unter `/admin` ein): + ```bash + kubectl -n artista run admin-create --rm -i --restart=Never \ + --image=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest --labels=db-client=true \ + --overrides='{"spec":{"securityContext":{"runAsNonRoot":true,"runAsUser":1001,"seccompProfile":{"type":"RuntimeDefault"}},"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest","command":["tsx","scripts/create-admin.ts","--email","du@example.org","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-modelcamp-config"}},{"secretRef":{"name":"artista-modelcamp-secrets"}}],"securityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]}}}]}}' + ``` + +## Update + +Bei jedem Push auf `main` baut die Gitea-Pipeline die Images mit dem Tag `latest`. Danach: + +```bash +kubectl -n artista rollout restart deploy/artista-modelcamp-app +``` + +Der initContainer migriert die Datenbank automatisch. diff --git a/k8s/base/app.yaml b/k8s/base/app.yaml deleted file mode 100644 index a5ae4ae..0000000 --- a/k8s/base/app.yaml +++ /dev/null @@ -1,133 +0,0 @@ -# Next.js-Anwendung. Zustandslos: Buchungen, Kapazitaet, Gutscheine, -# Reservierungen, Rate Limits, E-Mail-Ausgang und Zahlungsjobs liegen in -# PostgreSQL — beliebig viele Replikate moeglich. -# -# Migrationen laufen als eigener Schritt VOR dem App-Container: initContainer -# "migrate" (Werkzeug-Image, `prisma migrate deploy`). Scheitert er, startet -# die App nicht. Mehrere Replikate gleichzeitig sind unkritisch — Prisma -# serialisiert Migrationen ueber eine Datenbank-Sperre. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: artista-modelcamp-app -spec: - replicas: 2 - revisionHistoryLimit: 5 - strategy: - type: RollingUpdate - rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } - selector: - matchLabels: - app.kubernetes.io/name: artista-modelcamp-app - template: - metadata: - labels: - app.kubernetes.io/name: artista-modelcamp-app - artista/db-client: "true" - spec: - automountServiceAccountToken: false - securityContext: - runAsNonRoot: true - runAsUser: 1001 - runAsGroup: 1001 - fsGroup: 1001 - seccompProfile: { type: RuntimeDefault } - topologySpreadConstraints: - - maxSkew: 1 - topologyKey: kubernetes.io/hostname - whenUnsatisfiable: ScheduleAnyway - labelSelector: - matchLabels: - app.kubernetes.io/name: artista-modelcamp-app - initContainers: - - name: migrate - image: artista-modelcamp-tools - command: ["prisma", "migrate", "deploy"] - envFrom: - - secretRef: { name: artista-modelcamp-secrets } - resources: - requests: { cpu: 50m, memory: 128Mi } - limits: { memory: 512Mi } - securityContext: - allowPrivilegeEscalation: false - capabilities: { drop: ["ALL"] } - containers: - - name: app - image: artista-modelcamp - ports: - - name: http - containerPort: 3000 - envFrom: - - configMapRef: { name: artista-modelcamp-config } - - secretRef: { name: artista-modelcamp-secrets } - readinessProbe: - httpGet: { path: /api/health/ready, port: http } - periodSeconds: 10 - timeoutSeconds: 5 - livenessProbe: - # Bewusst ohne Datenbank: eine DB-Stoerung soll nicht zu - # Neustartschleifen fuehren. - httpGet: { path: /api/health, port: http } - initialDelaySeconds: 20 - periodSeconds: 20 - timeoutSeconds: 5 - failureThreshold: 3 - resources: - requests: { cpu: 200m, memory: 256Mi } - limits: { memory: 768Mi } - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: { drop: ["ALL"] } - volumeMounts: - - name: media - mountPath: /app/media - readOnly: true - # Reiner Zwischenspeicher fuer optimierte Bilder: fluechtig ok. - - name: next-cache - mountPath: /app/.next/cache - - name: tmp - mountPath: /tmp - volumes: - - name: media - persistentVolumeClaim: { claimName: artista-modelcamp-media } - - name: next-cache - emptyDir: { sizeLimit: 512Mi } - - name: tmp - emptyDir: { sizeLimit: 64Mi } ---- -apiVersion: v1 -kind: Service -metadata: - name: artista-modelcamp-app -spec: - selector: - app.kubernetes.io/name: artista-modelcamp-app - ports: - - name: http - port: 80 - targetPort: http ---- -# Die App nimmt Verbindungen nur vom Ingress-Controller an. Ohne diese Regel -# koennte jeder Pod im Cluster den Service direkt ansprechen und per -# gefaelschtem X-Real-Ip-Header die Rate Limits (inkl. Admin-Login/2FA) -# umgehen. Namespace des Traefik-Controllers anpassen -# ("kubectl get pods -A | grep traefik"). -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: artista-modelcamp-app-ingress -spec: - podSelector: - matchLabels: - app.kubernetes.io/name: artista-modelcamp-app - policyTypes: ["Ingress"] - ingress: - - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: traefik - ports: - - port: 3000 - # Kubelet-Probes kommen vom Knoten und sind von NetworkPolicies je nach - # CNI (Canal/Calico in RKE2) ausgenommen. diff --git a/k8s/base/config.yaml b/k8s/base/config.yaml deleted file mode 100644 index ba3d470..0000000 --- a/k8s/base/config.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# Der Namespace "artista" wird einmalig von Hand angelegt: -# kubectl create namespace artista -# kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted -# -# Nicht geheime Einstellungen. Geheimnisse: k8s/secret.example.yaml. -apiVersion: v1 -kind: ConfigMap -metadata: - name: artista-modelcamp-config -data: - APP_ENV: "production" - APP_URL: "https://modelcamp.example.org" - DATABASE_POOL_SIZE: "10" - MAIL_TRANSPORT: "smtp" - MAIL_FROM: "ARTISTA MODELCAMP " - ADMIN_NOTIFY_EMAIL: "" - SMTP_PORT: "587" - SMTP_SECURE: "false" - MEDIA_DIR: "/app/media" - EXTERNAL_VIDEO_PROVIDER: "none" ---- -# Die Datenbank ist nur fuer Pods mit dem Label artista/db-client erreichbar -# (App inkl. Migrations-initContainer, Worker). -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: artista-modelcamp-postgres-access -spec: - podSelector: - matchLabels: - app.kubernetes.io/name: artista-modelcamp-postgres - policyTypes: ["Ingress"] - ingress: - - from: - - podSelector: - matchLabels: - artista/db-client: "true" - ports: - - port: 5432 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml deleted file mode 100644 index 196dfc2..0000000 --- a/k8s/base/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Basis fuer RKE2. Nicht direkt anwenden — ueber ein Overlay -# (k8s/overlays/production), das Domain, Images und TLS setzt. -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: artista -resources: - - config.yaml - - storage.yaml - - postgres.yaml - - app.yaml - - worker.yaml -labels: - - pairs: - app.kubernetes.io/part-of: artista-modelcamp - includeSelectors: false diff --git a/k8s/base/storage.yaml b/k8s/base/storage.yaml deleted file mode 100644 index be7ab9a..0000000 --- a/k8s/base/storage.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# Die beiden dauerhaften Speicher. Im Produktions-Overlay werden sie fest an -# die SMB-PersistentVolumes gebunden (k8s/overlays/production/smb-volumes.yaml). -# Eine separate Sicherung gibt es nicht: die SMB-Freigabe wird auf dem NAS -# selbst mehrfach gesichert. - -# PostgreSQL-Datenverzeichnis. -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: artista-modelcamp-postgres-data -spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 10Gi ---- -# Freigegebene Bilder und Videos. ReadWriteMany, weil mehrere App-Replikate -# lesen. -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: artista-modelcamp-media -spec: - accessModes: ["ReadWriteMany"] - resources: - requests: - storage: 5Gi diff --git a/k8s/overlays/production/ingress.yaml b/k8s/overlays/production/ingress.yaml deleted file mode 100644 index c2abfbb..0000000 --- a/k8s/overlays/production/ingress.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Ingress fuer Traefik mit cert-manager. -# Anpassen: Host (auch APP_URL in kustomization.yaml), ingressClassName -# ("kubectl get ingressclass"), ClusterIssuer, TLS-Secret-Name. -# HTTP->HTTPS-Umleitung erwartet dieses Manifest EntryPoint-weit in Traefik -# (Helm: ports.web.redirections.entryPoint.to=websecure). -# -# X-Forwarded-For/X-Real-Ip: Rate Limits verlassen sich darauf, dass Traefik -# vom Client mitgeschickte Werte verwirft. Dafuer im Traefik-EntryPoint -# forwardedHeaders.trustedIPs nur auf echte vorgelagerte Proxys setzen. -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: artista-modelcamp - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod - traefik.ingress.kubernetes.io/router.entrypoints: websecure - traefik.ingress.kubernetes.io/router.tls: "true" -spec: - ingressClassName: traefik - tls: - - hosts: - - modelcamp.example.org - secretName: artista-modelcamp-tls - rules: - - host: modelcamp.example.org - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: artista-modelcamp-app - port: - name: http diff --git a/k8s/overlays/production/kustomization.yaml b/k8s/overlays/production/kustomization.yaml deleted file mode 100644 index 3fbf067..0000000 --- a/k8s/overlays/production/kustomization.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# Produktions-Overlay. Anpassen: Images/Tags, Domain, IngressClass, -# ClusterIssuer und TLS-Secret-Name. Anwenden: -# kubectl apply -k k8s/overlays/production -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: artista -resources: - - ../../base - - ingress.yaml - # PostgreSQL-Daten und Medien auf SMB. - # Ohne SMB: diese Zeile und die beiden PVC-Patches unten entfernen. - - smb-volumes.yaml - -images: - # Gebaut von .gitea/workflows/docker-build.yml: Git-Tag "v0.1.0" pushen - # ergibt Image-Tag "0.1.0" (Alternativen: "sha-", "latest"). - # Beide Images immer mit identischem Tag ausrollen. - - name: artista-modelcamp - newName: git.encry.de/nick.rosenloecher/artista-modelcamp - newTag: "0.1.0" - - name: artista-modelcamp-tools - newName: git.encry.de/nick.rosenloecher/artista-modelcamp-tools - newTag: "0.1.0" - -patches: - # PVCs fest an die SMB-PersistentVolumes binden (statisch, ohne StorageClass). - - target: { kind: PersistentVolumeClaim, name: artista-modelcamp-media } - patch: |- - - op: add - path: /spec/storageClassName - value: "" - - op: add - path: /spec/volumeName - value: artista-modelcamp-media-smb - - target: { kind: PersistentVolumeClaim, name: artista-modelcamp-postgres-data } - patch: |- - - op: add - path: /spec/storageClassName - value: "" - - op: add - path: /spec/volumeName - value: artista-modelcamp-postgres-smb - - target: { kind: ConfigMap, name: artista-modelcamp-config } - patch: |- - - op: replace - path: /data/APP_URL - value: https://modelcamp.example.org diff --git a/k8s/overlays/production/smb-volumes.yaml b/k8s/overlays/production/smb-volumes.yaml deleted file mode 100644 index 2fcad44..0000000 --- a/k8s/overlays/production/smb-volumes.yaml +++ /dev/null @@ -1,89 +0,0 @@ -# PersistentVolumes auf einer SMB-Freigabe (csi-driver-smb, smb.csi.k8s.io). -# -# VORAUSSETZUNG: csi-driver-smb im Cluster installiert, z. B.: -# helm repo add csi-driver-smb https://raw.githubusercontent.com/kubernetes-csi/csi-driver-smb/master/charts -# helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system -# und das Zugangs-Secret "artista-modelcamp-smb" (k8s/secret.example.yaml). -# -# ANZUPASSEN: "source" (////) je Volume. Die Ordner -# muessen auf der Freigabe bereits existieren; der Postgres-Ordner leer. -# -# Beide Verbindungen verschluesselt (SMB 3.1.1 + seal); das NAS muss -# SMB-Verschluesselung erlauben. fsGroup wirkt auf SMB nicht: Besitz und -# Rechte setzen uid/gid/dir_mode/file_mode. reclaimPolicy Retain: Loeschen -# von PVC/PV laesst die Dateien auf dem NAS. -# Keine eigene Sicherung im Cluster: die Freigabe wird auf dem NAS gesichert. - -# --- PostgreSQL-Datenverzeichnis ------------------------------------------------ -# BEWUSSTE AUSNAHME (Entscheidung des Betreibers): Datenbank auf SMB. -# Risiko: SMB garantiert Sperren/fsync nicht in jedem Fall wie ein lokales -# Dateisystem; bei Netzwerk- oder NAS-Ausfall waehrend Schreibvorgaengen kann -# die Datenbank beschaedigt werden. Die Optionen unten minimieren das: -# hard E/A wartet bei Netzstoerung statt Fehler an PostgreSQL zu -# melden (kein "soft"-Abbruch mitten im Schreiben) -# cache=strict strikte Cache-Kohaerenz, fsync geht bis zum Server -# nobrl keine Byte-Range-Locks (PostgreSQL braucht sie nicht) -# uid/gid 70, 0700/0600 PostgreSQL verlangt ein privates Datenverzeichnis -# Sicherung: nur NAS-Snapshots, die atomar sind (Btrfs/ZFS-Snapshot der ganzen -# Freigabe), sind fuer ein laufendes Datenverzeichnis konsistent. Einfaches -# Dateikopieren waehrend des Betriebs ergibt KEINE brauchbare Sicherung. -apiVersion: v1 -kind: PersistentVolume -metadata: - name: artista-modelcamp-postgres-smb -spec: - capacity: - storage: 10Gi - accessModes: ["ReadWriteOnce"] - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - mountOptions: - - vers=3.1.1 - - seal - - hard - - cache=strict - - uid=70 - - gid=70 - - dir_mode=0700 - - file_mode=0600 - - mfsymlinks - - nobrl - csi: - driver: smb.csi.k8s.io - volumeHandle: artista-modelcamp-postgres-smb - volumeAttributes: - source: //nas.example.lan/artista/modelcamp-postgres - nodeStageSecretRef: - name: artista-modelcamp-smb - namespace: artista ---- -# --- Medien (Bilder/Videos) ------------------------------------------------------ -# Die App liest nur. Neue Bilder legst du direkt ueber die SMB-Freigabe ab, -# z. B. vom Windows-Explorer aus. -apiVersion: v1 -kind: PersistentVolume -metadata: - name: artista-modelcamp-media-smb -spec: - capacity: - storage: 5Gi - accessModes: ["ReadWriteMany"] - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - mountOptions: - - vers=3.1.1 - - seal - - uid=1001 # Containerbenutzer "nextjs" (Dockerfile) - - gid=1001 - - dir_mode=0550 # App liest nur; Schreiben ausschliesslich ueber das NAS - - file_mode=0440 - - mfsymlinks - - nobrl - csi: - driver: smb.csi.k8s.io - volumeHandle: artista-modelcamp-media-smb - volumeAttributes: - source: //nas.example.lan/artista/modelcamp-media - nodeStageSecretRef: - name: artista-modelcamp-smb - namespace: artista diff --git a/k8s/secret.example.yaml b/k8s/secret.example.yaml deleted file mode 100644 index 94450b9..0000000 --- a/k8s/secret.example.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# VORLAGE — nicht mit echten Werten ins Repository einchecken. -# Anlegen z. B. per: -# kubectl -n artista create secret generic artista-modelcamp-secrets \ -# --from-literal=DB_PASSWORD='…' \ -# --from-literal=DATABASE_URL='postgresql://artista:…@artista-modelcamp-postgres:5432/artista' \ -# --from-literal=APP_SECRET="$(openssl rand -base64 48)" \ -# --from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 48)" \ -# --from-literal=STRIPE_SECRET_KEY='sk_test_…' \ -# --from-literal=STRIPE_WEBHOOK_SECRET='whsec_…' \ -# --from-literal=SMTP_HOST='…' --from-literal=SMTP_USER='…' --from-literal=SMTP_PASSWORD='…' -# Besser noch: Sealed Secrets oder External Secrets Operator. -apiVersion: v1 -kind: Secret -metadata: - name: artista-modelcamp-secrets - namespace: artista -type: Opaque -stringData: - DB_PASSWORD: "" - # Passwort URL-kodiert einsetzen, falls Sonderzeichen enthalten sind. - DATABASE_URL: "postgresql://artista:@artista-modelcamp-postgres:5432/artista" - APP_SECRET: "" - BETTER_AUTH_SECRET: "" - STRIPE_SECRET_KEY: "" - STRIPE_WEBHOOK_SECRET: "" - SMTP_HOST: "" - SMTP_USER: "" - SMTP_PASSWORD: "" ---- -# Zugang zur SMB-Freigabe fuer die PersistentVolumes -# (k8s/overlays/production/smb-volumes.yaml). Eigener, auf die Artista- -# Ordner beschraenkter NAS-Benutzer empfohlen. -# kubectl -n artista create secret generic artista-modelcamp-smb \ -# --from-literal=username='…' --from-literal=password='…' -apiVersion: v1 -kind: Secret -metadata: - name: artista-modelcamp-smb - namespace: artista -type: Opaque -stringData: - username: "" - password: "" - # Optional bei Domaenenkonten: - # domain: "" diff --git a/k8s/secrets.yaml.example b/k8s/secrets.yaml.example new file mode 100644 index 0000000..3cbfcb2 --- /dev/null +++ b/k8s/secrets.yaml.example @@ -0,0 +1,28 @@ +# Kopieren nach k8s/secrets.yaml (ist in .gitignore), Werte eintragen, +# dann: kubectl apply -f k8s/secrets.yaml +# Zufallswerte: openssl rand -base64 48 +apiVersion: v1 +kind: Secret +metadata: + name: artista-modelcamp-secrets + namespace: artista +stringData: + DB_PASSWORD: "PASSWORT" + DATABASE_URL: "postgresql://artista:PASSWORT@artista-modelcamp-postgres:5432/artista" + APP_SECRET: "ZUFALL-MIND-32-ZEICHEN" + BETTER_AUTH_SECRET: "ANDERER-ZUFALL-MIND-32-ZEICHEN" + STRIPE_SECRET_KEY: "sk_test_..." + STRIPE_WEBHOOK_SECRET: "whsec_..." + SMTP_HOST: "smtp.example.org" + SMTP_USER: "benutzer" + SMTP_PASSWORD: "passwort" +--- +# Zugang zur SMB-Freigabe (fuer 02-storage.yaml) +apiVersion: v1 +kind: Secret +metadata: + name: artista-modelcamp-smb + namespace: artista +stringData: + username: "NAS-BENUTZER" + password: "NAS-PASSWORT"