|
#!/bin/bash |
|
# |
|
# install-paperless-macos.sh |
|
# |
|
# Installiert paperless-ngx auf macOS mit PostgreSQL 18 und Valkey. |
|
# Laeuft auf Apple Silicon und Intel, ab macOS 13. |
|
# |
|
# Container-Runtime: eine vorhandene Docker-Installation wird bevorzugt. |
|
# Laeuft Docker Desktop oder Rancher Desktop nur nicht, wird gefragt, ob es |
|
# gestartet werden soll. Ist gar kein Docker vorhanden, kann Colima als |
|
# Rueckfallebene eingerichtet werden - ebenfalls erst nach Rueckfrage. |
|
# |
|
# Installiert wird nur, was fehlt: |
|
# - bei vorhandenem Docker: nichts |
|
# - sonst Homebrew sowie colima, docker (CLI) und docker-compose |
|
# |
|
# Images und Volume-Pfade folgen der offiziellen docker-compose.postgres.yml |
|
# von paperless-ngx (Stand: v3.0.x). |
|
# |
|
# Das Skript ist idempotent: ein erneuter Aufruf aktualisiert die Installation |
|
# (neue Images, geaenderte Konfiguration) ohne Datenverlust. |
|
# |
|
# Aufruf: |
|
# ./install-paperless-macos.sh [OPTIONEN] |
|
# |
|
# Optionen: |
|
# --dir PFAD Installationsverzeichnis (Default: ~/paperless-ngx) |
|
# --version TAG Image-Tag von paperless-ngx (Default: latest) |
|
# --port PORT Port der Weboberflaeche (Default: 8000) |
|
# --bind ADRESSE Bind-Adresse (Default: 127.0.0.1, "0.0.0.0" fuer LAN-Zugriff) |
|
# --languages SPRACHEN OCR-Sprachen in tesseract-Syntax (Default: deu+eng) |
|
# --timezone ZONE Zeitzone (Default: Europe/Berlin) |
|
# --admin-user NAME Administrator-Konto (Default: admin) |
|
# --cpu N CPU-Kerne fuer die Colima-VM (Default: aus der Hardware) |
|
# --memory GB Arbeitsspeicher der Colima-VM in GB (Default: aus der Hardware) |
|
# --disk GB Plattengroesse der Colima-VM in GB (Default: aus dem freien Platz) |
|
# --runtime WAHL Container-Runtime: auto (Default), docker, colima |
|
# auto = vorhandenes Docker bevorzugen, Colima nur als |
|
# Rueckfallebene und erst nach Rueckfrage |
|
# --yes Rueckfragen automatisch mit ja beantworten |
|
# --no-start Alles einrichten, aber VM und Container nicht starten |
|
# --dry-run Nichts installieren, nichts starten - nur die |
|
# Konfigurationsdateien erzeugen und berichten, was |
|
# ein echter Lauf tun wuerde |
|
# --uninstall Container und Volumes entfernen (nach Rueckfrage) |
|
# -h, --help Diese Hilfe |
|
# |
|
# Lizenz: GPL-3.0-or-later, Copyright (C) 2026 Oliver Steinhauer |
|
# Vollstaendiger Hinweis am Ende dieses Kopfes. |
|
# |
|
set -euo pipefail |
|
|
|
# --------------------------------------------------------------------------- |
|
# SPDX-License-Identifier: GPL-3.0-or-later |
|
# |
|
# Copyright (C) 2026 Oliver Steinhauer |
|
# |
|
# This program is free software: you can redistribute it and/or modify |
|
# it under the terms of the GNU General Public License as published by |
|
# the Free Software Foundation, either version 3 of the License, or |
|
# (at your option) any later version. |
|
# |
|
# This program is distributed in the hope that it will be useful, |
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
# GNU General Public License for more details. |
|
# |
|
# You should have received a copy of the GNU General Public License |
|
# along with this program. If not, see <https://www.gnu.org/licenses/>. |
|
# --------------------------------------------------------------------------- |
|
|
|
# --------------------------------------------------------------------------- |
|
# Defaults |
|
# --------------------------------------------------------------------------- |
|
|
|
PAPERLESS_DIR="${HOME}/paperless-ngx" |
|
IMAGE_TAG="latest" |
|
WEB_PORT="8000" |
|
BIND_ADDR="127.0.0.1" |
|
OCR_LANGUAGES="deu+eng" |
|
TIME_ZONE="Europe/Berlin" |
|
ADMIN_USER="admin" |
|
# Leer = wird weiter unten aus der Hardware des Rechners abgeleitet. |
|
VM_CPU="" |
|
VM_MEMORY="" |
|
VM_DISK="" |
|
START_STACK="yes" |
|
DO_UNINSTALL="no" |
|
DRY_RUN="no" |
|
RUNTIME_CHOICE="auto" |
|
ASSUME_YES="no" |
|
|
|
# Bewusst auf Major-Versionen gepinnt: ein Sprung auf postgres:19 erfordert |
|
# spaeter einen Dump/Restore, das darf nicht unbemerkt bei einem Update passieren. |
|
POSTGRES_IMAGE="docker.io/library/postgres:18" |
|
BROKER_IMAGE="docker.io/valkey/valkey:9-alpine" |
|
PAPERLESS_IMAGE="ghcr.io/paperless-ngx/paperless-ngx" |
|
COMPOSE_PROJECT="paperless" |
|
|
|
# Eigene Colima-Instanz statt des Standardprofils. Auf Rechnern, auf denen |
|
# schon Colima, Docker Desktop oder Rancher Desktop laeuft, wuerde das |
|
# Standardprofil sonst dessen Konfiguration ueberschreiben. |
|
COLIMA_PROFILE="paperless" |
|
|
|
# --------------------------------------------------------------------------- |
|
# Ausgabe-Helfer |
|
# --------------------------------------------------------------------------- |
|
|
|
if [ -t 1 ]; then |
|
C_BLUE=$'\033[1;34m'; C_GREEN=$'\033[1;32m'; C_YELLOW=$'\033[1;33m' |
|
C_RED=$'\033[1;31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m' |
|
else |
|
C_BLUE=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_DIM=""; C_OFF="" |
|
fi |
|
|
|
step() { printf '\n%s==>%s %s\n' "$C_BLUE" "$C_OFF" "$*"; } |
|
info() { printf ' %s\n' "$*"; } |
|
ok() { printf ' %s%s%s\n' "$C_GREEN" "$*" "$C_OFF"; } |
|
warn() { printf ' %sWarnung:%s %s\n' "$C_YELLOW" "$C_OFF" "$*"; } |
|
die() { printf '\n%sFehler:%s %s\n\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; } |
|
|
|
# Gibt den Kommentarblock am Dateianfang aus - waechst automatisch mit. |
|
usage() { |
|
awk 'NR>2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "$0" |
|
exit 0 |
|
} |
|
|
|
# --------------------------------------------------------------------------- |
|
# Argumente |
|
# --------------------------------------------------------------------------- |
|
|
|
while [ $# -gt 0 ]; do |
|
case "$1" in |
|
--dir) PAPERLESS_DIR="${2:?--dir braucht einen Pfad}"; shift 2 ;; |
|
--version) IMAGE_TAG="${2:?--version braucht einen Tag}"; shift 2 ;; |
|
--port) WEB_PORT="${2:?--port braucht eine Nummer}"; shift 2 ;; |
|
--bind) BIND_ADDR="${2:?--bind braucht eine Adresse}"; shift 2 ;; |
|
--languages) OCR_LANGUAGES="${2:?--languages braucht einen Wert}"; shift 2 ;; |
|
--timezone) TIME_ZONE="${2:?--timezone braucht einen Wert}"; shift 2 ;; |
|
--admin-user) ADMIN_USER="${2:?--admin-user braucht einen Namen}"; shift 2 ;; |
|
--cpu) VM_CPU="${2:?--cpu braucht eine Zahl}"; shift 2 ;; |
|
--memory) VM_MEMORY="${2:?--memory braucht eine Zahl}"; shift 2 ;; |
|
--disk) VM_DISK="${2:?--disk braucht eine Zahl}"; shift 2 ;; |
|
--runtime) RUNTIME_CHOICE="${2:?--runtime braucht auto|docker|colima}"; shift 2 ;; |
|
--yes|-y) ASSUME_YES="yes"; shift ;; |
|
--no-start) START_STACK="no"; shift ;; |
|
--dry-run) DRY_RUN="yes"; shift ;; |
|
--uninstall) DO_UNINSTALL="yes"; shift ;; |
|
-h|--help) usage ;; |
|
*) die "Unbekannte Option: $1 (--help fuer Hilfe)" ;; |
|
esac |
|
done |
|
|
|
case "$RUNTIME_CHOICE" in |
|
auto|docker|colima) ;; |
|
*) die "--runtime kennt nur auto, docker oder colima (erhalten: ${RUNTIME_CHOICE})" ;; |
|
esac |
|
|
|
if [ "$DRY_RUN" = "yes" ]; then |
|
[ "$DO_UNINSTALL" = "yes" ] && die "--dry-run und --uninstall schliessen sich aus." |
|
# Im Trockenlauf wird grundsaetzlich nichts gestartet. |
|
START_STACK="no" |
|
fi |
|
|
|
# Meldet einen uebersprungenen Schritt im Trockenlauf. |
|
skip() { printf ' %suebersprungen:%s %s\n' "$C_DIM" "$C_OFF" "$*"; } |
|
|
|
# --------------------------------------------------------------------------- |
|
# VM-Groesse aus der Hardware ableiten |
|
# --------------------------------------------------------------------------- |
|
# |
|
# Colima laeuft neben macOS, nicht statt macOS - der Host braucht also Luft. |
|
# Explizit uebergebene Werte (--cpu/--memory/--disk) bleiben unangetastet. |
|
|
|
clamp() { # clamp <wert> <min> <max> |
|
local value="$1" min="$2" max="$3" |
|
[ "$value" -lt "$min" ] && value="$min" |
|
[ "$value" -gt "$max" ] && value="$max" |
|
printf '%s' "$value" |
|
} |
|
|
|
if [ -z "$VM_CPU" ]; then |
|
HOST_CPUS="$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4)" |
|
# Die Haelfte der Kerne, damit der Mac daneben bedienbar bleibt. |
|
VM_CPU="$(clamp "$(( HOST_CPUS / 2 ))" 2 6)" |
|
CPU_SOURCE="abgeleitet aus ${HOST_CPUS} Kernen" |
|
else |
|
CPU_SOURCE="vorgegeben" |
|
fi |
|
|
|
if [ -z "$VM_MEMORY" ]; then |
|
HOST_MEM_BYTES="$(sysctl -n hw.memsize 2>/dev/null || echo 8589934592)" |
|
HOST_MEM_GB="$(( HOST_MEM_BYTES / 1024 / 1024 / 1024 ))" |
|
# Ein Drittel des RAM. Unter 3 GB laeuft OCR nicht vernuenftig, ueber 8 GB |
|
# bringt es paperless nichts mehr. |
|
VM_MEMORY="$(clamp "$(( HOST_MEM_GB / 3 ))" 3 8)" |
|
MEM_SOURCE="abgeleitet aus ${HOST_MEM_GB} GB RAM" |
|
LOW_MEMORY_HOST="$([ "$HOST_MEM_GB" -lt 8 ] && echo "$HOST_MEM_GB" || true)" |
|
else |
|
MEM_SOURCE="vorgegeben" |
|
fi |
|
|
|
if [ -z "$VM_DISK" ]; then |
|
# df -g liefert den freien Platz auf dem Systemvolume in GB. |
|
FREE_GB="$(df -g / 2>/dev/null | awk 'NR==2 {print $4}')" |
|
[ -n "${FREE_GB:-}" ] || FREE_GB=100 |
|
# Die Colima-Disk waechst nur nach Bedarf, die Angabe ist eine Obergrenze. |
|
VM_DISK="$(clamp "$(( FREE_GB / 2 ))" 30 100)" |
|
DISK_SOURCE="abgeleitet aus ${FREE_GB} GB frei" |
|
else |
|
DISK_SOURCE="vorgegeben" |
|
fi |
|
|
|
CONSUME_DIR="${PAPERLESS_DIR}/consume" |
|
EXPORT_DIR="${PAPERLESS_DIR}/export" |
|
# Alle Nutzdaten liegen bewusst als Bind-Mount auf APFS, damit Time Machine |
|
# sie erfasst - Docker-Volumes liegen im Disk-Image der Colima-VM und waeren |
|
# fuer Time Machine nur ein staendig veraenderter Klumpen. |
|
MEDIA_DIR="${PAPERLESS_DIR}/media" |
|
DATA_DIR="${PAPERLESS_DIR}/data" |
|
PGDATA_DIR="${PAPERLESS_DIR}/pgdata" |
|
BIN_DIR="${PAPERLESS_DIR}/bin" |
|
COMPOSE_FILE="${PAPERLESS_DIR}/docker-compose.yml" |
|
ENV_FILE="${PAPERLESS_DIR}/.env" |
|
APP_ENV_FILE="${PAPERLESS_DIR}/paperless.env" |
|
|
|
# --------------------------------------------------------------------------- |
|
# 0. Vorbedingungen |
|
# --------------------------------------------------------------------------- |
|
|
|
if [ "$DRY_RUN" = "yes" ]; then |
|
printf '\n%s### TROCKENLAUF ###%s\n' "$C_YELLOW" "$C_OFF" |
|
info "Es wird nichts installiert, keine VM gestartet, kein Container angelegt." |
|
info "Erzeugt werden ausschliesslich die Konfigurationsdateien unter:" |
|
info " ${PAPERLESS_DIR}" |
|
fi |
|
|
|
step "System wird geprueft" |
|
|
|
[ "$(uname -s)" = "Darwin" ] || die "Dieses Skript laeuft nur auf macOS." |
|
[ "$(id -u)" -ne 0 ] || die "Bitte NICHT mit sudo starten - Homebrew, Docker und Colima gehoeren dem Benutzer." |
|
|
|
MACOS_VERSION="$(sw_vers -productVersion)" |
|
MACOS_MAJOR="${MACOS_VERSION%%.*}" |
|
ARCH="$(uname -m)" |
|
info "macOS ${MACOS_VERSION} auf ${ARCH}" |
|
|
|
[ "$MACOS_MAJOR" -ge 13 ] || warn "macOS 13 oder neuer wird empfohlen (gefunden: ${MACOS_VERSION})." |
|
|
|
if [ -n "${LOW_MEMORY_HOST:-}" ]; then |
|
warn "Nur ${LOW_MEMORY_HOST} GB RAM im System - paperless bekommt ${VM_MEMORY} GB." |
|
info "OCR kann dabei langsam werden oder fehlschlagen. Empfohlen sind 8 GB oder mehr." |
|
fi |
|
|
|
if ! xcode-select -p >/dev/null 2>&1; then |
|
if [ "$DRY_RUN" = "yes" ]; then |
|
skip "Xcode Command Line Tools wuerden installiert (xcode-select --install)." |
|
else |
|
step "Xcode Command Line Tools werden installiert" |
|
warn "Es oeffnet sich ein Dialog. Bitte bestaetigen und warten, bis er durchgelaufen ist." |
|
xcode-select --install || true |
|
until xcode-select -p >/dev/null 2>&1; do sleep 10; done |
|
ok "Command Line Tools vorhanden." |
|
fi |
|
else |
|
info "Xcode Command Line Tools vorhanden." |
|
fi |
|
|
|
# --------------------------------------------------------------------------- |
|
# 1. Hilfsfunktionen fuer Rueckfragen und Homebrew |
|
# --------------------------------------------------------------------------- |
|
|
|
# Stellt eine Ja/Nein-Frage. Antwortet nur mit "ja", wenn es wirklich ein "ja" |
|
# gibt - ohne Terminal (Cron, CI, Pipe) gilt das als "nein", ausser --yes. |
|
confirm() { # confirm <frage> |
|
local answer |
|
if [ "$ASSUME_YES" = "yes" ]; then |
|
info "$1 -> ja (--yes)" |
|
return 0 |
|
fi |
|
if [ ! -t 0 ]; then |
|
warn "Rueckfrage ohne Terminal nicht moeglich: $1" |
|
info "Mit --yes laufen solche Rueckfragen automatisch durch." |
|
return 1 |
|
fi |
|
printf ' %s [j/N] ' "$1" |
|
read -r answer |
|
case "$answer" in |
|
[jJyY]|[jJ][aA]|[yY][eE][sS]) return 0 ;; |
|
*) return 1 ;; |
|
esac |
|
} |
|
|
|
# Der uebliche Brew-Prefix, auch ohne installiertes Homebrew brauchbar. |
|
case "$ARCH" in |
|
arm64) BREW_PREFIX="/opt/homebrew" ;; |
|
*) BREW_PREFIX="/usr/local" ;; |
|
esac |
|
|
|
BREW_READY="no" |
|
|
|
# Installiert bzw. aktualisiert Homebrew - aber nur, wenn wirklich etwas |
|
# installiert werden muss. Laeuft paperless auf einem vorhandenen Docker, |
|
# wird Homebrew gar nicht angefasst. |
|
ensure_homebrew() { |
|
[ "$BREW_READY" = "yes" ] && return 0 |
|
|
|
step "Homebrew" |
|
|
|
# Brew ist evtl. installiert, aber nicht im PATH dieser Shell. |
|
if ! command -v brew >/dev/null 2>&1; then |
|
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do |
|
[ -x "$candidate" ] && eval "$("$candidate" shellenv)" && break |
|
done |
|
fi |
|
|
|
if ! command -v brew >/dev/null 2>&1; then |
|
if [ "$DRY_RUN" = "yes" ]; then |
|
skip "Homebrew ist nicht vorhanden und wuerde jetzt installiert." |
|
info "angenommener Brew-Prefix: ${BREW_PREFIX}" |
|
BREW_READY="yes" |
|
return 0 |
|
fi |
|
info "Homebrew ist nicht vorhanden und wird installiert." |
|
info "Der Installer fragt ggf. nach dem Admin-Passwort." |
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
|
|
|
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do |
|
[ -x "$candidate" ] && eval "$("$candidate" shellenv)" && break |
|
done |
|
command -v brew >/dev/null 2>&1 || die "Homebrew wurde installiert, ist aber nicht im PATH." |
|
|
|
BREW_BIN="$(command -v brew)" |
|
for profile in "${HOME}/.zprofile" "${HOME}/.bash_profile"; do |
|
if [ -f "$profile" ] || [ "$profile" = "${HOME}/.zprofile" ]; then |
|
grep -q 'brew shellenv' "$profile" 2>/dev/null \ |
|
|| printf '\neval "$(%s shellenv)"\n' "$BREW_BIN" >> "$profile" |
|
fi |
|
done |
|
ok "Homebrew installiert." |
|
else |
|
info "gefunden: $(command -v brew)" |
|
if [ "$DRY_RUN" = "yes" ]; then |
|
skip "brew update" |
|
else |
|
info "Update laeuft (kann einen Moment dauern)..." |
|
brew update |
|
ok "Homebrew aktuell." |
|
fi |
|
fi |
|
|
|
BREW_PREFIX="$(brew --prefix)" |
|
BREW_READY="yes" |
|
} |
|
|
|
# Installiert Formeln, falls sie fehlen, und haelt vorhandene aktuell. |
|
brew_ensure_formulae() { # brew_ensure_formulae <formel>... |
|
ensure_homebrew |
|
local f missing=() |
|
for f in "$@"; do |
|
if command -v brew >/dev/null 2>&1 && brew list --formula --versions "$f" >/dev/null 2>&1; then |
|
info "vorhanden: $f" |
|
else |
|
missing+=("$f") |
|
fi |
|
done |
|
|
|
if [ "$DRY_RUN" = "yes" ]; then |
|
[ ${#missing[@]} -gt 0 ] && skip "brew install --formula ${missing[*]}" |
|
skip "brew upgrade --formula $*" |
|
return 0 |
|
fi |
|
|
|
if [ ${#missing[@]} -gt 0 ]; then |
|
info "wird installiert: ${missing[*]}" |
|
brew install --formula "${missing[@]}" |
|
fi |
|
brew upgrade --formula "$@" 2>/dev/null || true |
|
} |
|
|
|
# --------------------------------------------------------------------------- |
|
# 2. Container-Runtime waehlen |
|
# --------------------------------------------------------------------------- |
|
# |
|
# Vorrang hat immer eine bereits vorhandene Docker-Installation. Colima wird |
|
# nur als Rueckfallebene eingerichtet, und auch das erst nach Rueckfrage - |
|
# es ist eine zusaetzliche Linux-VM auf dem Rechner. |
|
# |
|
# Reihenfolge: |
|
# 1. Ein Docker-Daemon ist erreichbar -> benutzen |
|
# 2. Docker Desktop / Rancher Desktop da, aus -> fragen, ob starten |
|
# 3. Colima-Instanz existiert bereits -> benutzen |
|
# 4. Nichts davon -> fragen, ob Colima einrichten |
|
|
|
step "Container-Runtime" |
|
|
|
RUNTIME="" # "docker" oder "colima" |
|
DOCKER_BIN="" |
|
DOCKER_SOCK="" # nur bei Colima gesetzt |
|
|
|
DOCKER_DESKTOP_APP="/Applications/Docker.app" |
|
RANCHER_APP="/Applications/Rancher Desktop.app" |
|
|
|
# Sucht eine docker-CLI. Bei vorhandenem Docker Desktop ist dessen Binary die |
|
# richtige Wahl, deshalb hat der PATH hier Vorrang. |
|
find_docker_cli() { |
|
local c |
|
for c in "$(command -v docker 2>/dev/null || true)" \ |
|
"${HOME}/.docker/bin/docker" \ |
|
/usr/local/bin/docker \ |
|
"${BREW_PREFIX}/bin/docker"; do |
|
if [ -n "$c" ] && [ -x "$c" ]; then printf '%s' "$c"; return 0; fi |
|
done |
|
return 1 |
|
} |
|
|
|
daemon_reachable() { |
|
[ -n "$DOCKER_BIN" ] && "$DOCKER_BIN" info >/dev/null 2>&1 |
|
} |
|
|
|
# Wartet, bis der Daemon antwortet (Docker Desktop braucht nach dem Start |
|
# typischerweise 20-60 Sekunden). |
|
wait_for_daemon() { # wait_for_daemon <sekunden> |
|
local waited=0 limit="${1:-90}" |
|
while [ "$waited" -lt "$limit" ]; do |
|
daemon_reachable && return 0 |
|
sleep 3 |
|
waited=$(( waited + 3 )) |
|
[ $(( waited % 15 )) -eq 0 ] && info " ... noch nicht bereit (${waited}s)" |
|
done |
|
return 1 |
|
} |
|
|
|
# Versucht, eine Desktop-Anwendung als Runtime zu starten. |
|
try_start_app() { # try_start_app <name> <app-pfad> |
|
local name="$1" app="$2" |
|
[ -d "$app" ] || return 1 |
|
info "${name} ist installiert, der Daemon laeuft aber nicht." |
|
if [ "$DRY_RUN" = "yes" ]; then |
|
skip "${name} starten (open -a)" |
|
return 1 |
|
fi |
|
confirm "${name} jetzt starten?" || return 1 |
|
open -a "$app" || { warn "${name} liess sich nicht starten."; return 1; } |
|
info "Warte auf den Daemon..." |
|
if wait_for_daemon 120; then |
|
ok "${name} laeuft." |
|
return 0 |
|
fi |
|
warn "${name} wurde gestartet, der Daemon antwortet aber nicht." |
|
return 1 |
|
} |
|
|
|
DOCKER_BIN="$(find_docker_cli || true)" |
|
|
|
if [ "$RUNTIME_CHOICE" != "colima" ] && [ -n "$DOCKER_BIN" ] && daemon_reachable; then |
|
RUNTIME="docker" |
|
ok "Docker laeuft bereits - wird verwendet." |
|
info "CLI: ${DOCKER_BIN}" |
|
info "Kontext: $("$DOCKER_BIN" context show 2>/dev/null || echo unbekannt)" |
|
elif [ "$RUNTIME_CHOICE" != "colima" ] && try_start_app "Docker Desktop" "$DOCKER_DESKTOP_APP"; then |
|
RUNTIME="docker" |
|
elif [ "$RUNTIME_CHOICE" != "colima" ] && try_start_app "Rancher Desktop" "$RANCHER_APP"; then |
|
RUNTIME="docker" |
|
elif [ "$RUNTIME_CHOICE" = "docker" ]; then |
|
die "Mit --runtime docker verlangt, aber kein Docker-Daemon erreichbar." |
|
fi |
|
|
|
if [ -z "$RUNTIME" ]; then |
|
# Rueckfallebene Colima. Eine bereits eingerichtete Instanz gilt als |
|
# Zustimmung - dann wurde die Frage frueher schon einmal bejaht. |
|
COLIMA_BIN="${BREW_PREFIX}/bin/colima" |
|
[ -x "$COLIMA_BIN" ] || COLIMA_BIN="$(command -v colima 2>/dev/null || true)" |
|
|
|
# colima list fuehrt auch gestoppte Instanzen auf - im Gegensatz zu |
|
# "colima status", das nur bei laufenden Instanzen erfolgreich ist. |
|
COLIMA_EXISTS="no" |
|
if [ -n "$COLIMA_BIN" ] && "$COLIMA_BIN" list 2>/dev/null \ |
|
| awk -v p="$COLIMA_PROFILE" 'NR>1 && $1==p {found=1} END {exit !found}'; then |
|
COLIMA_EXISTS="yes" |
|
fi |
|
|
|
if [ "$COLIMA_EXISTS" = "yes" ]; then |
|
info "Colima-Instanz \"${COLIMA_PROFILE}\" ist bereits eingerichtet." |
|
RUNTIME="colima" |
|
else |
|
echo |
|
if [ "$RUNTIME_CHOICE" = "colima" ]; then |
|
info "Colima wurde per --runtime colima angefordert." |
|
else |
|
info "Es wurde keine laufende Docker-Installation gefunden." |
|
fi |
|
info "Einrichten von Colima bedeutet:" |
|
info " - Homebrew wird installiert bzw. aktualisiert" |
|
info " - die Formeln colima, docker und docker-compose werden installiert" |
|
info " - eine Linux-VM mit ${VM_CPU} CPUs, ${VM_MEMORY} GB RAM und ${VM_DISK} GB Disk" |
|
info " wird als eigene Instanz \"${COLIMA_PROFILE}\" angelegt" |
|
echo |
|
if [ "$DRY_RUN" = "yes" ]; then |
|
skip "Rueckfrage, ob Colima eingerichtet werden soll (im Trockenlauf als ja angenommen)" |
|
RUNTIME="colima" |
|
elif confirm "Colima jetzt einrichten und verwenden?"; then |
|
RUNTIME="colima" |
|
else |
|
echo |
|
info "Abgebrochen - es wurde nichts installiert." |
|
info "Alternativen:" |
|
info " - Docker Desktop installieren: https://www.docker.com/products/docker-desktop/" |
|
info " - danach dieses Skript erneut ausfuehren" |
|
exit 0 |
|
fi |
|
fi |
|
fi |
|
|
|
# Was die Wahl konkret bedeutet |
|
if [ "$RUNTIME" = "colima" ]; then |
|
# Colima braucht die reine docker-CLI aus Homebrew - nicht das Cask, das |
|
# waere Docker Desktop. |
|
brew_ensure_formulae colima docker docker-compose |
|
|
|
if [ "$DRY_RUN" = "yes" ]; then |
|
skip "Symlink ~/.docker/cli-plugins/docker-compose" |
|
else |
|
# docker-compose als CLI-Plugin verfuegbar machen. |
|
mkdir -p "${HOME}/.docker/cli-plugins" |
|
COMPOSE_BIN="${BREW_PREFIX}/opt/docker-compose/bin/docker-compose" |
|
[ -x "$COMPOSE_BIN" ] || COMPOSE_BIN="${BREW_PREFIX}/bin/docker-compose" |
|
[ -x "$COMPOSE_BIN" ] || die "docker-compose wurde nicht gefunden." |
|
ln -sfn "$COMPOSE_BIN" "${HOME}/.docker/cli-plugins/docker-compose" |
|
fi |
|
|
|
# Bei Colima bewusst der feste Homebrew-Pfad: Docker Desktop und Rancher |
|
# Desktop legen eigene "docker"-Binaries in den PATH und schatten die von |
|
# Homebrew, und sie setzen einen eigenen aktiven Kontext. Die Container |
|
# wuerden sonst in der falschen VM landen. |
|
DOCKER_BIN="${BREW_PREFIX}/bin/docker" |
|
[ -x "$DOCKER_BIN" ] || [ "$DRY_RUN" = "yes" ] \ |
|
|| die "docker-CLI nicht unter ${DOCKER_BIN} gefunden." |
|
info "Docker-CLI: ${DOCKER_BIN}" |
|
else |
|
# Vorhandenes Docker: "docker compose" bringt Docker Desktop selbst mit. |
|
if [ "$DRY_RUN" != "yes" ] && ! "$DOCKER_BIN" compose version >/dev/null 2>&1; then |
|
warn "Die vorhandene docker-CLI kennt kein \"compose\"." |
|
if confirm "docker-compose per Homebrew nachinstallieren?"; then |
|
brew_ensure_formulae docker-compose |
|
mkdir -p "${HOME}/.docker/cli-plugins" |
|
COMPOSE_BIN="${BREW_PREFIX}/opt/docker-compose/bin/docker-compose" |
|
[ -x "$COMPOSE_BIN" ] || COMPOSE_BIN="${BREW_PREFIX}/bin/docker-compose" |
|
ln -sfn "$COMPOSE_BIN" "${HOME}/.docker/cli-plugins/docker-compose" |
|
"$DOCKER_BIN" compose version >/dev/null 2>&1 \ |
|
|| die "\"docker compose\" funktioniert weiterhin nicht." |
|
else |
|
die "Ohne \"docker compose\" kann paperless nicht eingerichtet werden." |
|
fi |
|
fi |
|
fi |
|
|
|
ok "Runtime: ${RUNTIME}" |
|
|
|
# Alle Compose-Aufrufe laufen ueber diesen Helfer. |
|
dc() { |
|
"$DOCKER_BIN" compose --project-directory "$PAPERLESS_DIR" -p "$COMPOSE_PROJECT" "$@" |
|
} |
|
|
|
# --------------------------------------------------------------------------- |
|
# 3. Deinstallation (braucht die Werkzeuge von oben) |
|
# --------------------------------------------------------------------------- |
|
|
|
if [ "$DO_UNINSTALL" = "yes" ]; then |
|
step "Deinstallation" |
|
info "Entfernt werden die Container und die Broker-Queue." |
|
info "Dokumente, Datenbank und Konfiguration liegen als Bind-Mount auf der" |
|
info "Platte und bleiben unter ${PAPERLESS_DIR} erhalten." |
|
printf ' Zum Fortfahren "loeschen" eingeben: ' |
|
read -r CONFIRM |
|
[ "$CONFIRM" = "loeschen" ] || die "Abgebrochen. Es wurde nichts geaendert." |
|
|
|
if [ -f "$COMPOSE_FILE" ]; then |
|
if [ "$RUNTIME" = "colima" ]; then |
|
export DOCKER_HOST="unix://${HOME}/.colima/${COLIMA_PROFILE}/docker.sock" |
|
fi |
|
dc down --volumes || true |
|
fi |
|
ok "Container entfernt." |
|
if [ "$RUNTIME" = "colima" ]; then |
|
info "Die Colima-Instanz laeuft weiter: colima stop --profile ${COLIMA_PROFILE}" |
|
fi |
|
echo |
|
info "Daten wirklich loeschen - das ist unwiderruflich:" |
|
info " rm -rf \"${PAPERLESS_DIR}\"" |
|
exit 0 |
|
fi |
|
|
|
# --------------------------------------------------------------------------- |
|
# 4. Verzeichnisse |
|
# --------------------------------------------------------------------------- |
|
|
|
step "Verzeichnisstruktur unter ${PAPERLESS_DIR}" |
|
mkdir -p "$PAPERLESS_DIR" "$CONSUME_DIR" "$EXPORT_DIR" "$BIN_DIR" \ |
|
"$MEDIA_DIR" "$DATA_DIR" "$PGDATA_DIR" |
|
|
|
# PostgreSQL verweigert den Start, wenn das Datenverzeichnis fuer andere |
|
# lesbar ist. 0700 setzen, bevor der Container es das erste Mal anfasst. |
|
chmod 700 "$PGDATA_DIR" |
|
ok "Verzeichnisse angelegt." |
|
|
|
# --------------------------------------------------------------------------- |
|
# 5. Container-Runtime bereitstellen |
|
# --------------------------------------------------------------------------- |
|
|
|
if [ "$RUNTIME" = "colima" ]; then |
|
|
|
step "Colima-VM" |
|
|
|
info "Eigene Instanz: Profil \"${COLIMA_PROFILE}\" (vorhandene Colima-Profile bleiben unberuehrt)" |
|
|
|
# --arch host wird bewusst gesetzt: erbt das Profil je eine abweichende |
|
# Architektur, versucht Colima sonst zu emulieren und verlangt QEMU. |
|
COLIMA_ARGS=( |
|
--profile "$COLIMA_PROFILE" |
|
--arch host |
|
--cpu "$VM_CPU" |
|
--memory "$VM_MEMORY" |
|
--disk "$VM_DISK" |
|
--mount "${PAPERLESS_DIR}:w" |
|
) |
|
# Auf Apple Silicon ab macOS 13 ist das Apple-Virtualization-Framework mit |
|
# virtiofs deutlich schneller als der QEMU-Default. |
|
if [ "$ARCH" = "arm64" ] && [ "$MACOS_MAJOR" -ge 13 ]; then |
|
COLIMA_ARGS+=(--vm-type vz --mount-type virtiofs) |
|
fi |
|
|
|
if [ "$DRY_RUN" = "yes" ]; then |
|
info "Die VM wuerde angelegt mit:" |
|
info " ${VM_CPU} CPUs (${CPU_SOURCE})" |
|
info " ${VM_MEMORY} GB RAM (${MEM_SOURCE})" |
|
info " ${VM_DISK} GB Disk (${DISK_SOURCE}, waechst nur nach Bedarf)" |
|
skip "colima start ${COLIMA_ARGS[*]}" |
|
skip "LaunchAgent fuer den Autostart" |
|
skip "Erreichbarkeitspruefung ueber den Colima-Socket" |
|
elif colima status --profile "$COLIMA_PROFILE" >/dev/null 2>&1; then |
|
info "Instanz \"${COLIMA_PROFILE}\" laeuft bereits." |
|
|
|
# Pruefen, ob das Installationsverzeichnis in der VM beschreibbar ist. |
|
if ! colima ssh --profile "$COLIMA_PROFILE" -- test -w "$CONSUME_DIR" 2>/dev/null; then |
|
warn "${CONSUME_DIR} ist in der Colima-VM nicht beschreibbar." |
|
info "Instanz mit passendem Mount neu starten:" |
|
info " colima stop --profile ${COLIMA_PROFILE}" |
|
info " colima start --profile ${COLIMA_PROFILE} --mount \"${PAPERLESS_DIR}:w\"" |
|
die "Bitte obige Befehle ausfuehren und das Skript erneut starten." |
|
fi |
|
ok "VM erreichbar, Mounts in Ordnung." |
|
else |
|
info "VM wird gestartet mit:" |
|
info " ${VM_CPU} CPUs (${CPU_SOURCE})" |
|
info " ${VM_MEMORY} GB RAM (${MEM_SOURCE})" |
|
info " ${VM_DISK} GB Disk (${DISK_SOURCE}, waechst nur nach Bedarf)" |
|
info "Beim ersten Mal wird ein Linux-Image geladen - das dauert ein paar Minuten." |
|
colima start "${COLIMA_ARGS[@]}" |
|
ok "Colima-Instanz \"${COLIMA_PROFILE}\" laeuft." |
|
fi |
|
|
|
if [ "$DRY_RUN" != "yes" ]; then |
|
# Verbindung ueber den Socket der eigenen Instanz, nicht ueber den aktiven |
|
# Docker-Kontext - der zeigt auf Rechnern mit Docker Desktop oder Rancher |
|
# Desktop woandershin. |
|
DOCKER_SOCK="${HOME}/.colima/${COLIMA_PROFILE}/docker.sock" |
|
if [ ! -S "$DOCKER_SOCK" ]; then |
|
# Falls Colima die Pfadkonvention aendert: aus der Statusausgabe holen. |
|
DOCKER_SOCK="$(colima status --profile "$COLIMA_PROFILE" --json 2>/dev/null \ |
|
| tr ',' '\n' | sed -n 's|.*"\(/[^"]*docker\.sock\)".*|\1|p' | head -1)" |
|
fi |
|
[ -S "$DOCKER_SOCK" ] || die "Docker-Socket der Instanz \"${COLIMA_PROFILE}\" nicht gefunden." |
|
export DOCKER_HOST="unix://${DOCKER_SOCK}" |
|
info "Docker-Socket: ${DOCKER_SOCK}" |
|
|
|
"$DOCKER_BIN" info >/dev/null 2>&1 \ |
|
|| die "Docker erreicht die Instanz \"${COLIMA_PROFILE}\" nicht. Pruefen: colima status --profile ${COLIMA_PROFILE}" |
|
|
|
# Autostart. "brew services start colima" wuerde das Standardprofil starten, |
|
# nicht unseres - deshalb ein eigener LaunchAgent. |
|
AGENT_LABEL="org.paperlessngx.colima" |
|
AGENT_PLIST="${HOME}/Library/LaunchAgents/${AGENT_LABEL}.plist" |
|
mkdir -p "${HOME}/Library/LaunchAgents" |
|
cat > "$AGENT_PLIST" <<EOF |
|
<?xml version="1.0" encoding="UTF-8"?> |
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
<plist version="1.0"> |
|
<dict> |
|
<key>Label</key> |
|
<string>${AGENT_LABEL}</string> |
|
<key>ProgramArguments</key> |
|
<array> |
|
<string>${BREW_PREFIX}/bin/colima</string> |
|
<string>start</string> |
|
<string>--profile</string> |
|
<string>${COLIMA_PROFILE}</string> |
|
</array> |
|
<key>RunAtLoad</key> |
|
<true/> |
|
<key>StandardOutPath</key> |
|
<string>${PAPERLESS_DIR}/colima-autostart.log</string> |
|
<key>StandardErrorPath</key> |
|
<string>${PAPERLESS_DIR}/colima-autostart.log</string> |
|
</dict> |
|
</plist> |
|
EOF |
|
launchctl bootout "gui/$(id -u)/${AGENT_LABEL}" 2>/dev/null || true |
|
if launchctl bootstrap "gui/$(id -u)" "$AGENT_PLIST" 2>/dev/null; then |
|
ok "Autostart aktiviert (Instanz startet beim Anmelden)." |
|
else |
|
warn "Autostart konnte nicht registriert werden." |
|
info "Manuell: colima start --profile ${COLIMA_PROFILE}" |
|
fi |
|
fi |
|
|
|
else |
|
# Vorhandenes Docker: kein eigener Socket, kein Autostart, keine VM-Groesse. |
|
# Der Daemon wurde in Abschnitt 2 bereits als erreichbar geprueft. |
|
step "Container-Runtime" |
|
info "Vorhandenes Docker wird verwendet - keine zusaetzliche VM noetig." |
|
if [ "$DRY_RUN" != "yes" ]; then |
|
info "Kontext: $("$DOCKER_BIN" context show 2>/dev/null || echo unbekannt)" |
|
fi |
|
fi |
|
|
|
# --------------------------------------------------------------------------- |
|
# 6. Secrets |
|
# --------------------------------------------------------------------------- |
|
|
|
step "Konfiguration" |
|
|
|
# Erzeugt eine zufaellige alphanumerische Zeichenkette. |
|
# |
|
# Bewusst NICHT als "tr -dc ... < /dev/urandom | head -c N": head schliesst die |
|
# Pipe, sobald es genug Bytes hat, tr bekommt SIGPIPE und endet mit 141. Unter |
|
# "set -o pipefail" ist damit die ganze Pipeline fehlerhaft und "set -e" bricht |
|
# das Skript ab - reproduzierbar bei jeder Neuinstallation. |
|
rand_string() { |
|
local n="${1:-32}" out="" chunk="" tries=0 |
|
while [ "${#out}" -lt "$n" ]; do |
|
tries=$(( tries + 1 )) |
|
[ "$tries" -gt 20 ] && die "Zufallswerte konnten nicht erzeugt werden - ist openssl vorhanden?" |
|
chunk="$(openssl rand -base64 48 2>/dev/null | tr -d '/+=[:space:]')" || chunk="" |
|
out="${out}${chunk}" |
|
done |
|
printf '%s' "${out:0:$n}" |
|
} |
|
|
|
if [ -f "$ENV_FILE" ]; then |
|
info "Bestehende Secrets werden uebernommen: ${ENV_FILE}" |
|
# shellcheck disable=SC1090 |
|
set -a; source "$ENV_FILE"; set +a |
|
else |
|
POSTGRES_PASSWORD="$(rand_string 32)" |
|
PAPERLESS_SECRET_KEY="$(rand_string 64)" |
|
PAPERLESS_ADMIN_PASSWORD="$(rand_string 20)" |
|
|
|
cat > "$ENV_FILE" <<EOF |
|
# Zugangsdaten fuer die paperless-ngx Installation. Nur fuer den Besitzer lesbar. |
|
# Diese Datei sichern - ohne sie ist die Datenbank nicht mehr zu oeffnen. |
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD} |
|
PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY} |
|
PAPERLESS_ADMIN_USER=${ADMIN_USER} |
|
PAPERLESS_ADMIN_PASSWORD=${PAPERLESS_ADMIN_PASSWORD} |
|
NEW_INSTALL=yes |
|
EOF |
|
chmod 600 "$ENV_FILE" |
|
ok "Secrets erzeugt: ${ENV_FILE}" |
|
fi |
|
|
|
# Das paperless-Image bringt eng, deu, ita, spa und fra bereits mit. Nur was |
|
# darueber hinausgeht, muss ueber PAPERLESS_OCR_LANGUAGES beim Start des |
|
# Containers nachinstalliert werden - das kostet bei jedem Start Zeit. |
|
BUILTIN_LANGS="eng deu ita spa fra" |
|
EXTRA_LANGS="" |
|
for lang in $(printf '%s' "$OCR_LANGUAGES" | tr '+' ' '); do |
|
case " ${BUILTIN_LANGS} " in |
|
*" ${lang} "*) ;; |
|
*) EXTRA_LANGS="${EXTRA_LANGS}${EXTRA_LANGS:+ }${lang}" ;; |
|
esac |
|
done |
|
|
|
if [ -n "$EXTRA_LANGS" ]; then |
|
OCR_LANGUAGES_LINE="PAPERLESS_OCR_LANGUAGES=${EXTRA_LANGS}" |
|
info "Zusaetzlich nachzuinstallierende Tesseract-Sprachen: ${EXTRA_LANGS}" |
|
else |
|
OCR_LANGUAGES_LINE="# PAPERLESS_OCR_LANGUAGES= (nicht noetig, alle gewaehlten Sprachen sind im Image)" |
|
fi |
|
|
|
# Anwendungseinstellungen - hier darf der Benutzer gefahrlos nachjustieren. |
|
if [ -f "$APP_ENV_FILE" ]; then |
|
info "Bestehende Anwendungskonfiguration wird beibehalten: ${APP_ENV_FILE}" |
|
else |
|
cat > "$APP_ENV_FILE" <<EOF |
|
# paperless-ngx Anwendungseinstellungen |
|
# Alle Optionen: https://docs.paperless-ngx.com/configuration/ |
|
# Nach Aenderungen: ${BIN_DIR}/paperlessctl restart |
|
|
|
PAPERLESS_TIME_ZONE=${TIME_ZONE} |
|
PAPERLESS_OCR_LANGUAGE=${OCR_LANGUAGES} |
|
${OCR_LANGUAGES_LINE} |
|
# Modi ab paperless 3.0: auto (Default), redo, force, off |
|
PAPERLESS_OCR_MODE=auto |
|
PAPERLESS_OCR_CLEAN=clean |
|
|
|
# Der consume-Ordner liegt auf dem Mac und wird in die VM gemountet. |
|
# Dateisystem-Events kommen dort nicht zuverlaessig an, deshalb Polling. |
|
PAPERLESS_CONSUMER_POLLING=10 |
|
PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=5 |
|
PAPERLESS_CONSUMER_POLLING_DELAY=5 |
|
PAPERLESS_CONSUMER_RECURSIVE=true |
|
PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=true |
|
# .DS_Store, ._* und Thumbs.db ignoriert paperless bereits eingebaut. |
|
# Hier nur die restlichen macOS-Eigenheiten - als Regex, per re.match auf den |
|
# reinen Dateinamen angewendet (nicht auf den Pfad). |
|
PAPERLESS_CONSUMER_IGNORE_PATTERNS=["^\\\\.localized\$", "^\\\\.Spotlight-V100", "^\\\\.Trashes", "^\\\\.fseventsd", "^\\\\.TemporaryItems", "^\\\\.apdisk\$", "^\\\\.stfolder", "^\\\\.stversions"] |
|
|
|
# Ab paperless 3.0 Jinja-Syntax mit doppelten geschweiften Klammern. |
|
PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ correspondent }}/{{ title }} |
|
|
|
# Fuer Zugriff von anderen Geraeten: hier die vollstaendige URL eintragen, |
|
# sonst schlaegt die CSRF-Pruefung beim Anmelden fehl. |
|
# PAPERLESS_URL=http://mein-mac.local:${WEB_PORT} |
|
EOF |
|
ok "Anwendungskonfiguration erstellt: ${APP_ENV_FILE}" |
|
fi |
|
|
|
# --------------------------------------------------------------------------- |
|
# 7. Compose-Datei |
|
# --------------------------------------------------------------------------- |
|
|
|
cat > "$COMPOSE_FILE" <<EOF |
|
# Automatisch erzeugt von install-paperless-macos.sh - Aenderungen gehen bei |
|
# einem erneuten Lauf des Skripts verloren. |
|
# Anwendungseinstellungen gehoeren in paperless.env, Secrets in .env |
|
|
|
services: |
|
broker: |
|
image: ${BROKER_IMAGE} |
|
container_name: paperless-broker |
|
restart: unless-stopped |
|
volumes: |
|
- redisdata:/data |
|
healthcheck: |
|
test: ["CMD", "valkey-cli", "ping"] |
|
interval: 10s |
|
timeout: 5s |
|
retries: 10 |
|
|
|
db: |
|
image: ${POSTGRES_IMAGE} |
|
container_name: paperless-db |
|
restart: unless-stopped |
|
volumes: |
|
# Bind-Mount auf APFS, damit Time Machine die Datenbank mitsichert. |
|
# postgres:18 legt die Daten unter /var/lib/postgresql/18/docker ab, |
|
# deshalb wird das Elternverzeichnis gemountet, nicht .../data. |
|
- ${PGDATA_DIR}:/var/lib/postgresql |
|
environment: |
|
POSTGRES_DB: paperless |
|
POSTGRES_USER: paperless |
|
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env} |
|
healthcheck: |
|
test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"] |
|
interval: 10s |
|
timeout: 5s |
|
retries: 10 |
|
|
|
webserver: |
|
image: ${PAPERLESS_IMAGE}:${IMAGE_TAG} |
|
container_name: paperless-webserver |
|
restart: unless-stopped |
|
depends_on: |
|
db: |
|
condition: service_healthy |
|
broker: |
|
condition: service_healthy |
|
ports: |
|
- "${BIND_ADDR}:${WEB_PORT}:8000" |
|
volumes: |
|
- ${DATA_DIR}:/usr/src/paperless/data |
|
- ${MEDIA_DIR}:/usr/src/paperless/media |
|
- ${CONSUME_DIR}:/usr/src/paperless/consume |
|
- ${EXPORT_DIR}:/usr/src/paperless/export |
|
env_file: |
|
- ${APP_ENV_FILE} |
|
environment: |
|
PAPERLESS_REDIS: redis://broker:6379 |
|
PAPERLESS_DBENGINE: postgresql |
|
PAPERLESS_DBHOST: db |
|
PAPERLESS_DBPORT: 5432 |
|
PAPERLESS_DBNAME: paperless |
|
PAPERLESS_DBUSER: paperless |
|
PAPERLESS_DBPASS: \${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env} |
|
PAPERLESS_SECRET_KEY: \${PAPERLESS_SECRET_KEY:?PAPERLESS_SECRET_KEY fehlt in .env} |
|
# Wird nur beim allerersten Start ausgewertet und legt das Admin-Konto an. |
|
PAPERLESS_ADMIN_USER: \${PAPERLESS_ADMIN_USER:-admin} |
|
PAPERLESS_ADMIN_PASSWORD: \${PAPERLESS_ADMIN_PASSWORD:-} |
|
healthcheck: |
|
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"] |
|
interval: 30s |
|
timeout: 10s |
|
retries: 5 |
|
start_period: 60s |
|
|
|
volumes: |
|
# Nur noch die Broker-Queue als Volume - das ist fluechtiger Arbeitszustand |
|
# ohne Sicherungswert. Alles andere liegt als Bind-Mount unter ${PAPERLESS_DIR}. |
|
redisdata: |
|
EOF |
|
ok "docker-compose.yml geschrieben." |
|
|
|
# --------------------------------------------------------------------------- |
|
# 8. Verwaltungswerkzeug |
|
# --------------------------------------------------------------------------- |
|
|
|
cat > "${BIN_DIR}/paperlessctl" <<EOF |
|
#!/bin/bash |
|
# Verwaltung der paperless-ngx Installation unter ${PAPERLESS_DIR} |
|
set -euo pipefail |
|
|
|
DIR="${PAPERLESS_DIR}" |
|
PROJECT="${COMPOSE_PROJECT}" |
|
PROFILE="${COLIMA_PROFILE}" |
|
RUNTIME="${RUNTIME}" |
|
DOCKER="${DOCKER_BIN}" |
|
COLIMA="${BREW_PREFIX}/bin/colima" |
|
|
|
# Bei Colima fest ueber den Socket der eigenen Instanz: Docker Desktop und |
|
# Rancher Desktop bringen eigene "docker"-Binaries mit und setzen den aktiven |
|
# Kontext auf ihre eigene VM. Bei vorhandenem Docker gilt dessen Kontext. |
|
if [ "\$RUNTIME" = "colima" ]; then |
|
export DOCKER_HOST="unix://\${HOME}/.colima/\${PROFILE}/docker.sock" |
|
fi |
|
|
|
dc() { "\$DOCKER" compose --project-directory "\$DIR" -p "\$PROJECT" "\$@"; } |
|
|
|
# Sorgt dafuer, dass die Runtime laeuft. |
|
ensure_vm() { |
|
if [ "\$RUNTIME" = "colima" ]; then |
|
if ! "\$COLIMA" status --profile "\$PROFILE" >/dev/null 2>&1; then |
|
echo "Colima-Instanz \"\$PROFILE\" wird gestartet..." |
|
"\$COLIMA" start --profile "\$PROFILE" |
|
fi |
|
return |
|
fi |
|
|
|
"\$DOCKER" info >/dev/null 2>&1 && return |
|
|
|
if [ -d /Applications/Docker.app ]; then |
|
echo "Docker-Daemon laeuft nicht - Docker Desktop wird gestartet..." |
|
open -a /Applications/Docker.app |
|
for _ in \$(seq 1 40); do |
|
"\$DOCKER" info >/dev/null 2>&1 && return |
|
sleep 3 |
|
done |
|
fi |
|
echo "Docker-Daemon ist nicht erreichbar. Bitte Docker starten." >&2 |
|
exit 1 |
|
} |
|
|
|
usage() { |
|
cat <<'USAGE' |
|
paperlessctl <befehl> |
|
|
|
start Runtime und alle Container starten |
|
stop Container stoppen (Runtime laeuft weiter) |
|
restart Container neu starten |
|
status Status von Runtime und Containern |
|
logs [dienst] Logs folgen (webserver|db|broker, Default: alle) |
|
update Neue Images holen und Container erneuern |
|
backup Dokumente + Metadaten nach export/ schreiben |
|
manage ... Django-Kommando, z.B. "paperlessctl manage createsuperuser" |
|
shell Shell im paperless-Container |
|
psql psql-Sitzung in der Datenbank |
|
|
|
repair-password Datenbank-Passwort wieder mit .env in Einklang bringen. |
|
Noetig, wenn pgdata aus einer frueheren Installation stammt |
|
und paperless mit "password authentication failed" abbricht. |
|
USAGE |
|
} |
|
|
|
cmd="\${1:-}"; shift || true |
|
|
|
case "\$cmd" in |
|
start) ensure_vm; dc up -d ;; |
|
stop) dc stop ;; |
|
restart) ensure_vm; dc up -d --force-recreate ;; |
|
status) |
|
if [ "\$RUNTIME" = "colima" ]; then |
|
"\$COLIMA" status --profile "\$PROFILE" || true |
|
else |
|
echo "Runtime: docker (\$("\$DOCKER" context show 2>/dev/null || echo '?'))" |
|
fi |
|
echo |
|
dc ps |
|
;; |
|
logs) |
|
if [ \$# -gt 0 ]; then dc logs -f --tail 100 "\$@"; else dc logs -f --tail 100; fi |
|
;; |
|
update) |
|
ensure_vm |
|
dc pull |
|
dc up -d |
|
"\$DOCKER" image prune -f >/dev/null 2>&1 || true |
|
echo "Update abgeschlossen." |
|
;; |
|
backup) |
|
dc exec -T webserver document_exporter ../export --delete --no-progress-bar |
|
echo "Export liegt in ${EXPORT_DIR}" |
|
echo "Ebenfalls sichern: ${ENV_FILE} (enthaelt die Schluessel)" |
|
;; |
|
repair-password) |
|
PW="\$(sed -n 's/^POSTGRES_PASSWORD=//p' "\${DIR}/.env" | head -1)" |
|
[ -n "\$PW" ] || { echo "POSTGRES_PASSWORD fehlt in \${DIR}/.env" >&2; exit 1; } |
|
# Ueber den lokalen Socket gilt "trust" - das alte Passwort wird nicht gebraucht. |
|
printf "ALTER USER paperless PASSWORD '%s';\n" "\${PW//\\'/\\'\\'}" \\ |
|
| dc exec -T db psql -U paperless -d paperless -q |
|
echo "Passwort abgeglichen. Webserver wird neu gestartet..." |
|
dc restart webserver |
|
;; |
|
manage) dc exec webserver python3 manage.py "\$@" ;; |
|
shell) dc exec webserver bash ;; |
|
psql) dc exec db psql -U paperless -d paperless ;; |
|
*) |
|
usage |
|
[ -z "\$cmd" ] && exit 0 || exit 1 |
|
;; |
|
esac |
|
EOF |
|
chmod +x "${BIN_DIR}/paperlessctl" |
|
ok "Verwaltungswerkzeug: ${BIN_DIR}/paperlessctl" |
|
|
|
# --------------------------------------------------------------------------- |
|
# 9. Images holen und starten |
|
# --------------------------------------------------------------------------- |
|
|
|
if [ "$DRY_RUN" = "yes" ]; then |
|
step "Was ein echter Lauf jetzt noch tun wuerde" |
|
skip "docker compose pull (ca. 1,5 GB: paperless-ngx, postgres, valkey)" |
|
skip "docker compose up -d (broker, db, webserver)" |
|
skip "Warten auf http://${BIND_ADDR}:${WEB_PORT}/accounts/login/" |
|
|
|
cat <<EOF |
|
|
|
${C_GREEN}Trockenlauf abgeschlossen.${C_OFF} Es wurde nichts installiert und nichts gestartet. |
|
|
|
Erzeugte Dateien: |
|
${COMPOSE_FILE} |
|
${APP_ENV_FILE} |
|
${ENV_FILE} |
|
${BIN_DIR}/paperlessctl |
|
|
|
Pruefen laesst sich das Ergebnis ohne laufende VM mit: |
|
${DOCKER_BIN} compose --project-directory "${PAPERLESS_DIR}" config |
|
|
|
Echter Lauf: dasselbe Kommando ohne --dry-run. |
|
|
|
EOF |
|
exit 0 |
|
fi |
|
|
|
if [ "$START_STACK" != "yes" ]; then |
|
cat <<EOF |
|
|
|
${C_GREEN}Vorbereitung abgeschlossen.${C_OFF} Die Container wurden auf Wunsch nicht gestartet. |
|
Start mit: ${BIN_DIR}/paperlessctl start |
|
|
|
EOF |
|
exit 0 |
|
fi |
|
|
|
step "Container-Images werden geladen" |
|
info "Beim ersten Mal werden ca. 1,5 GB heruntergeladen." |
|
dc pull |
|
ok "Images vorhanden." |
|
|
|
step "Container werden gestartet" |
|
dc up -d |
|
ok "Container gestartet." |
|
|
|
# Die Datenbank liegt als Bind-Mount auf APFS und wird per virtiofs in die |
|
# Colima-VM gereicht. Wenn PostgreSQL dabei ueber Eigentuemer oder Rechte |
|
# stolpert, scheitert es hier - und zwar bevor paperless ueberhaupt startet. |
|
step "Datenbank wird geprueft" |
|
DB_OK="no" |
|
for _ in $(seq 1 60); do |
|
if dc exec -T db pg_isready -U paperless -d paperless >/dev/null 2>&1; then |
|
DB_OK="yes"; break |
|
fi |
|
sleep 2 |
|
done |
|
|
|
if [ "$DB_OK" = "yes" ]; then |
|
ok "PostgreSQL laeuft auf dem Bind-Mount unter ${PGDATA_DIR}" |
|
|
|
# Passwort des Datenbankbenutzers mit .env abgleichen. |
|
# |
|
# PostgreSQL wertet POSTGRES_PASSWORD ausschliesslich bei der Erst- |
|
# initialisierung aus. Bleibt pgdata bestehen, waehrend .env neu erzeugt |
|
# wird - etwa weil nur ein Teil der Installation geloescht wurde - traegt |
|
# der Cluster weiter das alte Passwort, und paperless scheitert mit |
|
# "password authentication failed". Seit pgdata ein Bind-Mount ist, kann |
|
# das leicht passieren: die Daten ueberleben jedes Aufraeumen im |
|
# Installationsverzeichnis. |
|
# |
|
# Der Abgleich ist idempotent und laeuft ueber den lokalen Unix-Socket, |
|
# fuer den in der Standard-pg_hba.conf "trust" gilt - also ohne Kenntnis |
|
# des alten Passworts. Uebergabe per stdin, damit das Passwort nicht in |
|
# der Prozessliste des Containers auftaucht. |
|
# Einfache Anfuehrungszeichen verdoppeln, falls jemand .env von Hand |
|
# bearbeitet hat - die generierten Passwoerter sind rein alphanumerisch. |
|
if printf "ALTER USER paperless PASSWORD '%s';\n" "${POSTGRES_PASSWORD//\'/\'\'}" \ |
|
| dc exec -T db psql -U paperless -d paperless -q >/dev/null 2>&1; then |
|
ok "Datenbank-Passwort mit .env abgeglichen." |
|
else |
|
warn "Passwort konnte nicht abgeglichen werden." |
|
info "Bei Fehlern der Form 'password authentication failed' hilft:" |
|
info " ${BIN_DIR}/paperlessctl repair-password" |
|
fi |
|
else |
|
warn "PostgreSQL ist nach 2 Minuten nicht bereit." |
|
info "Das ist die erwartete Schwachstelle des Bind-Mounts fuer PGDATA:" |
|
info "PostgreSQL besteht auf Eigentuemer und Rechten am Datenverzeichnis," |
|
info "die ueber virtiofs von APFS nicht zwingend durchgereicht werden." |
|
echo |
|
info "Letzte Zeilen aus dem Datenbank-Log:" |
|
echo |
|
dc logs --tail 25 db 2>&1 | sed 's/^/ /' || true |
|
echo |
|
die "Start abgebrochen. Bitte obige Ausgabe pruefen." |
|
fi |
|
|
|
step "Warte auf paperless-ngx" |
|
info "Der erste Start dauert laenger - Datenbank und Migrationen werden angelegt." |
|
READY="no" |
|
for _ in $(seq 1 180); do |
|
if curl -fsS -o /dev/null "http://127.0.0.1:${WEB_PORT}/accounts/login/" 2>/dev/null; then |
|
READY="yes"; break |
|
fi |
|
sleep 2 |
|
done |
|
|
|
if [ "$READY" = "yes" ]; then |
|
ok "paperless-ngx antwortet." |
|
else |
|
warn "paperless-ngx antwortet noch nicht (Timeout nach 6 Minuten)." |
|
info "Logs ansehen: ${BIN_DIR}/paperlessctl logs webserver" |
|
fi |
|
|
|
# --------------------------------------------------------------------------- |
|
# Zusammenfassung |
|
# --------------------------------------------------------------------------- |
|
|
|
SHOW_CREDENTIALS="no" |
|
grep -q '^NEW_INSTALL=yes' "$ENV_FILE" 2>/dev/null && SHOW_CREDENTIALS="yes" |
|
|
|
cat <<EOF |
|
|
|
${C_GREEN}Fertig.${C_OFF} paperless-ngx laeuft (Runtime: ${RUNTIME}). |
|
|
|
Weboberflaeche http://${BIND_ADDR}:${WEB_PORT} |
|
Dokumente rein ${CONSUME_DIR} |
|
Einstellungen ${APP_ENV_FILE} |
|
Zugangsdaten ${ENV_FILE} |
|
|
|
Alle Nutzdaten liegen auf der Platte, nicht in Docker-Volumes - Time Machine |
|
sichert sie damit im normalen Lauf mit: |
|
|
|
Dokumente ${MEDIA_DIR} |
|
Suchindex u.a. ${DATA_DIR} |
|
Datenbank ${PGDATA_DIR} |
|
|
|
${C_YELLOW}Wichtig zur Sicherung:${C_OFF} Ein Time-Machine-Abzug von ${PGDATA_DIR} |
|
entsteht waehrend die Datenbank laeuft und ist deshalb nicht garantiert |
|
wiederherstellbar. Fuer eine verlaessliche Sicherung zusaetzlich regelmaessig: |
|
|
|
${BIN_DIR}/paperlessctl backup |
|
|
|
Das schreibt Dokumente und Metadaten konsistent nach ${EXPORT_DIR}. |
|
EOF |
|
|
|
if [ "$SHOW_CREDENTIALS" = "yes" ]; then |
|
# Marker entfernen, damit das Passwort nicht bei jedem Lauf erneut erscheint. |
|
ADMIN_PW="$(sed -n 's/^PAPERLESS_ADMIN_PASSWORD=//p' "$ENV_FILE" | head -1)" |
|
sed -i '' '/^NEW_INSTALL=yes$/d' "$ENV_FILE" |
|
|
|
cat <<EOF |
|
|
|
${C_YELLOW}Zugangsdaten fuer die erste Anmeldung:${C_OFF} |
|
Benutzer: ${ADMIN_USER} |
|
Passwort: ${ADMIN_PW} |
|
|
|
Das Passwort steht auch in ${ENV_FILE}. |
|
Aendern laesst es sich in der Weboberflaeche oder mit: |
|
${BIN_DIR}/paperlessctl manage changepassword ${ADMIN_USER} |
|
EOF |
|
fi |
|
|
|
cat <<EOF |
|
|
|
Verwaltung: |
|
${BIN_DIR}/paperlessctl status |
|
${BIN_DIR}/paperlessctl logs webserver |
|
${BIN_DIR}/paperlessctl update |
|
${BIN_DIR}/paperlessctl backup |
|
|
|
${C_DIM}Tipp: fuer bequemen Zugriff in den PATH aufnehmen |
|
echo 'export PATH="${BIN_DIR}:\$PATH"' >> ~/.zprofile${C_OFF} |
|
|
|
Erneutes Ausfuehren dieses Skripts aktualisiert die Installation. |
|
Entfernen: $0 --uninstall |
|
|
|
EOF |