diff --git a/kubernetes/apps/paperless/kustomization.yaml b/kubernetes/apps/paperless/kustomization.yaml index ce86360e..69dceca0 100644 --- a/kubernetes/apps/paperless/kustomization.yaml +++ b/kubernetes/apps/paperless/kustomization.yaml @@ -4,6 +4,8 @@ resources: - pvcs.yaml - secrets.sops.yaml - ftp-scanner-secret.sops.yaml + - seed-rbac.yaml + - seed-ssh.sops.yaml - redis.yaml - gotenberg.yaml - tika.yaml diff --git a/kubernetes/apps/paperless/paperless.yaml b/kubernetes/apps/paperless/paperless.yaml index 2daa8c90..6bcce250 100644 --- a/kubernetes/apps/paperless/paperless.yaml +++ b/kubernetes/apps/paperless/paperless.yaml @@ -45,11 +45,129 @@ spec: app.kubernetes.io/name: paperless spec: enableServiceLinks: false + serviceAccountName: paperless-seed securityContext: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault + initContainers: + # Atomic seed: idempotency-gated rsync of the data + media PVCs + # from the legacy paperless VM and pg_restore against the live + # pprl-pg cluster. Replaces the standalone seed Job pattern so + # the PVC is mounted by a single Pod (no RWO contention) and + # the main container only starts after seed completes (init- + # container ordering enforces "app starts after seed"). + - name: seed + image: gitea.renner.uno/gitea_admin/ci-k8s:2026-04-29 + env: + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CLUSTER + value: pprl-pg + - name: PGHOST + value: pprl-pg-rw.paperless.svc + - name: PGPORT + value: "5432" + - name: PGDATABASE + value: paperless + - name: PGUSER + valueFrom: + secretKeyRef: + name: pprl-pg-app + key: username + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: pprl-pg-app + key: password + - name: LEGACY_HOST + value: paperless.srv.renner.uno + command: + - bash + - -c + - | + set -euo pipefail + log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; } + + log "Installing rsync + postgresql-client ..." + apk add --no-cache rsync postgresql16-client >/dev/null + + EXISTING=$(kubectl get backups.postgresql.cnpg.io -n "$NAMESPACE" \ + -l "cnpg.io/cluster=$CLUSTER" \ + -o jsonpath='{range .items[?(@.status.phase=="completed")]}{.metadata.name}{"\n"}{end}' \ + 2>/dev/null | grep -c . || true) + if [ "$EXISTING" -gt 0 ]; then + log "$EXISTING completed CNPG Backup(s) for $CLUSTER — skipping seed" + exit 0 + fi + log "No completed CNPG Backup for $CLUSTER — proceeding with seed" + + mkdir -p /root/.ssh + install -m 600 /ssh/id_ed25519 /root/.ssh/id_ed25519 + printf 'Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n' > /root/.ssh/config + LEGACY="root@$LEGACY_HOST" + + # ---- 1. PVC rsync ---- + log "rsync paperless_data → /usr/src/paperless/data ..." + rsync -aH --delete --info=progress2 \ + "$LEGACY:/var/lib/docker/volumes/paperless_data/_data/" /usr/src/paperless/data/ + log "rsync paperless_media → /usr/src/paperless/media ..." + rsync -aH --delete --info=progress2 \ + "$LEGACY:/var/lib/docker/volumes/paperless_media/_data/" /usr/src/paperless/media/ + chown -R 1000:1000 /usr/src/paperless/data /usr/src/paperless/media + du -sh /usr/src/paperless/data /usr/src/paperless/media + + # ---- 2. Legacy DB dump (run on legacy, rsync the file) ---- + log "Locating paperless DB container on $LEGACY_HOST ..." + DB_CT=$(ssh "$LEGACY" "docker ps --filter label=com.docker.compose.service=db --format '{{.Names}}' | head -1") + [ -n "$DB_CT" ] || { log "no DB container found on $LEGACY_HOST"; exit 1; } + LEGACY_DUMP=/tmp/$PGDATABASE-seed-dump + log "Running pg_dump on $LEGACY_HOST → $LEGACY_DUMP ..." + ssh "$LEGACY" "docker exec -i $DB_CT pg_dump --clean --if-exists --no-owner --no-privileges --format=custom -U $PGUSER $PGDATABASE > $LEGACY_DUMP" + log "rsync dump file from legacy ..." + rsync -aH --info=progress2 "$LEGACY:$LEGACY_DUMP" /tmp/legacy.dump + ssh "$LEGACY" "rm -f $LEGACY_DUMP" || true + test -s /tmp/legacy.dump || { log "dump empty — refusing to restore"; exit 1; } + log "Dump size: $(du -h /tmp/legacy.dump | cut -f1)" + + # ---- 3. pg_restore into live cluster (commit point) ---- + log "pg_restore → $PGHOST/$PGDATABASE as $PGUSER ..." + pg_restore --clean --if-exists --no-owner --no-privileges \ + --role="$PGUSER" \ + --dbname="$PGDATABASE" \ + /tmp/legacy.dump \ + || log "pg_restore completed with warnings (expected)" + psql -c "REASSIGN OWNED BY postgres TO $PGUSER;" || true + psql -c "GRANT ALL ON SCHEMA public TO $PGUSER;" || true + + # ---- 4. Trigger CNPG Backup (fire-and-forget) ---- + BACKUP_NAME="$CLUSTER-seed-$(date -u +%Y%m%d-%H%M%S)" + log "Creating CNPG Backup $BACKUP_NAME ..." + cat </tmp/seed-ssh.yaml -# apiVersion: v1 -# kind: Secret -# metadata: -# name: paperless-seed-ssh -# namespace: paperless -# type: Opaque -# data: -# id_ed25519: $(base64 -w0 < ~/.ssh/your-legacy-key) -# EOF -# -# 2. Encrypt with the repo's age recipient and commit: -# -# sops -e /tmp/seed-ssh.yaml > kubernetes/migrations/paperless/seed-ssh.sops.yaml -# rm /tmp/seed-ssh.yaml -# -# 3. Uncomment the resource entry below. -# 4. Set spec.suspend: false in kubernetes/clusters/homelab/paperless-seed.yaml -# and add `- name: paperless-seed` to dependsOn in -# kubernetes/clusters/homelab/paperless.yaml. -# -# Until that's done, the migrate-paperless pipeline still works — it -# creates paperless-seed-ssh on-the-fly from the Woodpecker secret and -# applies seed-job.yaml directly (bypasses Flux). -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - seed-job.yaml - - seed-ssh.sops.yaml diff --git a/kubernetes/migrations/paperless/seed-job.yaml b/kubernetes/migrations/paperless/seed-job.yaml deleted file mode 100644 index bb1f63ee..00000000 --- a/kubernetes/migrations/paperless/seed-job.yaml +++ /dev/null @@ -1,198 +0,0 @@ -# Self-contained atomic seed for Paperless: idempotency-gated rsync of -# data + media PVCs from the legacy VM, pg_restore into the live pprl-pg -# CNPG cluster, then a CNPG Backup CR so future cold-start -# bootstrap.recovery has a base backup. -# -# Idempotency: the Job no-ops if any completed CNPG Backup exists for -# pprl-pg. So: -# - First run (empty bucket): seeds + creates Backup. -# - Re-runs after success: kubectl-applying the same manifest is a -# no-op because Jobs are immutable; the existing Complete Job stays. -# - After namespace wipe: Job resource is gone, manifest re-apply -# creates a fresh Job, and the kubectl-get-backups check sees no -# completed Backup → seeds again. -# -# Atomicity: the DB pg_restore and the Backup CR are the LAST operations. -# If rsync fails, pg_restore is never reached, and no Backup is created -# → next reconcile sees "no completed Backup" and the Job re-runs. -# -# Required Secret in the paperless namespace (created on-the-fly by the -# pipeline today; SOPS-committable as a follow-up to enable Flux -# auto-trigger): -# - paperless-seed-ssh (id_ed25519 for legacy VM access) -# CNPG-managed (always present once the cluster exists): -# - pprl-pg-app (username + password for the database owner) ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: paperless-seed - namespace: paperless ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: paperless-seed - namespace: paperless -rules: - - apiGroups: [postgresql.cnpg.io] - resources: [backups] - verbs: [get, list, watch, create] - - apiGroups: [postgresql.cnpg.io] - resources: [clusters] - verbs: [get, list, watch] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: paperless-seed - namespace: paperless -subjects: - - kind: ServiceAccount - name: paperless-seed - namespace: paperless -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: paperless-seed ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: paperless-seed - namespace: paperless -spec: - backoffLimit: 0 - ttlSecondsAfterFinished: 600 - template: - spec: - restartPolicy: Never - serviceAccountName: paperless-seed - containers: - - name: seed - image: gitea.renner.uno/gitea_admin/ci-k8s:2026-04-29 - env: - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: CLUSTER - value: pprl-pg - - name: PGHOST - value: pprl-pg-rw.paperless.svc - - name: PGPORT - value: "5432" - - name: PGDATABASE - value: paperless - - name: PGUSER - valueFrom: - secretKeyRef: - name: pprl-pg-app - key: username - - name: PGPASSWORD - valueFrom: - secretKeyRef: - name: pprl-pg-app - key: password - - name: LEGACY_HOST - value: paperless.srv.renner.uno - command: - - bash - - -c - - | - set -euo pipefail - log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; } - - # ci-k8s already has kubectl/sops/openssh/curl; we only - # add rsync + postgresql-client for the seed work. - log "Installing rsync + postgresql-client ..." - apk add --no-cache rsync postgresql16-client >/dev/null - - # ---- Idempotency: skip if any completed Backup exists ---- - # The user's gate: "trigger if no backup exists". Once the - # cluster has a completed Backup, future runs no-op so a - # Flux re-apply doesn't clobber live data. - EXISTING=$(kubectl get backups.postgresql.cnpg.io -n "$NAMESPACE" \ - -l "cnpg.io/cluster=$CLUSTER" \ - -o jsonpath='{range .items[?(@.status.phase=="completed")]}{.metadata.name}{"\n"}{end}' \ - 2>/dev/null | grep -c . || true) - if [ "$EXISTING" -gt 0 ]; then - log "$EXISTING completed CNPG Backup(s) for $CLUSTER — skipping seed" - exit 0 - fi - log "No completed CNPG Backup for $CLUSTER — proceeding with seed" - - # ---- SSH ---- - mkdir -p /root/.ssh - install -m 600 /ssh/id_ed25519 /root/.ssh/id_ed25519 - printf 'Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n' > /root/.ssh/config - LEGACY="root@$LEGACY_HOST" - - # ---- 1. PVC rsync ---- - log "rsync paperless_data → /dst/data ..." - rsync -aH --delete --info=progress2 \ - "$LEGACY:/var/lib/docker/volumes/paperless_data/_data/" /dst/data/ - log "rsync paperless_media → /dst/media ..." - rsync -aH --delete --info=progress2 \ - "$LEGACY:/var/lib/docker/volumes/paperless_media/_data/" /dst/media/ - log "Normalizing PVC ownership to 1000:1000 ..." - chown -R 1000:1000 /dst/data /dst/media - du -sh /dst/data /dst/media - - # ---- 2. Legacy DB dump (run on legacy, rsync the file) ---- - log "Locating paperless DB container on $LEGACY_HOST ..." - DB_CT=$(ssh "$LEGACY" "docker ps --filter label=com.docker.compose.service=db --format '{{.Names}}' | head -1") - [ -n "$DB_CT" ] || { log "no DB container found on $LEGACY_HOST"; exit 1; } - LEGACY_DUMP=/tmp/$PGDATABASE-seed-dump - log "Running pg_dump on $LEGACY_HOST → $LEGACY_DUMP ..." - ssh "$LEGACY" "docker exec -i $DB_CT pg_dump --clean --if-exists --no-owner --no-privileges --format=custom -U $PGUSER $PGDATABASE > $LEGACY_DUMP" - log "rsync dump file from legacy ..." - rsync -aH --info=progress2 "$LEGACY:$LEGACY_DUMP" /tmp/legacy.dump - ssh "$LEGACY" "rm -f $LEGACY_DUMP" || true - test -s /tmp/legacy.dump || { log "dump empty — refusing to restore"; exit 1; } - log "Dump size: $(du -h /tmp/legacy.dump | cut -f1)" - - # ---- 3. pg_restore into live cluster ---- - log "pg_restore → $PGHOST/$PGDATABASE as $PGUSER ..." - pg_restore --clean --if-exists --no-owner --no-privileges \ - --role="$PGUSER" \ - --dbname="$PGDATABASE" \ - /tmp/legacy.dump \ - || log "pg_restore completed with warnings (expected)" - psql -c "REASSIGN OWNED BY postgres TO $PGUSER;" || true - psql -c "GRANT ALL ON SCHEMA public TO $PGUSER;" || true - - # ---- 4. Trigger CNPG Backup (the on-disk commit point) ---- - BACKUP_NAME="$CLUSTER-seed-$(date -u +%Y%m%d-%H%M%S)" - log "Creating CNPG Backup $BACKUP_NAME ..." - cat <