Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d44d9701 | |||
| 052a3a8446 | |||
| 2192fa5bb4 | |||
| 552a90e9e1 | |||
| 2b947c5e8f | |||
| 790f20b499 | |||
| a3f4a7c004 | |||
| e9b2e3e229 | |||
| ec5e240ef1 | |||
| 46ee819029 | |||
| 0005bfc4cf | |||
| 6a5de2a372 | |||
| df48c97677 | |||
| b2c8f8b4e5 | |||
| 96a7acff33 | |||
| 29155f916a | |||
| 65588686c7 | |||
| 8f06d06037 | |||
| fcf9cc3fc9 | |||
| eaa5627062 | |||
| 9fe5a9b1da | |||
| a65bd72d74 | |||
| b9cfdbe29e | |||
| dde46f10e1 | |||
| 20091143d2 | |||
| 8e1505023b | |||
| 60553bbba8 | |||
| 96295ee4f0 |
@@ -499,7 +499,7 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py status
|
||||
|
||||
Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen.
|
||||
|
||||
Optional führt `fedeo-updater.service` die Aufträge unabhängig von der anfragenden Verbindung aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die lokale Unix-Socket-API bietet Prüfung, Update-Start und Status; Backend-Anbindung und Frontend-Button folgen separat. Einrichtung und API-Beispiele stehen in der folgenden Anleitung.
|
||||
Unter **Administration → Systemstatus → FEDEO-Updates** können Systemadministratoren neue Releases prüfen und nach Bestätigung installieren. Dazu führt `fedeo-updater.service` die Aufträge unabhängig von Browser und Backend-Neustarts aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die aktuelle Selfhost-Compose-Datei bindet dessen lokales Socket-Verzeichnis im Backend ein. Bestehende Installationen benötigen einmalig die aktuellen Hostskripte und ein CLI-Update auf einen Release mit der Oberfläche. Einrichtung und Schritte stehen in der folgenden Anleitung.
|
||||
|
||||
Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md).
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ FEDEO_SCAN_MODE=Color
|
||||
FEDEO_SCAN_SOURCE=
|
||||
FEDEO_SCAN_TIMEOUT_SECONDS=900
|
||||
FEDEO_SCAN_POSTPROCESS=false
|
||||
FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||
FEDEO_SCAN_OCR=true
|
||||
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||
FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||
FEDEO_SCAN_POSTPROCESS_PYTHON=
|
||||
FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||
|
||||
@@ -13,6 +13,9 @@ FROM node:20-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
ENV FEDEO_WORK_DIR=/work
|
||||
ENV FEDEO_SCAN_POSTPROCESS=false
|
||||
ENV FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||
ENV FEDEO_SCAN_OCR=true
|
||||
ENV FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
|
||||
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||
@@ -28,6 +31,9 @@ RUN apt-get update \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
sane-utils \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-deu \
|
||||
tesseract-ocr-eng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements-opencv.txt ./
|
||||
|
||||
@@ -26,16 +26,29 @@ Die Installation ist je Agent getrennt:
|
||||
|
||||
Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-<ID>.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten.
|
||||
|
||||
### Scannerverwaltung meldet HTTP 500 nach einem Serverupdate
|
||||
|
||||
Die älteren Scanner-SQL-Dateien `0051` bis `0053` waren nicht im Drizzle-Migrationsjournal eingetragen und wurden dadurch bei regulären Updates übersprungen. Die Vorwärtsmigration `0072_restore_instance_scan_agents` legt fehlende Tabellen und Spalten nachträglich an. Vorhandene Agenten, Tokens, Einstellungen und Scanaufträge bleiben erhalten.
|
||||
|
||||
Zuerst einen Backend-Stand mit dieser Migration ausrollen. Bei aktiviertem `FEDEO_RUN_MIGRATIONS` wird sie beim Start automatisch ausgeführt. Bei bewusst deaktivierten Startmigrationen muss anschließend im aktualisierten Backend `npm run migrate` ausgeführt werden. Die alten Journal-Zeitstempel nachträglich einzufügen genügt bei bereits aktualisierten Datenbanken nicht.
|
||||
|
||||
Der Datenbanktest läuft isoliert gegen ein temporäres PostgreSQL-Image und verwendet keine Anwendungskonfiguration:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAgentMigration.test.ts
|
||||
```
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install node sane-backends
|
||||
brew install node sane-backends tesseract tesseract-lang
|
||||
scanimage -L
|
||||
```
|
||||
|
||||
Für die Dateierstellung wird Python mit Pillow benötigt, beispielsweise über `npm run setup:opencv` (siehe unten).
|
||||
Für die Dateierstellung und Hintergrundbereinigung wird Python mit Pillow und OpenCV benötigt, beispielsweise über `npm run setup:opencv` (siehe unten). `tesseract --list-langs` muss für die Standardkonfiguration `deu` und `eng` enthalten.
|
||||
|
||||
Drucken nutzt später das macOS-Drucksystem/CUPS:
|
||||
|
||||
@@ -47,7 +60,7 @@ lpstat -p
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y nodejs npm sane-utils python3-pil
|
||||
sudo apt install -y nodejs npm sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
|
||||
scanimage -L
|
||||
```
|
||||
|
||||
@@ -77,6 +90,9 @@ Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist
|
||||
FEDEO_SCAN_RESOLUTION=300
|
||||
FEDEO_SCAN_MODE=Color
|
||||
FEDEO_SCAN_POSTPROCESS=false
|
||||
FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||
FEDEO_SCAN_OCR=true
|
||||
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||
FEDEO_SCAN_TIMEOUT_SECONDS=900
|
||||
```
|
||||
|
||||
@@ -126,7 +142,24 @@ npm run build:installer
|
||||
|
||||
`npm test` prüft mit `check:installer`, dass das Paket dem aktuellen Quellcode entspricht. Das Paket enthält keine Zugangsdaten; diese ergänzt erst der Browser beim Download. Die Installer-Tests verwenden temporäre Verzeichnisse und simulierte Systembefehle.
|
||||
|
||||
## OpenCV-Nachbearbeitung
|
||||
## Scannerhintergrund und OCR
|
||||
|
||||
Hintergrundbereinigung und OCR sind standardmäßig aktiv und lassen sich in **Administration → Scanner** als Vorgabe oder im einzelnen Scan-Dialog einstellen:
|
||||
|
||||
- **Scannerhintergrund weiß machen:** Bei einem kleinen Bon oder Dokument werden sicher erkannte Flächen außerhalb des Papiers weiß. Seitengröße, Position und Inhalt des Papiers bleiben erhalten. Unsichere Papiergrenzen werden nicht verändert. Diese Verarbeitung ist unabhängig vom zusätzlichen Zuschnitt.
|
||||
- **Durchsuchbares PDF (OCR):** Der Agent erkennt nach der Bildverarbeitung Text mit Tesseract und bettet ihn direkt in das PDF ein, bevor er es hochlädt. Das gilt auch für normale Dateien ohne Eingangsbeleg. Deutsch und Englisch sind vorausgewählt; PNG und TIFF erhalten keine OCR-Textschicht. Die Darstellung bleibt das gescannte Bild, ergänzt um auswählbaren und durchsuchbaren Text ([Tesseract-Dokumentation](https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html)).
|
||||
|
||||
```env
|
||||
FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||
FEDEO_SCAN_OCR=true
|
||||
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||
```
|
||||
|
||||
Nach einem Update von FEDEO bestehende Agenten über **Einrichtung öffnen** mit dem neuen Linux-Installer aktualisieren. Dieser installiert auch OpenCV, Tesseract und die Sprachpakete. Bei manueller Installation die oben genannten Pakete und die aktuelle Agent-Software installieren und den Dienst neu starten. Der Agent meldet die verfügbare Verarbeitung beim Start an FEDEO; fehlende Fähigkeiten werden vor dem Auftrag gemeldet. Zusätzlich prüft er die benötigten Pakete und die gewählte OCR-Sprache vor jedem Papiereinzug. Ein OCR-Fehler erzeugt keinen erfolgreichen Scan ohne Textschicht; die Rohseiten bleiben zur Wiederherstellung erhalten.
|
||||
|
||||
Die OCR läuft innerhalb des Scan-Auftrags nach dem Einzug. Bei langen Stapeln kann dieser Schritt länger dauern; das Zeitlimit für die Dateierstellung wird ebenfalls über `FEDEO_SCAN_TIMEOUT_SECONDS` gesetzt. Leere Rückseiten und Seitenreihenfolge bleiben erhalten.
|
||||
|
||||
## Zusätzliche OpenCV-Nachbearbeitung
|
||||
|
||||
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
|
||||
|
||||
@@ -143,7 +176,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
|
||||
FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||
```
|
||||
|
||||
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die Rohseiten im gewünschten Ausgabeformat zusammengeführt. Eine fehlgeschlagene Korrektur verliert keine Seiten. Die Warnung erscheint im Scan-Ergebnis. Beim iX1600-Profil ist die Bildkorrektur zunächst ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt.
|
||||
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls die zusätzliche Korrektur fehlschlägt und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die betroffenen Rohseiten weiterverarbeitet; die Warnung erscheint im Scan-Ergebnis. Fehlendes OpenCV kann nur bei ausgeschalteter Hintergrundbereinigung toleriert werden. Beim iX1600-Profil ist der zusätzliche Zuschnitt ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt; Hintergrundbereinigung und OCR sind eingeschaltet.
|
||||
|
||||
Profile:
|
||||
|
||||
@@ -153,7 +186,7 @@ Profile:
|
||||
|
||||
## Container-Betrieb
|
||||
|
||||
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
|
||||
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV, Tesseract einschließlich Deutsch/Englisch und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -189,7 +222,7 @@ npm test
|
||||
npm run test:python
|
||||
```
|
||||
|
||||
Die Tests benötigen Python 3 mit Pillow. Sie simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
|
||||
Die Tests benötigen Python 3 mit Pillow, NumPy und OpenCV. Für den OCR-Integrationstest sind Tesseract mit `deu`/`eng` und `pdftotext` aus Poppler erforderlich; ohne diese Programme wird er übersprungen. Bei Verwendung der virtuellen Umgebung zuerst `. .venv-opencv/bin/activate` ausführen. Die Tests simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Bildtests prüfen die Hintergrundbereinigung bei geraden und schrägen Bons sowie den Erhalt von Text, farbigen Logos und vollständigen Seiten. Der OCR-Test liest die tatsächliche PDF-Textschicht einschließlich leerer Rückseiten aus. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
|
||||
|
||||
Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ async function filesUnder(directory) {
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
|
||||
const paths = [
|
||||
...(await filesUnder('dist')).filter(file => file.endsWith('.js')),
|
||||
'scripts/scan_document.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
|
||||
'scripts/scan_document.py', 'scripts/scan_background.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
|
||||
'requirements-opencv.txt',
|
||||
]
|
||||
const files = Object.fromEntries(await Promise.all(paths.map(async file => [file, (await readFile(path.join(root, file))).toString('base64')])))
|
||||
|
||||
@@ -25,17 +25,22 @@ FEDEO_SETUP_UNIT="fedeo-device-agent-$FEDEO_SETUP_ID.service"
|
||||
FEDEO_SETUP_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
echo 'FEDEO Scan-Agent wird für diesen Benutzer eingerichtet.'
|
||||
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! python3 -c 'import PIL' >/dev/null 2>&1; then
|
||||
fedeo_processing_ready() {
|
||||
python3 -c 'import PIL, cv2, numpy' >/dev/null 2>&1 &&
|
||||
tesseract --list-langs 2>/dev/null | grep -qx deu &&
|
||||
tesseract --list-langs 2>/dev/null | grep -qx eng
|
||||
}
|
||||
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! fedeo_processing_ready; then
|
||||
if ! command -v apt-get >/dev/null; then
|
||||
echo 'Bitte Node.js >= 20, SANE (scanimage), Python 3 und Pillow installieren und das Skript erneut starten.' >&2
|
||||
echo 'Bitte Node.js >= 20, SANE, Python/Pillow/OpenCV sowie Tesseract mit Deutsch/Englisch installieren und das Skript erneut starten.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).'
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nodejs sane-utils python3-pil
|
||||
sudo apt-get install -y nodejs sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
|
||||
fi
|
||||
node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 wird benötigt. Bitte Node.js aktualisieren und die Installation erneut starten."); process.exit(1) }'
|
||||
python3 -c 'import PIL'
|
||||
fedeo_processing_ready || { echo 'Die benötigte Bildverarbeitung oder die OCR-Sprachpakete fehlen weiterhin.' >&2; exit 1; }
|
||||
|
||||
FEDEO_SETUP_NODE="$(command -v node)"
|
||||
FEDEO_SETUP_HAS_SYSTEMD=false
|
||||
@@ -81,7 +86,9 @@ def unit_quote(value):
|
||||
unit_path.write_text('\n'.join([
|
||||
'[Unit]', 'Description=FEDEO Scan-Agent', 'After=network-online.target', '',
|
||||
'[Service]', 'Type=simple',
|
||||
'WorkingDirectory=' + unit_quote(agent_dir),
|
||||
# WorkingDirectory takes a literal path, unlike Environment/ExecStart.
|
||||
# Quotes would become part of the path; only systemd specifiers need escaping.
|
||||
'WorkingDirectory=' + str(agent_dir).replace('%', '%%'),
|
||||
'Environment=' + unit_quote('FEDEO_AGENT_ENV=' + str(config_path)),
|
||||
'ExecStart=' + unit_quote(node) + ' ' + unit_quote(agent_dir / 'dist/main.js'),
|
||||
'Restart=on-failure', 'RestartSec=5', 'UMask=0077', '',
|
||||
|
||||
74
agents/fedeo-device-agent/scripts/scan_background.py
Normal file
74
agents/fedeo-device-agent/scripts/scan_background.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Conservative paper masking. Keep the canvas and every pixel inside the paper."""
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def whiten_background(image):
|
||||
height, width = image.shape[:2]
|
||||
scale = min(1.0, 1000.0 / max(height, width))
|
||||
small = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
|
||||
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||||
border = np.concatenate((gray[0], gray[-1], gray[:, 0], gray[:, -1]))
|
||||
background = float(np.median(border))
|
||||
# White surroundings already need no correction. Never search inside a
|
||||
# white document for an isolated text box or logo to keep instead.
|
||||
if background >= 235 or np.percentile(border, 75) - np.percentile(border, 25) > 55:
|
||||
return image
|
||||
|
||||
threshold, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
mask = np.uint8(gray > max(threshold, background + 20)) * 255
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
candidates = []
|
||||
area = gray.size
|
||||
for contour in contours:
|
||||
contour_area = cv2.contourArea(contour)
|
||||
if not 0.015 * area < contour_area < 0.985 * area:
|
||||
continue
|
||||
perimeter = cv2.arcLength(contour, True)
|
||||
polygon = cv2.approxPolyDP(contour, 0.018 * perimeter, True)
|
||||
if len(polygon) != 4 or not cv2.isContourConvex(polygon):
|
||||
continue
|
||||
# Use the quadrilateral only to recognize a sheet, then keep its full
|
||||
# contour hull so rounded or slightly uneven edges cannot lose content.
|
||||
polygon = cv2.convexHull(contour)
|
||||
paper = np.zeros_like(gray)
|
||||
cv2.fillPoly(paper, [polygon], 255)
|
||||
inside = cv2.erode(paper, np.ones((7, 7), np.uint8)) > 0
|
||||
ring = (cv2.dilate(paper, np.ones((9, 9), np.uint8)) > 0) & (paper == 0)
|
||||
if inside.sum() < 100 or ring.sum() < 50:
|
||||
continue
|
||||
paper_light = float(np.median(gray[inside]))
|
||||
# A paper edge must separate a mostly light sheet from darker scanner
|
||||
# backing all around it. Ambiguous photos/pages are left untouched.
|
||||
if paper_light < background + 30 or np.mean(gray[ring] < paper_light - 20) < 0.85:
|
||||
continue
|
||||
candidates.append(polygon)
|
||||
|
||||
if not candidates:
|
||||
return image
|
||||
keep_small = np.zeros_like(gray)
|
||||
cv2.fillPoly(keep_small, candidates, 255)
|
||||
# Do not erase another significant object that we could not classify.
|
||||
outside = (keep_small == 0)
|
||||
if np.mean(gray[outside] > background + 30) > 0.04:
|
||||
return image
|
||||
keep = np.zeros((height, width), dtype=np.uint8)
|
||||
polygons = [np.rint(polygon / scale).astype(np.int32) for polygon in candidates]
|
||||
cv2.fillPoly(keep, polygons, 255)
|
||||
# Rounding/downsampling uncertainty grows with the original scan DPI.
|
||||
padding = max(2, int(np.ceil(2 / scale)))
|
||||
keep = cv2.dilate(keep, np.ones((2 * padding + 1, 2 * padding + 1), np.uint8))
|
||||
result = image.copy()
|
||||
result[keep == 0] = 255
|
||||
return result
|
||||
|
||||
|
||||
def clean_scan_background(input_path: Path, output_path: Path):
|
||||
image = cv2.imread(str(input_path), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
raise RuntimeError("Scan-Bild konnte nicht gelesen werden")
|
||||
if not cv2.imwrite(str(output_path), whiten_background(image)):
|
||||
raise RuntimeError("Bereinigtes Scan-Bild konnte nicht gespeichert werden")
|
||||
@@ -1,21 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble ordered scanner pages; OpenCV is optional, PDF creation is not."""
|
||||
"""Prepare ordered scanner pages and publish an optionally searchable PDF atomically."""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PIL import Image
|
||||
from PIL import Image, PdfParser
|
||||
|
||||
|
||||
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False):
|
||||
def run_ocr_command(arguments):
|
||||
process = subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
stdout, stderr = process.communicate()
|
||||
except BaseException:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError("OCR fehlgeschlagen: " + stderr.strip())
|
||||
return stdout
|
||||
|
||||
|
||||
def check_ocr(language):
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_]+(?:\+[a-zA-Z0-9_]+)*", language):
|
||||
raise ValueError("Ungültige OCR-Sprache")
|
||||
try:
|
||||
languages = run_ocr_command(["tesseract", "--list-langs"]).splitlines()
|
||||
except FileNotFoundError as error:
|
||||
raise RuntimeError("Tesseract fehlt. Bitte tesseract-ocr und die Sprachpakete tesseract-ocr-deu/tesseract-ocr-eng installieren.") from error
|
||||
missing = set(language.split("+")) - {line.strip() for line in languages}
|
||||
if missing:
|
||||
raise RuntimeError("OCR-Sprachpakete fehlen: " + ", ".join(sorted(missing)))
|
||||
|
||||
|
||||
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False,
|
||||
ocr=False, ocr_language="deu+eng", background_cleanup=False):
|
||||
if not inputs:
|
||||
raise ValueError("Keine Seiten vorhanden")
|
||||
if not math.isfinite(resolution) or resolution <= 0:
|
||||
raise ValueError("Ungültige Scan-Auflösung")
|
||||
output = Path(output)
|
||||
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
|
||||
if not file_format:
|
||||
raise ValueError("Unbekanntes Ausgabeformat")
|
||||
if file_format != "PDF" and len(inputs) != 1:
|
||||
raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat")
|
||||
if (len(inputs) != 1 or ocr) and file_format != "PDF":
|
||||
raise ValueError("Mehrseitige Scans und OCR benötigen PDF als Ausgabeformat")
|
||||
if ocr:
|
||||
check_ocr(ocr_language)
|
||||
|
||||
warnings = []
|
||||
processor = None
|
||||
@@ -27,47 +62,92 @@ def create_document(inputs, output, resolution, use_postprocess=False, profile="
|
||||
if strict:
|
||||
raise
|
||||
warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.")
|
||||
if background_cleanup:
|
||||
from scan_background import clean_scan_background
|
||||
|
||||
partial = output.with_name(output.name + ".partial")
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="prepared-", dir=output.parent) as directory:
|
||||
prepared = []
|
||||
for index, input_path in enumerate(inputs):
|
||||
input_path = Path(input_path)
|
||||
image_path = input_path
|
||||
corrected = input_path.with_name(input_path.stem + ".corrected.png")
|
||||
image_path = Path(input_path)
|
||||
corrected = Path(directory) / f"corrected-{index:06}.png"
|
||||
if processor:
|
||||
try:
|
||||
processor(input_path, corrected, profile)
|
||||
processor(image_path, corrected, profile)
|
||||
image_path = corrected
|
||||
except Exception:
|
||||
if strict:
|
||||
raise
|
||||
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
|
||||
if background_cleanup:
|
||||
cleaned = Path(directory) / f"cleaned-{index:06}.png"
|
||||
clean_scan_background(image_path, cleaned)
|
||||
image_path = cleaned
|
||||
|
||||
# Only one decoded page is kept in memory, even for large duplex stacks.
|
||||
# Only one decoded page is kept in memory, even for duplex stacks.
|
||||
with Image.open(image_path) as image:
|
||||
with image.convert("RGB") as rgb:
|
||||
if file_format == "PDF":
|
||||
if ocr:
|
||||
page = Path(directory).resolve() / f"page-{index:06}.png"
|
||||
rgb.save(page, "PNG", dpi=(resolution, resolution))
|
||||
prepared.append(page)
|
||||
elif file_format == "PDF":
|
||||
rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0)
|
||||
else:
|
||||
rgb.save(partial, file_format, dpi=(resolution, resolution))
|
||||
corrected.unlink(missing_ok=True)
|
||||
if background_cleanup:
|
||||
cleaned.unlink(missing_ok=True)
|
||||
if ocr:
|
||||
page_list = Path(directory) / "pages.txt"
|
||||
if any("\n" in str(page) or "\r" in str(page) for page in prepared):
|
||||
raise ValueError("Das Scan-Arbeitsverzeichnis darf keinen Zeilenumbruch enthalten")
|
||||
page_list.write_text("\n".join(map(str, prepared)) + "\n", encoding="utf8")
|
||||
prefix = Path(directory) / "searchable"
|
||||
run_ocr_command(["tesseract", str(page_list), str(prefix), "-l", ocr_language,
|
||||
"--dpi", str(round(resolution)), "pdf"])
|
||||
searchable = prefix.with_suffix(".pdf")
|
||||
with PdfParser.PdfParser(filename=str(searchable)) as pdf:
|
||||
if len(pdf.pages) != len(inputs):
|
||||
raise RuntimeError("OCR-PDF ist unvollständig; Rohscans bleiben erhalten")
|
||||
searchable.replace(partial)
|
||||
partial.replace(output)
|
||||
finally:
|
||||
partial.unlink(missing_ok=True)
|
||||
|
||||
return {"pageCount": len(inputs), "warnings": warnings}
|
||||
|
||||
|
||||
def interrupted(*_):
|
||||
raise SystemExit("Scan-Verarbeitung abgebrochen")
|
||||
|
||||
|
||||
def main():
|
||||
# Let finally blocks stop Tesseract and remove incomplete PDFs on agent timeout.
|
||||
signal.signal(signal.SIGTERM, interrupted)
|
||||
parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen")
|
||||
parser.add_argument("--inputs", nargs="+", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--resolution", type=float, required=True)
|
||||
parser.add_argument("--inputs", nargs="+")
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--resolution", type=float, default=300)
|
||||
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
|
||||
parser.add_argument("--postprocess", action="store_true")
|
||||
parser.add_argument("--strict", action="store_true")
|
||||
parser.add_argument("--background-cleanup", action="store_true")
|
||||
parser.add_argument("--ocr", action="store_true")
|
||||
parser.add_argument("--ocr-language", default="deu+eng")
|
||||
parser.add_argument("--check-runtime", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile, args.strict)))
|
||||
if args.check_runtime:
|
||||
if args.background_cleanup or (args.postprocess and args.strict):
|
||||
import cv2, numpy
|
||||
if args.ocr:
|
||||
check_ocr(args.ocr_language)
|
||||
print(json.dumps({"ready": True}))
|
||||
return
|
||||
if not args.inputs or not args.output:
|
||||
parser.error("--inputs und --output sind erforderlich")
|
||||
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile,
|
||||
args.strict, args.ocr, args.ocr_language, args.background_cleanup)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -62,6 +62,9 @@ export const loadConfig = (): AgentConfig => {
|
||||
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
|
||||
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
|
||||
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
|
||||
scanBackgroundCleanup: booleanFromEnv(process.env.FEDEO_SCAN_BACKGROUND_CLEANUP, true),
|
||||
scanOcr: booleanFromEnv(process.env.FEDEO_SCAN_OCR, true),
|
||||
scanOcrLanguage: optional(process.env.FEDEO_SCAN_OCR_LANGUAGE) || "deu+eng",
|
||||
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
|
||||
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
|
||||
postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadConfig } from "./config.js"
|
||||
import { log } from "./logger.js"
|
||||
import { listPrinters } from "./print/cups.js"
|
||||
import { hasSane, listScanners, runScan } from "./scan/sane.js"
|
||||
import { processingCapabilities } from "./scan/postprocess.js"
|
||||
import { AgentHeartbeat } from "./types.js"
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -19,6 +20,7 @@ const stringifyError = (error: unknown) => {
|
||||
const main = async () => {
|
||||
const config = loadConfig()
|
||||
const api = new FedeoApi(config)
|
||||
const processing = await processingCapabilities(config)
|
||||
|
||||
log.info("FEDEO Geräte-Agent startet", {
|
||||
platform: process.platform,
|
||||
@@ -38,6 +40,7 @@ const main = async () => {
|
||||
const heartbeatPayload: AgentHeartbeat = {
|
||||
capabilities: {
|
||||
scan: scanAvailable,
|
||||
...processing,
|
||||
print: printerNames.length > 0,
|
||||
platform: process.platform,
|
||||
},
|
||||
|
||||
@@ -6,15 +6,34 @@ import { runCommand } from "../commands.js"
|
||||
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const documentScript = path.join(agentRoot, "scripts/scan_document.py")
|
||||
|
||||
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
|
||||
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
|
||||
type ProcessingOptions = { ocr: boolean; ocrLanguage: string; backgroundCleanup: boolean }
|
||||
|
||||
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean, options: ProcessingOptions) => {
|
||||
const args = [documentScript, "--check-runtime", "--ocr-language", options.ocrLanguage]
|
||||
if (postprocess) args.push("--postprocess")
|
||||
if (config.postprocessStrict) args.push("--strict")
|
||||
if (options.backgroundCleanup) args.push("--background-cleanup")
|
||||
if (options.ocr) args.push("--ocr")
|
||||
try {
|
||||
const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 })
|
||||
const result = await runCommand(config.postprocessPython, args, { timeoutMs: 10_000 })
|
||||
if (result.code === 0) return
|
||||
} catch {
|
||||
throw new Error(result.stderr.trim())
|
||||
} catch (error) {
|
||||
// Report missing runtimes before the scanner consumes any paper.
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error("Scan-Verarbeitung nicht verfügbar. Bitte den Agenten mit Python/Pillow, OpenCV und Tesseract samt OCR-Sprachpaketen installieren. " + detail)
|
||||
}
|
||||
throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.")
|
||||
}
|
||||
|
||||
export const processingCapabilities = async (config: AgentConfig) => {
|
||||
const available = async (options: ProcessingOptions) => {
|
||||
try { await checkScanRuntime(config, false, options); return true } catch { return false }
|
||||
}
|
||||
const [ocr, backgroundCleanup] = await Promise.all([
|
||||
available({ ocr: true, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: false }),
|
||||
available({ ocr: false, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: true }),
|
||||
])
|
||||
return { ocr, backgroundCleanup }
|
||||
}
|
||||
|
||||
export const createScanDocument = async (
|
||||
@@ -23,11 +42,14 @@ export const createScanDocument = async (
|
||||
outputPath: string,
|
||||
resolution: number,
|
||||
postprocess: boolean,
|
||||
profile: AgentConfig["postprocessProfile"]
|
||||
profile: AgentConfig["postprocessProfile"],
|
||||
options: ProcessingOptions
|
||||
): Promise<ScanResult> => {
|
||||
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
|
||||
if (postprocess) args.push("--postprocess")
|
||||
if (config.postprocessStrict) args.push("--strict")
|
||||
if (options.backgroundCleanup) args.push("--background-cleanup")
|
||||
if (options.ocr) args.push("--ocr", "--ocr-language", options.ocrLanguage)
|
||||
args.push("--inputs", ...pages)
|
||||
|
||||
const result = await runCommand(config.postprocessPython, args, {
|
||||
|
||||
@@ -20,7 +20,9 @@ export const hasSane = () => commandExists("scanimage")
|
||||
|
||||
export const listScanners = async () => {
|
||||
if (!await hasSane()) return []
|
||||
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 })
|
||||
// SANE also probes network backends; these can take more than ten seconds
|
||||
// even when the USB scanner is already available.
|
||||
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 30_000 })
|
||||
if (result.code !== 0) return []
|
||||
return result.stdout.split(/\r?\n/)
|
||||
.map((line) => line.trim().match(/^device `([^']+)'/)?.[1])
|
||||
@@ -51,7 +53,12 @@ export const runScan = async (
|
||||
const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
|
||||
const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
|
||||
if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil")
|
||||
await checkScanRuntime(config, postprocess)
|
||||
const processing = {
|
||||
backgroundCleanup: booleanSetting(settings, "backgroundCleanup", config.scanBackgroundCleanup),
|
||||
ocr: format === "pdf" && booleanSetting(settings, "ocr", config.scanOcr),
|
||||
ocrLanguage: stringSetting(settings, "ocrLanguage") || config.scanOcrLanguage,
|
||||
}
|
||||
await checkScanRuntime(config, postprocess, processing)
|
||||
|
||||
// Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
|
||||
await mkdir(config.workDir, { recursive: true, mode: 0o700 })
|
||||
@@ -109,8 +116,8 @@ export const runScan = async (
|
||||
throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.")
|
||||
}
|
||||
|
||||
await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`)
|
||||
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
|
||||
await reportProgress(`${pages.length} Seiten gescannt. ${processing.ocr ? "Durchsuchbares PDF mit OCR wird erstellt..." : "Datei wird erstellt..."}`)
|
||||
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"], processing)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)
|
||||
|
||||
@@ -11,6 +11,9 @@ export type AgentConfig = {
|
||||
scanSource?: string
|
||||
scanTimeoutSeconds: number
|
||||
scanPostprocess: boolean
|
||||
scanBackgroundCleanup: boolean
|
||||
scanOcr: boolean
|
||||
scanOcrLanguage: string
|
||||
postprocessProfile: "document" | "receipt" | "raw"
|
||||
postprocessPython: string
|
||||
postprocessStrict: boolean
|
||||
@@ -20,6 +23,8 @@ export type AgentHeartbeat = {
|
||||
capabilities: {
|
||||
scan: boolean
|
||||
print: boolean
|
||||
ocr: boolean
|
||||
backgroundCleanup: boolean
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
scannerNames: string[]
|
||||
|
||||
@@ -14,6 +14,8 @@ const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
|
||||
const token = 'fedeo_agent_' + 'a'.repeat(64)
|
||||
const options = { agentId, token, backendUrl: 'https://fedeo.example.com/backend', autostart: true }
|
||||
const execute = promisify(execFile)
|
||||
const hasSystemdAnalyze = process.platform === 'linux' && await execute('systemd-analyze', ['--version'])
|
||||
.then(() => true, error => { if (error.code === 'ENOENT') return false; throw error })
|
||||
|
||||
test('backend URLs preserve reverse-proxy paths and reject credentials or non-HTTP protocols', () => {
|
||||
assert.equal(agentBackendUrl('/backend/', 'https://fedeo.example.com'), options.backendUrl)
|
||||
@@ -50,11 +52,15 @@ for (const start of [false, true]) {
|
||||
await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 })
|
||||
const realPython = (await execute('sh', ['-c', 'command -v python3'])).stdout.trim()
|
||||
await writeFile(path.join(bin, 'python3'), '#!/bin/sh\nif [ "$1" = "-c" ] && [ "$2" = "import PIL, cv2, numpy" ]; then exit 0; fi\nexec "$FEDEO_TEST_PYTHON" "$@"\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'tesseract'), '#!/bin/sh\nprintf "deu\\neng\\n"\n', { mode: 0o755 })
|
||||
const env = {
|
||||
...process.env, PATH: bin + ':' + process.env.PATH,
|
||||
XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
|
||||
XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'),
|
||||
FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'),
|
||||
FEDEO_TEST_PYTHON: realPython,
|
||||
}
|
||||
const scriptPath = path.join(temp, 'install.sh')
|
||||
await writeFile(scriptPath, renderAgentInstaller(bundle, options))
|
||||
@@ -70,9 +76,13 @@ for (const start of [false, true]) {
|
||||
for (const [file, encoded] of Object.entries(bundle.files)) {
|
||||
assert.equal((await readFile(path.join(agentDir, file))).toString('base64'), encoded)
|
||||
}
|
||||
const unit = await readFile(path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service'), 'utf8')
|
||||
const unitPath = path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service')
|
||||
const unit = await readFile(unitPath, 'utf8')
|
||||
assert.ok(unit.includes('data with spaces %%'))
|
||||
assert.ok(!unit.includes(token))
|
||||
await t.test('systemd accepts the generated service, including a working directory with spaces and percent signs', { skip: !hasSystemdAnalyze }, async () => {
|
||||
await execute('systemd-analyze', ['--user', 'verify', unitPath])
|
||||
})
|
||||
const calls = await readFile(env.FEDEO_TEST_SYSTEMD_LOG, 'utf8')
|
||||
assert.equal(calls.includes('--user enable fedeo-device-agent-'), start)
|
||||
assert.equal(calls.includes('--user restart fedeo-device-agent-'), start)
|
||||
|
||||
@@ -3,16 +3,21 @@ import test, { TestContext } from "node:test"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
|
||||
import { runScan } from "../src/scan/sane.js"
|
||||
import { listScanners, runScan } from "../src/scan/sane.js"
|
||||
import { commandExists, runCommand } from "../src/commands.js"
|
||||
import { AgentConfig, ScanJob } from "../src/types.js"
|
||||
|
||||
const fakeScanner = `#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
import json, os, sys, time
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
args = sys.argv[1:]
|
||||
Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args))
|
||||
if args == ['-L']:
|
||||
time.sleep(float(os.environ.get('SCAN_TEST_DISCOVERY_DELAY', '0')))
|
||||
print("device " + chr(96) + "fujitsu:ScanSnap iX1600:test' is a FUJITSU scanner")
|
||||
print("device " + chr(96) + "airscan:test' is a network scanner")
|
||||
sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '0')))
|
||||
batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None)
|
||||
single = args[args.index('--output-file') + 1] if '--output-file' in args else None
|
||||
count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1
|
||||
@@ -46,12 +51,25 @@ const setup = async (t: TestContext) => {
|
||||
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
|
||||
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
|
||||
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
|
||||
scanBackgroundCleanup: false, scanOcr: false, scanOcrLanguage: "deu+eng",
|
||||
postprocessPython: "python3", postprocessStrict: false,
|
||||
}
|
||||
const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } }
|
||||
return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] }
|
||||
}
|
||||
|
||||
test("scanner discovery waits for network probes beyond the former ten-second limit", async (t) => {
|
||||
await setup(t)
|
||||
process.env.SCAN_TEST_DISCOVERY_DELAY = "10.2"
|
||||
assert.deepEqual(await listScanners(), ["fujitsu:ScanSnap iX1600:test", "airscan:test"])
|
||||
})
|
||||
|
||||
test("failed scanner discovery does not advertise devices from an unsuccessful command", async (t) => {
|
||||
await setup(t)
|
||||
process.env.SCAN_TEST_EXIT = "1"
|
||||
assert.deepEqual(await listScanners(), [])
|
||||
})
|
||||
|
||||
test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => {
|
||||
const { config, job, args } = await setup(t)
|
||||
process.env.SCAN_TEST_EMPTY_FINAL = "1"
|
||||
@@ -126,6 +144,18 @@ test("missing image runtime is detected before feeding paper", async (t) => {
|
||||
await assert.rejects(args(), /ENOENT/)
|
||||
})
|
||||
|
||||
test("missing requested OCR language fails before the scanner consumes paper", async (t) => {
|
||||
const { config, job, dir, args } = await setup(t)
|
||||
const tesseract = path.join(dir, "tesseract")
|
||||
await writeFile(tesseract, "#!/bin/sh\nprintf 'List of available languages (1):\\neng\\n'\n")
|
||||
await chmod(tesseract, 0o755)
|
||||
job.settings!.ocr = true
|
||||
job.settings!.ocrLanguage = "deu+eng"
|
||||
await assert.rejects(runScan(config, job), /OCR-Sprachpakete fehlen: deu/)
|
||||
await assert.rejects(args(), /ENOENT/)
|
||||
await assert.rejects(readdir(config.workDir), /ENOENT/)
|
||||
})
|
||||
|
||||
test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => {
|
||||
const { config, job, args } = await setup(t)
|
||||
job.settings = { source: "Flatbed", format: "png" }
|
||||
|
||||
64
agents/fedeo-device-agent/tests/scan_background_test.py
Normal file
64
agents/fedeo-device-agent/tests/scan_background_test.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
from scan_background import whiten_background
|
||||
|
||||
|
||||
class ScanBackgroundTest(unittest.TestCase):
|
||||
def receipt(self, background=45, paper=245):
|
||||
image = np.full((1600, 1200, 3), background, dtype=np.uint8)
|
||||
image[120:1440, 360:820] = paper
|
||||
for y in range(220, 1350, 70):
|
||||
cv2.putText(image, "BON 12,34 EUR", (375, y), cv2.FONT_HERSHEY_SIMPLEX, .7, (20, 20, 20), 2)
|
||||
cv2.rectangle(image, (400, 140), (780, 180), (40, 80, 210), -1)
|
||||
return image
|
||||
|
||||
def test_receipt_on_dark_or_gray_backing_keeps_canvas_text_and_colored_logo(self):
|
||||
for background in (35, 140):
|
||||
image = self.receipt(background)
|
||||
result = whiten_background(image)
|
||||
self.assertEqual(result.shape, image.shape)
|
||||
self.assertTrue(np.all(result[:80] == 255))
|
||||
self.assertTrue(np.all(result[:, :250] == 255))
|
||||
self.assertTrue(np.array_equal(result[120:1440, 360:820], image[120:1440, 360:820]))
|
||||
|
||||
def test_slanted_receipt_is_whitened_without_rotating_or_cropping(self):
|
||||
image = np.full((1600, 1200, 3), 55, dtype=np.uint8)
|
||||
polygon = np.array([[450, 100], [890, 155], [720, 1450], [280, 1390]])
|
||||
cv2.fillPoly(image, [polygon], (245, 245, 245))
|
||||
cv2.putText(image, "FEDEO", (470, 700), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 0), 3)
|
||||
result = whiten_background(image)
|
||||
self.assertEqual(result.shape, image.shape)
|
||||
inside = np.zeros(image.shape[:2], dtype=np.uint8)
|
||||
cv2.fillPoly(inside, [polygon], 255)
|
||||
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
|
||||
self.assertTrue(np.all(result[:50] == 255))
|
||||
|
||||
def test_white_full_page_is_not_replaced_by_its_largest_text_box(self):
|
||||
image = np.full((1600, 1200, 3), 255, dtype=np.uint8)
|
||||
cv2.rectangle(image, (300, 400), (800, 1200), (30, 30, 30), 4)
|
||||
cv2.putText(image, "Footer", (20, 1550), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
|
||||
self.assertTrue(np.array_equal(whiten_background(image), image))
|
||||
|
||||
def test_slanted_receipt_retains_every_paper_edge_pixel_at_300_dpi(self):
|
||||
image = np.full((3508, 2480, 3), 45, dtype=np.uint8)
|
||||
polygon = np.array([[820, 200], [1700, 340], [1250, 3300], [380, 3160]])
|
||||
cv2.fillPoly(image, [polygon], (245, 245, 245))
|
||||
inside = np.zeros(image.shape[:2], dtype=np.uint8)
|
||||
cv2.fillPoly(inside, [polygon], 255)
|
||||
result = whiten_background(image)
|
||||
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
|
||||
self.assertTrue(np.all(result[:50] == 255))
|
||||
|
||||
def test_blank_and_ambiguous_low_contrast_pages_remain_unchanged(self):
|
||||
for image in [np.full((300, 200, 3), 245, dtype=np.uint8), self.receipt(215, 230)]:
|
||||
self.assertTrue(np.array_equal(whiten_background(image), image))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
71
agents/fedeo-device-agent/tests/scan_ocr_test.py
Normal file
71
agents/fedeo-device-agent/tests/scan_ocr_test.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, PdfParser
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
from scan_document import create_document, check_ocr
|
||||
|
||||
|
||||
class ScanOcrTest(unittest.TestCase):
|
||||
def test_missing_language_is_reported_before_processing(self):
|
||||
with patch("scan_document.run_ocr_command", return_value="List of available languages (1):\neng\n"):
|
||||
with self.assertRaisesRegex(RuntimeError, "deu"):
|
||||
check_ocr("deu+eng")
|
||||
with self.assertRaises(ValueError):
|
||||
check_ocr("--bad-language")
|
||||
|
||||
@unittest.skipUnless(shutil.which("tesseract") and shutil.which("pdftotext"), "Tesseract and Poppler required")
|
||||
def test_ocr_keeps_receipt_canvas_blank_back_and_page_order_with_real_extractable_text(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
pages = []
|
||||
for index, text in enumerate(["FEDEO Bon 12345", "", "Zweite Seite 24680"]):
|
||||
page = root / f"page-{index}.png"
|
||||
image = Image.new("RGB", (1240, 1754), (45, 45, 45) if index == 0 else "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
if index == 0:
|
||||
draw.rectangle((260, 150, 1000, 1550), fill="white")
|
||||
if text:
|
||||
draw.text((300, 300), text, fill="black", font=ImageFont.load_default(size=44))
|
||||
draw.text((300, 390), "Summe EUR 12,34", fill="black", font=ImageFont.load_default(size=36))
|
||||
image.save(page)
|
||||
pages.append(page)
|
||||
output = root / "searchable.pdf"
|
||||
result = create_document(pages, output, 150, ocr=True, background_cleanup=True)
|
||||
self.assertEqual(result["pageCount"], 3)
|
||||
with PdfParser.PdfParser(filename=str(output)) as pdf:
|
||||
self.assertEqual(len(pdf.pages), 3)
|
||||
for reference in pdf.pages:
|
||||
box = pdf.read_indirect(reference)[b"MediaBox"]
|
||||
self.assertAlmostEqual(box[2], 1240 * 72 / 150, delta=.1)
|
||||
self.assertAlmostEqual(box[3], 1754 * 72 / 150, delta=.1)
|
||||
text = subprocess.check_output(["pdftotext", str(output), "-"], text=True)
|
||||
extracted = text.split("\f")
|
||||
self.assertIn("12345", extracted[0])
|
||||
self.assertEqual(extracted[1].strip(), "")
|
||||
self.assertIn("24680", extracted[2])
|
||||
self.assertTrue(all(page.exists() for page in pages))
|
||||
|
||||
def test_ocr_failure_never_publishes_a_partial_document_or_removes_raw_pages(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
page = root / "raw.png"
|
||||
Image.new("RGB", (400, 600), "white").save(page)
|
||||
output = root / "failed.pdf"
|
||||
with patch("scan_document.check_ocr"), patch("scan_document.run_ocr_command", side_effect=RuntimeError("OCR failed")):
|
||||
with self.assertRaisesRegex(RuntimeError, "OCR failed"):
|
||||
create_document([page], output, 300, ocr=True)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertFalse(output.with_name("failed.pdf.partial").exists())
|
||||
self.assertTrue(page.exists())
|
||||
self.assertEqual(list(root.glob("prepared-*")), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Forward repair: 0051-0053 were shipped without migration journal entries.
|
||||
-- Keep existing agents/jobs when these SQL files were applied manually.
|
||||
CREATE TABLE IF NOT EXISTS "instance_agents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"token_prefix" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL,
|
||||
"scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"last_seen_at" timestamp with time zone,
|
||||
"last_debug_info" jsonb,
|
||||
CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "instance_agent_scan_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant_id" bigint NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"requested_by" uuid,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"scanner_name" text,
|
||||
"requested_filename" text,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"target" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"agent_message" text,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"claimed_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"file_id" uuid,
|
||||
CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at");
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "preferred_scanner_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb;
|
||||
20
backend/db/migrations/0073_scanner_document_inbox.sql
Normal file
20
backend/db/migrations/0073_scanner_document_inbox.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "scanner_inbox_connections" (
|
||||
"tenant_id" bigint PRIMARY KEY REFERENCES "tenants"("id") ON DELETE CASCADE,
|
||||
"username" text NOT NULL UNIQUE,
|
||||
"password_hash" text NOT NULL,
|
||||
"active" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scanner_inbox_documents" (
|
||||
"file_id" uuid PRIMARY KEY REFERENCES "files"("id") ON DELETE CASCADE,
|
||||
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
|
||||
"remote_filename" text NOT NULL,
|
||||
"checksum" text NOT NULL,
|
||||
"received_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "scanner_inbox_documents_filename_key" ON "scanner_inbox_documents" ("tenant_id", "remote_filename");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "scanner_inbox_documents_received_idx" ON "scanner_inbox_documents" ("tenant_id", "received_at");
|
||||
1
backend/db/migrations/0074_scanner_inbox_filing.sql
Normal file
1
backend/db/migrations/0074_scanner_inbox_filing.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "scanner_inbox_documents" ADD COLUMN "filed_at" timestamp with time zone;
|
||||
@@ -484,6 +484,27 @@
|
||||
"when": 1790121600000,
|
||||
"tag": "0071_statement_allocation_costcentre",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1790121600001,
|
||||
"tag": "0072_restore_instance_scan_agents",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "7",
|
||||
"when": 1790640000000,
|
||||
"tag": "0073_scanner_document_inbox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1790640000001,
|
||||
"tag": "0074_scanner_inbox_filing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -100,3 +100,4 @@ export * from "./serialtypes"
|
||||
export * from "./serialexecutions"
|
||||
export * from "./public_links"
|
||||
export * from "./wikipages"
|
||||
export * from "./scanner_inbox"
|
||||
|
||||
24
backend/db/schema/scanner_inbox.ts
Normal file
24
backend/db/schema/scanner_inbox.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { bigint, boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"
|
||||
import { tenants } from "./tenants"
|
||||
import { files } from "./files"
|
||||
|
||||
export const scannerInboxConnections = pgTable("scanner_inbox_connections", {
|
||||
tenantId: bigint("tenant_id", { mode: "number" }).primaryKey().references(() => tenants.id, { onDelete: "cascade" }),
|
||||
username: text("username").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
})
|
||||
|
||||
export const scannerInboxDocuments = pgTable("scanner_inbox_documents", {
|
||||
fileId: uuid("file_id").primaryKey().references(() => files.id, { onDelete: "cascade" }),
|
||||
tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }),
|
||||
remoteFilename: text("remote_filename").notNull(),
|
||||
checksum: text("checksum").notNull(),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
filedAt: timestamp("filed_at", { withTimezone: true }),
|
||||
}, table => ({
|
||||
filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename),
|
||||
receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt),
|
||||
}))
|
||||
@@ -9,6 +9,7 @@ import authRoutes from "./routes/auth/auth";
|
||||
import authRoutesAuthenticated from "./routes/auth/auth-authenticated";
|
||||
import authPlugin from "./plugins/auth";
|
||||
import adminRoutes from "./routes/admin";
|
||||
import selfhostUpdateRoutes from "./routes/selfhostUpdates";
|
||||
import corsPlugin from "./plugins/cors";
|
||||
import queryConfigPlugin from "./plugins/queryconfig";
|
||||
import dbPlugin from "./plugins/db";
|
||||
@@ -36,6 +37,8 @@ import telephonyRoutes from "./routes/telephony";
|
||||
import instanceAgentRoutes from "./routes/instanceAgents";
|
||||
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
|
||||
import documentImportRoutes from "./routes/documentImports";
|
||||
import scannerInboxGateway from "./routes/scannerInboxGateway";
|
||||
import documentInboxRoutes from "./routes/documentInbox";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
@@ -133,6 +136,8 @@ async function main() {
|
||||
await agentApp.register(instanceAgentGatewayRoutes)
|
||||
},{prefix: "/instance-agent"})
|
||||
|
||||
await app.register(scannerInboxGateway, { prefix: "/scanner-inbox" });
|
||||
|
||||
await app.register(corsPlugin);
|
||||
|
||||
//Geschützte Routes
|
||||
@@ -143,6 +148,7 @@ async function main() {
|
||||
await subApp.register(meRoutes);
|
||||
await subApp.register(tenantRoutes);
|
||||
await subApp.register(adminRoutes);
|
||||
await subApp.register(selfhostUpdateRoutes);
|
||||
await subApp.register(resourceRoutesSpecial);
|
||||
await subApp.register(historyRoutes);
|
||||
await subApp.register(fileRoutes);
|
||||
@@ -165,6 +171,7 @@ async function main() {
|
||||
await subApp.register(telephonyRoutes);
|
||||
await subApp.register(instanceAgentRoutes);
|
||||
await subApp.register(documentImportRoutes);
|
||||
await subApp.register(documentInboxRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
|
||||
184
backend/src/modules/scanner-inbox.service.ts
Normal file
184
backend/src/modules/scanner-inbox.service.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { files, filetags, folders, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema"
|
||||
import { sanitizeFilename } from "../utils/filename"
|
||||
import { s3 } from "../utils/s3"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
export const SCANNER_UPLOAD_LIMIT = 100 * 1024 * 1024
|
||||
export const SCANNER_DAV_PATH = "/scanner-inbox/"
|
||||
export const scannerError = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode })
|
||||
const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex")
|
||||
|
||||
export type ScannerIdentity = { tenantId: number; username: string; passwordHash: string }
|
||||
export interface ScannerStorage {
|
||||
put(key: string, body: Buffer, contentType: string): Promise<void>
|
||||
remove(key: string): Promise<void>
|
||||
}
|
||||
|
||||
const objectStorage: ScannerStorage = {
|
||||
async put(key, body, contentType) {
|
||||
await s3.send(new PutObjectCommand({ Bucket: secrets.S3_BUCKET, Key: key, Body: body, ContentType: contentType, ContentLength: body.length }))
|
||||
},
|
||||
async remove(key) {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: secrets.S3_BUCKET, Key: key }))
|
||||
},
|
||||
}
|
||||
|
||||
export function validateScannerFile(filename: string, body: Buffer) {
|
||||
if (!filename || filename.length > 200 || /[\\/\u0000-\u001f\u007f]/.test(filename) || filename.startsWith(".")) {
|
||||
throw scannerError(400, "Bitte einen einzelnen Dateinamen ohne Unterordner verwenden.")
|
||||
}
|
||||
if (!body.length) throw scannerError(400, "Die Datei ist leer.")
|
||||
if (body.length > SCANNER_UPLOAD_LIMIT) throw scannerError(413, "Die Datei überschreitet 100 MB.")
|
||||
const extension = filename.split(".").pop()?.toLowerCase()
|
||||
if (extension === "pdf" && body.subarray(0, 5).toString() === "%PDF-" && body.subarray(-1024).includes(Buffer.from("%%EOF"))) return "application/pdf"
|
||||
if (["jpg", "jpeg"].includes(extension) && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff) return "image/jpeg"
|
||||
if (extension === "png" && body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png"
|
||||
if (["tif", "tiff"].includes(extension) && ["49492a00", "4d4d002a"].includes(body.subarray(0, 4).toString("hex"))) return "image/tiff"
|
||||
throw scannerError(415, "Bitte eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei übertragen.")
|
||||
}
|
||||
|
||||
export function createScannerInboxService(db: FastifyInstance["db"], storage: ScannerStorage = objectStorage) {
|
||||
const publicConnection = {
|
||||
username: scannerInboxConnections.username,
|
||||
active: scannerInboxConnections.active,
|
||||
createdAt: scannerInboxConnections.createdAt,
|
||||
}
|
||||
const connection = async (tenantId: number) => {
|
||||
const [row] = await db.select(publicConnection).from(scannerInboxConnections)
|
||||
.where(eq(scannerInboxConnections.tenantId, tenantId)).limit(1)
|
||||
return row || null
|
||||
}
|
||||
const credentials = async (tenantId: number) => {
|
||||
// Epson ES-580W destination passwords are limited to 20 characters (120 random bits).
|
||||
const password = randomBytes(15).toString("base64url")
|
||||
const [row] = await db.insert(scannerInboxConnections).values({
|
||||
tenantId, username: `scan_${randomBytes(8).toString("hex")}`, passwordHash: digest(password),
|
||||
}).onConflictDoUpdate({
|
||||
target: scannerInboxConnections.tenantId,
|
||||
set: { passwordHash: digest(password), active: true, updatedAt: new Date() },
|
||||
}).returning(publicConnection)
|
||||
return { ...row, password }
|
||||
}
|
||||
const disable = async (tenantId: number) => {
|
||||
await db.update(scannerInboxConnections).set({ active: false, updatedAt: new Date() })
|
||||
.where(eq(scannerInboxConnections.tenantId, tenantId))
|
||||
}
|
||||
const authenticate = async (username: string, password: string): Promise<ScannerIdentity | null> => {
|
||||
const [row] = await db.select().from(scannerInboxConnections).where(and(
|
||||
eq(scannerInboxConnections.username, username), eq(scannerInboxConnections.active, true),
|
||||
)).limit(1)
|
||||
const expected = row?.passwordHash || "0".repeat(64)
|
||||
const valid = timingSafeEqual(Buffer.from(digest(password)), Buffer.from(expected))
|
||||
return row && valid ? { tenantId: row.tenantId, username: row.username, passwordHash: row.passwordHash } : null
|
||||
}
|
||||
const find = async (tenantId: number, filename: string) => {
|
||||
const [row] = await db.select({ size: files.size, mimeType: files.mimeType, checksum: scannerInboxDocuments.checksum,
|
||||
receivedAt: scannerInboxDocuments.receivedAt }).from(scannerInboxDocuments)
|
||||
.innerJoin(files, and(eq(files.id, scannerInboxDocuments.fileId), eq(files.tenant, scannerInboxDocuments.tenantId)))
|
||||
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.remoteFilename, filename))).limit(1)
|
||||
return row || null
|
||||
}
|
||||
const list = async (tenantId: number, limit: number, offset: number, search: string) => {
|
||||
const condition = and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId),
|
||||
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
|
||||
search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)
|
||||
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
|
||||
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt })
|
||||
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
||||
.where(condition).orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limit).offset(offset)
|
||||
const [total] = await db.select({ value: count() }).from(scannerInboxDocuments)
|
||||
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition)
|
||||
return { documents, total: total.value }
|
||||
}
|
||||
const filingOptions = async (tenantId: number) => {
|
||||
const [availableFolders, types] = await Promise.all([
|
||||
db.select({ id: folders.id, name: folders.name, parent: folders.parent,
|
||||
standardFiletype: folders.standardFiletype, standardFiletypeIsOptional: folders.standardFiletypeIsOptional })
|
||||
.from(folders).where(and(eq(folders.tenant, tenantId), eq(folders.archived, false))).orderBy(asc(folders.name)),
|
||||
db.select({ id: filetags.id, name: filetags.name }).from(filetags)
|
||||
.where(and(eq(filetags.tenant, tenantId), eq(filetags.archived, false))).orderBy(asc(filetags.name)),
|
||||
])
|
||||
return { folders: availableFolders, types }
|
||||
}
|
||||
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string) => db.transaction(async tx => {
|
||||
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
|
||||
.where(eq(tenants.id, tenantId)).for("share")
|
||||
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
|
||||
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
|
||||
const [entry] = await tx.select({ filedAt: scannerInboxDocuments.filedAt }).from(scannerInboxDocuments)
|
||||
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId))).for("update")
|
||||
if (!entry) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
|
||||
if (entry.filedAt) throw scannerError(409, "Dieses Dokument wurde bereits abgelegt. Bitte den Eingang aktualisieren.")
|
||||
const [file] = await tx.select({ id: files.id }).from(files)
|
||||
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId), eq(files.archived, false))).for("update")
|
||||
if (!file) throw scannerError(404, "Dokument nicht gefunden oder archiviert.")
|
||||
if (folderId) {
|
||||
const [folder] = await tx.select({ standardFiletype: folders.standardFiletype, optional: folders.standardFiletypeIsOptional })
|
||||
.from(folders).where(and(eq(folders.id, folderId), eq(folders.tenant, tenantId), eq(folders.archived, false))).for("share")
|
||||
if (!folder) throw scannerError(400, "Der gewählte Ordner ist nicht verfügbar.")
|
||||
if (folder.standardFiletype && !folder.optional && folder.standardFiletype !== typeId) {
|
||||
throw scannerError(400, "Dieser Ordner gibt einen festen Dokumenttyp vor.")
|
||||
}
|
||||
}
|
||||
const [type] = await tx.select({ id: filetags.id }).from(filetags)
|
||||
.where(and(eq(filetags.id, typeId), eq(filetags.tenant, tenantId), eq(filetags.archived, false))).for("share")
|
||||
if (!type) throw scannerError(400, "Der gewählte Dokumenttyp ist nicht verfügbar.")
|
||||
const now = new Date()
|
||||
await tx.update(files).set({ folder: folderId, type: typeId, updatedAt: now, updatedBy: userId })
|
||||
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
|
||||
// Keep the receipt so a scanner retry cannot recreate a document after filing.
|
||||
await tx.update(scannerInboxDocuments).set({ filedAt: now })
|
||||
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
|
||||
return { id: fileId, folder: folderId, type: typeId }
|
||||
})
|
||||
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
|
||||
const contentType = validateScannerFile(filename, body)
|
||||
const checksum = digest(body)
|
||||
let uploadedKey: string | undefined
|
||||
try {
|
||||
return await db.transaction(async tx => {
|
||||
// Coordinate with tenant maintenance and credential revocation during the upload.
|
||||
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
|
||||
.where(eq(tenants.id, identity.tenantId)).for("share")
|
||||
if (!tenant) throw scannerError(401, "Scanner-Zugang ungültig.")
|
||||
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
|
||||
const [active] = await tx.select({ tenantId: scannerInboxConnections.tenantId }).from(scannerInboxConnections)
|
||||
.where(and(eq(scannerInboxConnections.tenantId, identity.tenantId), eq(scannerInboxConnections.username, identity.username),
|
||||
eq(scannerInboxConnections.passwordHash, identity.passwordHash), eq(scannerInboxConnections.active, true))).for("share")
|
||||
if (!active) throw scannerError(401, "Scanner-Zugang ungültig.")
|
||||
|
||||
// Serialise retries across processes; a lost HTTP response must not duplicate a document.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
|
||||
const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
|
||||
eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
|
||||
)).limit(1)
|
||||
if (existing) {
|
||||
if (onlyIfNew) throw scannerError(412, "Der Dateiname ist bereits vorhanden.")
|
||||
if (existing.checksum !== checksum) throw scannerError(409, "Der Dateiname ist bereits vergeben. Bitte einen eindeutigen Dateinamen verwenden.")
|
||||
return { created: false, checksum }
|
||||
}
|
||||
const id = randomUUID()
|
||||
const key = `${identity.tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
|
||||
uploadedKey = key
|
||||
await storage.put(key, body, contentType)
|
||||
// Bypass saveFile's text extraction: the inbox only stores the original document.
|
||||
await tx.insert(files).values({ id, tenant: identity.tenantId, name: filename, path: key, mimeType: contentType, size: body.length })
|
||||
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId: identity.tenantId, remoteFilename: filename, checksum })
|
||||
return { created: true, checksum }
|
||||
})
|
||||
} catch (error) {
|
||||
if (uploadedKey) {
|
||||
try { await storage.remove(uploadedKey) }
|
||||
catch (cleanupError) { console.error("Scanner-Upload konnte nicht bereinigt werden", { key: uploadedKey, cleanupError }) }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, accept }
|
||||
}
|
||||
|
||||
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>
|
||||
104
backend/src/modules/selfhost-updater.ts
Normal file
104
backend/src/modules/selfhost-updater.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { request } from "node:http";
|
||||
import { z } from "zod";
|
||||
|
||||
const version = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
|
||||
export const requestId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/);
|
||||
export const checkBody = z.object({ requestId, version: version.optional() }).strict();
|
||||
export const applyBody = z.object({ requestId, version, checkRequestId: requestId }).strict();
|
||||
const errors = {
|
||||
INVALID_REQUEST: "Ungültige Anfrage.", NOT_FOUND: "Auftrag nicht gefunden.",
|
||||
BUSY: "Eine Prüfung oder ein Update läuft bereits.", SHUTTING_DOWN: "Der Update-Dienst wird beendet.",
|
||||
REQUEST_CONFLICT: "Diese Auftrags-ID wurde bereits für eine andere Anfrage verwendet.",
|
||||
CHECK_REQUIRED: "Bitte die Zielversion erneut prüfen.",
|
||||
COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
|
||||
RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
|
||||
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.",
|
||||
UPDATE_FAILED: "Auftrag fehlgeschlagen.",
|
||||
LOGS_UNAVAILABLE: "Das Update-Protokoll kann gerade nicht gelesen werden.",
|
||||
INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
|
||||
UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.",
|
||||
UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.",
|
||||
};
|
||||
type ErrorCode = keyof typeof errors;
|
||||
const knownError = (code: unknown): code is ErrorCode => typeof code === "string" && Object.prototype.hasOwnProperty.call(errors, code);
|
||||
export class UpdaterError extends Error {
|
||||
constructor(public code: ErrorCode, public status = 503) { super(errors[code]); }
|
||||
}
|
||||
const state = z.object({
|
||||
status: z.enum(["idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"]),
|
||||
phase: z.enum(["preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
|
||||
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"]).optional(),
|
||||
command: z.enum(["check", "apply"]).optional(), requestId: requestId.optional(),
|
||||
version: version.optional(), previousVersion: version.optional(), revision: z.string().regex(/^[a-f0-9]{40}$/).optional(),
|
||||
recoveryRequired: z.boolean().optional(), maintenance: z.boolean().optional(), mutationStarted: z.boolean().optional(),
|
||||
startedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), updatedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(),
|
||||
});
|
||||
const plan = state.omit({ status: true }).extend({
|
||||
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
|
||||
estimatedBackupBytes: z.number().nonnegative().finite().optional(),
|
||||
storageProvider: z.enum(["minio", "seaweedfs"]).optional(), storageImageRetained: z.boolean().optional(),
|
||||
retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
|
||||
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])),
|
||||
});
|
||||
const job = state.extend({ plan: plan.optional(), progress: state.optional(),
|
||||
error: z.object({ code: z.string() }).transform(({ code }) => ({
|
||||
code: knownError(code) ? code : "UPDATE_FAILED",
|
||||
message: knownError(code) ? errors[code] : errors.UPDATE_FAILED,
|
||||
})).optional(),
|
||||
});
|
||||
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
|
||||
installed: state.pick({ version: true, revision: true }), request: job.optional(),
|
||||
capabilities: z.object({ requestLogs: z.boolean() }).optional(),
|
||||
storage: z.object({ provider: z.literal("seaweedfs"), migrationPending: z.boolean() }).optional(),
|
||||
});
|
||||
const logs = z.object({ requestId, logs: z.array(z.object({
|
||||
source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(),
|
||||
})).max(3),
|
||||
});
|
||||
|
||||
// No caller-controlled host, URL, socket, headers or command can reach the privileged service.
|
||||
export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
|
||||
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
|
||||
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}(?:\/logs)?$/.test(path)))
|
||||
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
|
||||
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = request({ socketPath, path, method, agent: false, headers: payload ? {
|
||||
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload),
|
||||
} : {} }, res => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
res.on("data", chunk => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 65536) req.destroy(new UpdaterError("UPDATER_PROTOCOL", 502));
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
res.on("error", () => reject(new UpdaterError("UPDATER_UNAVAILABLE")));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
const code = data?.error?.code;
|
||||
if (![200, 202].includes(res.statusCode || 0)) {
|
||||
throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
|
||||
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502);
|
||||
}
|
||||
const schema = path === "/v1/status" ? status : path.endsWith("/logs") ? logs : job;
|
||||
const parsed = schema.parse(data);
|
||||
if (path.endsWith("/logs") && data.requestId !== path.split("/")[3]) {
|
||||
throw new UpdaterError("UPDATER_PROTOCOL", 502);
|
||||
}
|
||||
resolve({ status: res.statusCode!, body: parsed });
|
||||
} catch (error) {
|
||||
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
|
||||
}
|
||||
});
|
||||
});
|
||||
const timer = setTimeout(() => req.destroy(new UpdaterError("UPDATER_UNAVAILABLE")), timeoutMs);
|
||||
req.on("close", () => clearTimeout(timer));
|
||||
req.on("error", error => reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE")));
|
||||
req.end(payload);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { HeadBucketCommand } from "@aws-sdk/client-s3"
|
||||
import { s3 } from "../utils/s3"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
type MetricSample = {
|
||||
labels: Record<string, string>
|
||||
@@ -10,9 +13,6 @@ const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\
|
||||
const nodeExporterUrl = () =>
|
||||
(process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "")
|
||||
|
||||
const s3EndpointUrl = () =>
|
||||
(process.env.S3_ENDPOINT || "").replace(/\/+$/, "")
|
||||
|
||||
const parseLabels = (value = "") => {
|
||||
const labels: Record<string, string> = {}
|
||||
const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g
|
||||
@@ -63,20 +63,17 @@ const serviceState = (ok: boolean, detail?: Record<string, any>) => ({
|
||||
...detail,
|
||||
})
|
||||
|
||||
const checkHttp = async (url: string, timeoutMs = 3000) => {
|
||||
export const checkFileStorage = async (client = s3, bucket = secrets.S3_BUCKET, timeoutMs = 3000) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
return serviceState(response.ok, {
|
||||
httpStatus: response.status,
|
||||
url,
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (!client || !bucket) return serviceState(false, { error: "S3-Dateispeicher ist nicht eingerichtet" })
|
||||
await client.send(new HeadBucketCommand({ Bucket: bucket }), { abortSignal: controller.signal })
|
||||
return serviceState(true, { provider: process.env.FEDEO_STORAGE_PROVIDER === "seaweedfs" ? "seaweedfs" : "s3" })
|
||||
} catch {
|
||||
return serviceState(false, {
|
||||
url,
|
||||
error: err?.message || "HTTP-Abfrage fehlgeschlagen",
|
||||
error: controller.signal.aborted ? "S3-Prüfung hat das Zeitlimit überschritten" : "S3-Bucket nicht erreichbar oder Zugriff verweigert",
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
@@ -116,7 +113,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
|
||||
|
||||
const databaseCheck = await server.db.execute("SELECT NOW() as now")
|
||||
const minioUrl = s3EndpointUrl()
|
||||
|
||||
return {
|
||||
checkedAt: checkedAt.toISOString(),
|
||||
@@ -160,9 +156,8 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
url: nodeExporterMetricsUrl,
|
||||
error: nodeExporterError,
|
||||
}),
|
||||
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
|
||||
error: "S3_ENDPOINT ist nicht gesetzt",
|
||||
}),
|
||||
// Preserve the response key for older clients; check the authenticated S3 API for every provider.
|
||||
minio: await checkFileStorage(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
importTenantFullExportArchive,
|
||||
} from "../utils/tenantFullExport";
|
||||
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
||||
import { buildSystemStatus } from "../modules/system-status.service";
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
@@ -563,29 +564,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
return job;
|
||||
};
|
||||
|
||||
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!req.user?.user_id) {
|
||||
reply.code(401).send({ error: "Unauthorized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const [currentUser] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, req.user.user_id))
|
||||
.limit(1);
|
||||
|
||||
if (!currentUser?.is_admin) {
|
||||
reply.code(403).send({ error: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentUser;
|
||||
};
|
||||
const requireAdmin = (req: FastifyRequest, reply: FastifyReply) => requireSystemAdmin(server, req, reply);
|
||||
|
||||
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
||||
const existingRoles = await server.db
|
||||
|
||||
47
backend/src/routes/documentInbox.ts
Normal file
47
backend/src/routes/documentInbox.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { authProfiles } from "../../db/schema"
|
||||
import { createScannerInboxService, SCANNER_DAV_PATH, SCANNER_UPLOAD_LIMIT, type ScannerInboxService } from "../modules/scanner-inbox.service"
|
||||
|
||||
export default async function documentInboxRoutes(server: FastifyInstance, options: { service?: ScannerInboxService }) {
|
||||
const service = options.service || createScannerInboxService(server.db)
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
if (!req.user?.tenant_id) return reply.code(400).send({ error: "Bitte einen Mandanten auswählen." })
|
||||
const [profile] = await server.db.select({ portal: authProfiles.customer_for_portal }).from(authProfiles)
|
||||
.where(and(eq(authProfiles.tenant_id, req.user.tenant_id), eq(authProfiles.user_id, req.user.user_id))).limit(1)
|
||||
if (profile?.portal) return reply.code(403).send({ error: "Die Dokumentenablage ist nur intern verfügbar." })
|
||||
if (req.routeOptions.url?.includes("/scanner") && !req.user.is_admin) {
|
||||
return reply.code(403).send({ error: "Scanner-Zugänge können nur Administratoren verwalten." })
|
||||
}
|
||||
reply.header("Cache-Control", "no-store")
|
||||
})
|
||||
server.get("/document-inbox", async (req, reply) => {
|
||||
const query = req.query as { limit?: string; offset?: string; search?: string }
|
||||
const limit = Number(query.limit ?? 50)
|
||||
const offset = Number(query.offset ?? 0)
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0 || (query.search?.length || 0) > 200) {
|
||||
return reply.code(400).send({ error: "Ungültige Such- oder Seiteneinstellungen." })
|
||||
}
|
||||
return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "")
|
||||
})
|
||||
server.get("/document-inbox/filing-options", async req => service.filingOptions(req.user.tenant_id!))
|
||||
server.post<{ Params: { id: string }; Body: { folder: string | null; type: string } }>("/document-inbox/:id/file", {
|
||||
schema: {
|
||||
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
|
||||
body: { type: "object", required: ["folder", "type"], additionalProperties: false, properties: {
|
||||
folder: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||
type: { type: "string", format: "uuid" },
|
||||
} },
|
||||
},
|
||||
}, async req => service.fileDocument(req.user.tenant_id!, req.user.user_id, req.params.id, req.body.folder, req.body.type))
|
||||
server.get("/document-inbox/scanner", async req => ({
|
||||
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
|
||||
}))
|
||||
server.post("/document-inbox/scanner/credentials", async req => ({
|
||||
connection: await service.credentials(req.user.tenant_id!), path: SCANNER_DAV_PATH,
|
||||
}))
|
||||
server.delete("/document-inbox/scanner", async (_req, reply) => {
|
||||
await service.disable(_req.user.tenant_id!)
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
@@ -152,6 +152,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
||||
active: instanceAgents.active,
|
||||
preferredScannerName: instanceAgents.preferredScannerName,
|
||||
scanDefaults: instanceAgents.scanDefaults,
|
||||
capabilities: instanceAgents.capabilities,
|
||||
})
|
||||
.from(instanceAgents)
|
||||
.where(eq(instanceAgents.id, body.agentId))
|
||||
@@ -161,6 +162,14 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
||||
return reply.code(404).send({ error: "Active agent not found" })
|
||||
}
|
||||
|
||||
const settings = { ...((agent.scanDefaults || {}) as Record<string, any>), ...(body.settings || {}) }
|
||||
const enabled = (value: unknown) => value === true || (typeof value === "string" && ["1", "true", "yes", "ja", "on"].includes(value.toLowerCase().trim()))
|
||||
const capabilities = agent.capabilities as Record<string, any>
|
||||
if (((settings.format || "pdf") === "pdf" && enabled(settings.ocr) && !capabilities?.ocr) ||
|
||||
(enabled(settings.backgroundCleanup) && !capabilities?.backgroundCleanup)) {
|
||||
return reply.code(409).send({ error: "Bitte den Scanner-Agenten mit Tesseract und OpenCV aktualisieren. Die angeforderte Scan-Verarbeitung ist dort noch nicht verfügbar." })
|
||||
}
|
||||
|
||||
const [job] = await server.db
|
||||
.insert(instanceAgentScanJobs)
|
||||
.values({
|
||||
@@ -169,10 +178,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
||||
requestedBy: req.user?.user_id,
|
||||
scannerName: body.scannerName || agent.preferredScannerName,
|
||||
requestedFilename: body.requestedFilename,
|
||||
settings: {
|
||||
...((agent.scanDefaults || {}) as Record<string, any>),
|
||||
...(body.settings || {}),
|
||||
},
|
||||
settings,
|
||||
target: body.target || {},
|
||||
})
|
||||
.returning()
|
||||
|
||||
74
backend/src/routes/scannerInboxGateway.ts
Normal file
74
backend/src/routes/scannerInboxGateway.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify"
|
||||
import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, scannerError, type ScannerIdentity, type ScannerInboxService } from "../modules/scanner-inbox.service"
|
||||
|
||||
const escapeXml = (text: string) => text.replace(/[<>&"']/g, char => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[char]!))
|
||||
const methods = "OPTIONS, PROPFIND, HEAD, PUT"
|
||||
|
||||
export default async function scannerInboxGateway(server: FastifyInstance, options: { service?: ScannerInboxService }) {
|
||||
const service = options.service || createScannerInboxService(server.db)
|
||||
const identities = new WeakMap<FastifyRequest, ScannerIdentity>()
|
||||
server.addHttpMethod("PROPFIND", { hasBody: true })
|
||||
server.addHttpMethod("MKCOL", { hasBody: true })
|
||||
// Keep raw scanner bytes, even when firmware supplies application/octet-stream or no MIME type.
|
||||
server.removeAllContentTypeParsers()
|
||||
server.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: SCANNER_UPLOAD_LIMIT }, (_req, body, done) => done(null, body))
|
||||
|
||||
server.addHook("onRequest", async (req, reply) => {
|
||||
reply.header("Cache-Control", "no-store")
|
||||
const encoded = /^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(req.headers.authorization || "")?.[1]
|
||||
const value = encoded && encoded.length < 1024 ? Buffer.from(encoded, "base64").toString("utf8") : ""
|
||||
const separator = value.indexOf(":")
|
||||
const identity = separator > 0 ? await service.authenticate(value.slice(0, separator), value.slice(separator + 1)) : null
|
||||
if (!identity) return reply.header("WWW-Authenticate", 'Basic realm="FEDEO Scanner", charset="UTF-8"')
|
||||
.code(401).send({ error: "Scanner-Zugangsdaten erforderlich." })
|
||||
identities.set(req, identity)
|
||||
})
|
||||
|
||||
const handle = async (req: FastifyRequest, reply: any) => {
|
||||
const identity = identities.get(req)!
|
||||
const filename = (req.params as { "*"?: string })["*"] || ""
|
||||
if (filename && (filename.length > 200 || filename.startsWith(".") || /[\\/\u0000-\u001f\u007f]/.test(filename))) {
|
||||
throw scannerError(400, "Unterordner und ungültige Dateinamen werden nicht unterstützt.")
|
||||
}
|
||||
if (req.method === "OPTIONS") return reply.header("DAV", "1").header("Allow", methods).code(200).send()
|
||||
if (req.method === "MKCOL") return reply.header("Allow", methods).code(filename ? 403 : 405).send()
|
||||
if (req.method === "PUT") {
|
||||
if (!filename) throw scannerError(400, "Ein Dateiname ist erforderlich.")
|
||||
if (req.headers["content-range"]) throw scannerError(400, "Bitte die vollständige Datei übertragen.")
|
||||
if (req.headers["if-match"] || req.headers["if-unmodified-since"] || req.headers.if) {
|
||||
throw scannerError(412, "Änderungen an vorhandenen Dokumenten werden nicht unterstützt.")
|
||||
}
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)
|
||||
const result = await service.accept(identity, filename, body, req.headers["if-none-match"] === "*")
|
||||
return reply.header("ETag", `"${result.checksum}"`).code(result.created ? 201 : 204).send()
|
||||
}
|
||||
const entry = filename ? await service.find(identity.tenantId, filename) : null
|
||||
if (filename && !entry) return reply.code(404).send()
|
||||
if (req.method === "HEAD") {
|
||||
if (entry) reply.header("Content-Length", entry.size).header("Content-Type", entry.mimeType).header("ETag", `"${entry.checksum}"`)
|
||||
return reply.code(200).send()
|
||||
}
|
||||
if (req.method === "PROPFIND") {
|
||||
// This is an upload inbox, not a browsable archive. Depth 1 deliberately exposes no other files.
|
||||
if (req.headers.depth && !["0", "1"].includes(String(req.headers.depth))) {
|
||||
return reply.type("application/xml").code(403).send('<d:error xmlns:d="DAV:"><d:propfind-finite-depth/></d:error>')
|
||||
}
|
||||
const prefixHeader = req.headers["x-forwarded-prefix"]
|
||||
const prefix = typeof prefixHeader === "string" && /^(\/[A-Za-z0-9._~-]+)*\/?$/.test(prefixHeader) ? prefixHeader.replace(/\/$/, "") : ""
|
||||
const href = prefix + req.url.split("?")[0] + (!filename && !req.url.split("?")[0].endsWith("/") ? "/" : "")
|
||||
const props = entry
|
||||
? `<d:resourcetype/><d:getcontentlength>${entry.size}</d:getcontentlength><d:getcontenttype>${entry.mimeType}</d:getcontenttype><d:getetag>"${entry.checksum}"</d:getetag><d:getlastmodified>${entry.receivedAt.toUTCString()}</d:getlastmodified>`
|
||||
: '<d:resourcetype><d:collection/></d:resourcetype>'
|
||||
return reply.header("DAV", "1").type("application/xml; charset=utf-8").code(207).send(
|
||||
`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>${escapeXml(href)}</d:href><d:propstat><d:prop><d:displayname>${escapeXml(filename || "FEDEO Dokumentenablage")}</d:displayname>${props}</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`)
|
||||
}
|
||||
return reply.header("Allow", methods).code(405).send()
|
||||
}
|
||||
|
||||
// Both slash forms work without redirects (some scanners discard authentication on redirects).
|
||||
for (const url of ["/", "/*"]) {
|
||||
server.route<{}, { cors: false }>({ method: ["OPTIONS", "PROPFIND", "HEAD", "PUT", "MKCOL", "GET", "DELETE", "POST", "PATCH"],
|
||||
// Scanner OPTIONS requests are WebDAV discovery, not browser CORS preflights.
|
||||
url, config: { cors: false }, bodyLimit: SCANNER_UPLOAD_LIMIT, handler: handle })
|
||||
}
|
||||
}
|
||||
42
backend/src/routes/selfhostUpdates.ts
Normal file
42
backend/src/routes/selfhostUpdates.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { applyBody, checkBody, createUpdaterClient, requestId, UpdaterError } from "../modules/selfhost-updater";
|
||||
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
||||
import { getBuildInfo } from "../utils/buildInfo";
|
||||
|
||||
export default async function selfhostUpdateRoutes(server: FastifyInstance, options: { client?: ReturnType<typeof createUpdaterClient> }) {
|
||||
const client = options.client || createUpdaterClient();
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
await requireSystemAdmin(server, req, reply);
|
||||
});
|
||||
const forward = async (reply: any, method: "GET" | "POST", path: string, body?: unknown) => {
|
||||
try {
|
||||
const result = await client(method, path, body);
|
||||
return reply.code(result.status).send(path === "/v1/status"
|
||||
? { ...result.body as object, backend: getBuildInfo() } : result.body);
|
||||
} catch (error) {
|
||||
const safe = error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE");
|
||||
return reply.code(safe.status).send({ error: { code: safe.code, message: safe.message } });
|
||||
}
|
||||
};
|
||||
server.get("/admin/updates/status", async (_req, reply) => forward(reply, "GET", "/v1/status"));
|
||||
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id", async (req, reply) => {
|
||||
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
|
||||
return forward(reply, "GET", `/v1/requests/${req.params.id}`);
|
||||
});
|
||||
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id/logs", async (req, reply) => {
|
||||
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
|
||||
return forward(reply, "GET", `/v1/requests/${req.params.id}/logs`);
|
||||
});
|
||||
for (const command of ["check", "apply"] as const) {
|
||||
server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => {
|
||||
// The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF).
|
||||
if (!/^Bearer \S{11,}$/.test(req.headers.authorization || "")) {
|
||||
return reply.code(403).send({ error: { code: "BEARER_REQUIRED", message: "Bitte erneut anmelden." } });
|
||||
}
|
||||
const parsed = (command === "apply" ? applyBody : checkBody).safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
|
||||
return forward(reply, "POST", `/v1/${command}`, parsed.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
backend/src/utils/requireSystemAdmin.ts
Normal file
18
backend/src/utils/requireSystemAdmin.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authUsers } from "../../db/schema/auth_users";
|
||||
|
||||
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user?.user_id) {
|
||||
reply.code(401).send({ error: "Unauthorized" });
|
||||
return null;
|
||||
}
|
||||
// Read the current global role; tenant roles and stale JWT claims do not grant access.
|
||||
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
|
||||
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
|
||||
if (!user?.is_admin) {
|
||||
reply.code(403).send({ error: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const resourceConfig = {
|
||||
projects: {
|
||||
searchColumns: ["name","customerRef","projectNumber","notes"],
|
||||
mtoLoad: ["customer","plant","contract","projecttype"],
|
||||
mtmLoad: ["tasks", "files","createddocuments"],
|
||||
mtmLoad: ["tasks", "files","createddocuments", "events"],
|
||||
table: projects,
|
||||
numberRangeHolder: "projectNumber"
|
||||
},
|
||||
|
||||
133
backend/tests/instanceAgentMigration.test.ts
Normal file
133
backend/tests/instanceAgentMigration.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { execFile } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { Pool } from "pg"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import { readMigrationFiles } from "drizzle-orm/migrator"
|
||||
import Fastify from "fastify"
|
||||
import routes from "../src/routes/instanceAgents"
|
||||
|
||||
const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep
|
||||
const repairTag = "0072_restore_instance_scan_agents"
|
||||
const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries
|
||||
const repair = journal.find((entry: any) => entry.tag === repairTag)
|
||||
const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when))
|
||||
const execute = promisify(execFile)
|
||||
|
||||
test("the scanner repair is registered after every previously shipped migration", () => {
|
||||
assert.ok(repair, "scanner repair must be in the Drizzle journal")
|
||||
assert.ok(repair.when > previousWatermark)
|
||||
const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark)
|
||||
assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"'))))
|
||||
})
|
||||
|
||||
// Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own
|
||||
// disposable database container; it never reads DATABASE_URL or application secrets.
|
||||
test("migration repairs upgraded databases and preserves manually installed scanners", {
|
||||
skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE,
|
||||
}, async t => {
|
||||
// Isolate this repair from unrelated future migrations. Registration in the
|
||||
// actual shipped journal is checked above; its timestamp remains unchanged.
|
||||
const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-"))
|
||||
t.after(() => rm(testMigrations, { recursive: true, force: true }))
|
||||
await mkdir(path.join(testMigrations, "meta"))
|
||||
await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] }))
|
||||
await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql"))
|
||||
const container = "fedeo-scan-migration-" + randomUUID()
|
||||
const password = randomUUID()
|
||||
await execute("docker", ["run", "--rm", "-d", "--name", container,
|
||||
"-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432",
|
||||
process.env.FEDEO_TEST_POSTGRES_IMAGE!])
|
||||
let admin: Pool | undefined
|
||||
t.after(async () => {
|
||||
try { await admin?.end() }
|
||||
finally { await execute("docker", ["rm", "-f", container]) }
|
||||
})
|
||||
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
|
||||
const port = Number(stdout.trim().split(":").at(-1))
|
||||
admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await admin.query("SELECT 1"); break } catch (error) {
|
||||
if (attempt >= 80) throw error
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of ["absent", "0051", "0052", "0053"]) {
|
||||
await t.test(state, async () => {
|
||||
const database = "scan_" + state
|
||||
await admin.query('CREATE DATABASE "' + database + '"')
|
||||
const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 })
|
||||
const app = Fastify()
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE tenants (id bigint PRIMARY KEY);
|
||||
CREATE TABLE auth_users (id uuid PRIMARY KEY);
|
||||
CREATE TABLE files (id uuid PRIMARY KEY);
|
||||
INSERT INTO tenants VALUES (1);
|
||||
CREATE SCHEMA drizzle;
|
||||
CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
||||
`)
|
||||
// An existing installation already passed 0071. Re-inserting old
|
||||
// journal entries 0051-0053 would therefore NOT fix this database.
|
||||
await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark])
|
||||
if (state !== "absent") {
|
||||
for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) {
|
||||
if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8"))
|
||||
}
|
||||
await pool.query(`
|
||||
INSERT INTO instance_agents (id, name, token_prefix, token_hash)
|
||||
VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash');
|
||||
INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings)
|
||||
VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}');
|
||||
`)
|
||||
if (state !== "0051") {
|
||||
await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`)
|
||||
}
|
||||
}
|
||||
|
||||
const db = drizzle(pool)
|
||||
await migrate(db, { migrationsFolder: testMigrations })
|
||||
await migrate(db, { migrationsFolder: testMigrations })
|
||||
// Also prove the repair SQL itself is safe after manual application.
|
||||
await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8"))
|
||||
app.decorate("db", db)
|
||||
app.addHook("onRequest", async req => { req.user = { is_admin: true } as any })
|
||||
await app.register(routes)
|
||||
const listing = await app.inject({ method: "GET", url: "/instance-agents" })
|
||||
assert.equal(listing.statusCode, 200, listing.body)
|
||||
const agents = listing.json().agents
|
||||
assert.equal(agents.length, state === "absent" ? 0 : 1)
|
||||
if (state !== "absent") {
|
||||
assert.equal(agents[0].name, "Existing scanner")
|
||||
assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash")
|
||||
assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600)
|
||||
if (state !== "0051") {
|
||||
assert.equal(agents[0].preferredScannerName, "existing-device")
|
||||
assert.deepEqual(agents[0].scanDefaults, { resolution: 600 })
|
||||
}
|
||||
}
|
||||
const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } })
|
||||
assert.equal(created.statusCode, 200, created.body)
|
||||
assert.equal(created.json().agent.scanDefaults.postprocess, false)
|
||||
assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document")
|
||||
assert.equal(created.json().agent.scanDefaults.format, "pdf")
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1)
|
||||
const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname)
|
||||
assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx"))
|
||||
assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx"))
|
||||
await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" })
|
||||
} finally {
|
||||
await app.close()
|
||||
await pool.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -55,3 +55,30 @@ test("unknown agents cannot receive setup credentials", async (t) => {
|
||||
assert.equal(response.statusCode, 404)
|
||||
assert.equal(f.updates.length, 0)
|
||||
})
|
||||
|
||||
test("OCR and background cleanup require an agent that supports the requested processing", async t => {
|
||||
const app = Fastify()
|
||||
t.after(() => app.close())
|
||||
const jobs: any[] = []
|
||||
let capabilities: Record<string, boolean> = { scan: true }
|
||||
app.addHook("onRequest", async request => { request.user = { is_admin: true, tenant_id: 1 } as any })
|
||||
app.decorate("db", {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, active: true, scanDefaults: {}, capabilities }] }) }) }),
|
||||
insert: () => ({ values: (data: any) => ({ returning: async () => { jobs.push(data); return [{ id: "job", ...data }] } }) }),
|
||||
})
|
||||
await app.register(routes)
|
||||
for (const settings of [{ ocr: true }, { ocr: "true" }, { backgroundCleanup: true }]) {
|
||||
const response = await app.inject({ method: "POST", url: "/scan-jobs", payload: { agentId: id, settings } })
|
||||
assert.equal(response.statusCode, 409)
|
||||
assert.equal(jobs.length, 0)
|
||||
}
|
||||
capabilities = { scan: true, ocr: true, backgroundCleanup: true }
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/scan-jobs",
|
||||
payload: { agentId: id, settings: { ocr: true, ocrLanguage: "deu+eng", backgroundCleanup: true }, target: { folder: null, type: null } },
|
||||
})
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.equal(jobs.length, 1)
|
||||
assert.equal(jobs[0].settings.ocr, true)
|
||||
assert.equal(jobs[0].target.type, null)
|
||||
})
|
||||
|
||||
330
backend/tests/scannerInbox.test.ts
Normal file
330
backend/tests/scannerInbox.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { execFile } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { readFile, mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import Fastify from "fastify"
|
||||
import { Pool } from "pg"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import { getTableColumns } from "drizzle-orm"
|
||||
import { PDFDocument } from "pdf-lib"
|
||||
import { files } from "../db/schema"
|
||||
import gateway from "../src/routes/scannerInboxGateway"
|
||||
import inboxRoutes from "../src/routes/documentInbox"
|
||||
import corsPlugin from "../src/plugins/cors"
|
||||
import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, validateScannerFile, type ScannerInboxService } from "../src/modules/scanner-inbox.service"
|
||||
|
||||
const execute = promisify(execFile)
|
||||
const basic = (username = "scanner", password = "secret") => ({ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` })
|
||||
const samplePdf = Buffer.from("%PDF-1.7\nscanner fixture\n%%EOF")
|
||||
|
||||
test("scanner formats are verified independently of the supplied content type", () => {
|
||||
assert.equal(validateScannerFile("scan.PDF", samplePdf), "application/pdf")
|
||||
assert.equal(validateScannerFile("scan.jpg", Buffer.from([255, 216, 255, 0])), "image/jpeg")
|
||||
assert.equal(validateScannerFile("scan.png", Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), "image/png")
|
||||
assert.equal(validateScannerFile("scan.tiff", Buffer.from("49492a00", "hex")), "image/tiff")
|
||||
for (const [filename, body, status] of [
|
||||
["scan.pdf", Buffer.alloc(0), 400], ["scan.pdf", Buffer.from("<html>error</html>"), 415],
|
||||
["scan.pdf", Buffer.from("%PDF-truncated"), 415], ["scan.zip", samplePdf, 415],
|
||||
["../scan.pdf", samplePdf, 400], ["a/b.pdf", samplePdf, 400], ["a\\b.pdf", samplePdf, 400],
|
||||
[".hidden.pdf", samplePdf, 400], ["a\u0000.pdf", samplePdf, 400],
|
||||
] as const) assert.throws(() => validateScannerFile(filename, body), { statusCode: status })
|
||||
assert.throws(() => validateScannerFile("large.pdf", Buffer.alloc(SCANNER_UPLOAD_LIMIT + 1)), { statusCode: 413 })
|
||||
})
|
||||
|
||||
const protocolFixture = async () => {
|
||||
const calls: any[] = []
|
||||
const service = {
|
||||
authenticate: async (username: string, password: string) => username === "scanner" && password === "secret" ? { tenantId: 7 } : null,
|
||||
accept: async (...args: any[]) => { calls.push(args); validateScannerFile(args[1], args[2]); return { created: true, checksum: "abc" } },
|
||||
find: async () => null,
|
||||
} as unknown as ScannerInboxService
|
||||
const app = Fastify()
|
||||
await app.register(gateway, { prefix: "/scanner-inbox", service })
|
||||
// Match production: the global CORS plugin is registered after the scanner gateway.
|
||||
await app.register(corsPlugin)
|
||||
return { app, calls }
|
||||
}
|
||||
|
||||
test("WebDAV authentication happens before body parsing, including discovery requests", async t => {
|
||||
const { app } = await protocolFixture()
|
||||
t.after(() => app.close())
|
||||
for (const method of ["OPTIONS", "PROPFIND", "PUT", "HEAD"] as const) {
|
||||
const res = await app.inject({ method, url: "/scanner-inbox/scan.pdf" })
|
||||
assert.equal(res.statusCode, 401)
|
||||
assert.match(res.headers["www-authenticate"] as string, /^Basic /)
|
||||
}
|
||||
const res = await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic("scanner", "wrong"), "content-type": "application/json" }, payload: "not json" })
|
||||
assert.equal(res.statusCode, 401)
|
||||
})
|
||||
|
||||
test("Epson discovery works with both slash forms, XML bodies and a backend proxy prefix", async t => {
|
||||
const { app } = await protocolFixture()
|
||||
t.after(() => app.close())
|
||||
for (const url of ["/scanner-inbox", "/scanner-inbox/"]) {
|
||||
const options = await app.inject({ method: "OPTIONS", url, headers: basic() })
|
||||
assert.equal(options.statusCode, 200)
|
||||
assert.match(options.headers.allow as string, /PUT/)
|
||||
const head = await app.inject({ method: "HEAD", url, headers: basic() })
|
||||
assert.equal(head.statusCode, 200)
|
||||
const res = await app.inject({ method: "PROPFIND", url, headers: { ...basic(), depth: "1", "content-type": "text/xml", "x-forwarded-prefix": "/backend" }, payload: '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>' })
|
||||
assert.equal(res.statusCode, 207, res.body)
|
||||
assert.match(res.body, /<d:href>\/backend\/scanner-inbox\/<\/d:href>/)
|
||||
assert.match(res.body, /<d:collection\/>/)
|
||||
assert.equal((res.body.match(/<d:response>/g) || []).length, 1)
|
||||
}
|
||||
assert.equal((await app.inject({ method: "PROPFIND", url: "/scanner-inbox/", headers: { ...basic(), depth: "infinity" } })).statusCode, 403)
|
||||
})
|
||||
|
||||
test("raw PDFs reach storage unchanged and scanner credentials cannot download or delete", async t => {
|
||||
const { app, calls } = await protocolFixture()
|
||||
t.after(() => app.close())
|
||||
for (const contentType of ["application/pdf", "application/octet-stream", undefined]) {
|
||||
const res = await app.inject({ method: "PUT", url: "/scanner-inbox/Rechnung%20%26%20Beleg.pdf", headers: { ...basic(), ...(contentType ? { "content-type": contentType } : {}) }, payload: samplePdf })
|
||||
assert.equal(res.statusCode, 201, res.body)
|
||||
assert.equal(calls.at(-1)[0].tenantId, 7)
|
||||
assert.equal(calls.at(-1)[1], "Rechnung & Beleg.pdf")
|
||||
assert.deepEqual(calls.at(-1)[2], samplePdf)
|
||||
}
|
||||
for (const method of ["GET", "DELETE", "POST", "PATCH"] as const) {
|
||||
assert.equal((await app.inject({ method, url: "/scanner-inbox/", headers: basic() })).statusCode, 405)
|
||||
}
|
||||
for (const name of ["sub/scan.pdf", "sub%2Fscan.pdf", "..%5Cscan.pdf"]) {
|
||||
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/" + name, headers: basic(), payload: samplePdf })).statusCode, 400)
|
||||
}
|
||||
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic(), "content-range": "bytes 0-10/20" }, payload: samplePdf })).statusCode, 400)
|
||||
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic(), "if-match": '"old"' }, payload: samplePdf })).statusCode, 412)
|
||||
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic(), "content-type": "application/pdf", "content-length": String(SCANNER_UPLOAD_LIMIT + 1) }, payload: samplePdf })).statusCode, 413)
|
||||
})
|
||||
|
||||
// Uses only a disposable database and fake object storage, never DATABASE_URL or production secrets.
|
||||
test("scanner inbox integration: migration, persistence, isolation, retries and management", {
|
||||
skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE,
|
||||
}, async t => {
|
||||
const container = "fedeo-scanner-inbox-test-" + randomUUID()
|
||||
const dbPassword = randomUUID()
|
||||
await execute("docker", ["run", "--rm", "-d", "--name", container, "-e", `POSTGRES_PASSWORD=${dbPassword}`, "-p", "127.0.0.1::5432", process.env.FEDEO_TEST_POSTGRES_IMAGE!])
|
||||
let pool: Pool | undefined
|
||||
const app = Fastify()
|
||||
const tempMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scanner-inbox-"))
|
||||
t.after(async () => {
|
||||
await app.close()
|
||||
await pool?.end()
|
||||
await execute("docker", ["rm", "-f", container])
|
||||
await rm(tempMigrations, { recursive: true, force: true })
|
||||
})
|
||||
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
|
||||
pool = new Pool({ host: "127.0.0.1", port: Number(stdout.trim().split(":").at(-1)), user: "postgres", password: dbPassword, database: "postgres", connectionTimeoutMillis: 1000 })
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await pool.query("SELECT 1"); break } catch (error) {
|
||||
if (attempt >= 80) throw error
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
const columns = Object.values(getTableColumns(files)).map(column => {
|
||||
const suffix = column.name === "id" ? " PRIMARY KEY" : column.name === "created_at" ? " DEFAULT now()" : column.name === "archived" ? " DEFAULT false" : ""
|
||||
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
||||
}).join(", ")
|
||||
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, locked text);
|
||||
INSERT INTO tenants VALUES (1, null), (2, null);
|
||||
CREATE TABLE files (${columns}, CHECK (name <> 'reject.pdf'));
|
||||
CREATE TABLE filetags (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||
CREATE TABLE folders (id uuid PRIMARY KEY, tenant bigint, name text, parent uuid, "standardFiletype" uuid,
|
||||
"standardFiletypeIsOptional" boolean DEFAULT true, archived boolean DEFAULT false);
|
||||
CREATE TABLE auth_profiles (tenant_id bigint, user_id uuid, customer_for_portal bigint);`)
|
||||
const migrationRoot = path.resolve(__dirname, "../db/migrations")
|
||||
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
|
||||
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing"].map(tag => journal.entries.find((item: any) => item.tag === tag))
|
||||
await mkdir(path.join(tempMigrations, "meta"))
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry)
|
||||
assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when))
|
||||
await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql")))
|
||||
}
|
||||
await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries }))
|
||||
const db = drizzle(pool) as any
|
||||
await migrate(db, { migrationsFolder: tempMigrations })
|
||||
await migrate(db, { migrationsFolder: tempMigrations })
|
||||
const stored = new Map<string, Buffer>()
|
||||
let storageFailure = false
|
||||
const service = createScannerInboxService(db, {
|
||||
async put(key, body) { stored.set(key, Buffer.from(body)); if (storageFailure) throw new Error("Simulated object store failure") },
|
||||
async remove(key) { stored.delete(key) },
|
||||
})
|
||||
const first = await service.credentials(1)
|
||||
const second = await service.credentials(2)
|
||||
assert.equal(first.password.length, 20, "Epson destination password limit")
|
||||
assert.ok(first.username.length <= 30)
|
||||
assert.equal((await service.connection(1) as any).passwordHash, undefined)
|
||||
assert.equal((await pool.query("SELECT password_hash FROM scanner_inbox_connections WHERE tenant_id=1")).rows[0].password_hash.length, 64)
|
||||
const userId = randomUUID()
|
||||
app.decorate("db", db)
|
||||
await app.register(gateway, { prefix: "/scanner-inbox", service })
|
||||
await app.register(corsPlugin)
|
||||
await app.register(async api => {
|
||||
api.addHook("onRequest", async req => {
|
||||
req.user = { tenant_id: Number(req.headers["x-test-tenant"] || 1) || null, user_id: userId, email: "test@example.invalid", is_admin: req.headers["x-test-admin"] !== "false" }
|
||||
})
|
||||
await api.register(inboxRoutes, { service })
|
||||
}, { prefix: "/api" })
|
||||
const doc = await PDFDocument.create()
|
||||
doc.addPage()
|
||||
doc.addPage()
|
||||
const pdf = Buffer.from(await doc.save())
|
||||
const send = (name: string, payload = pdf, headers: Record<string, string> = basic(first.username, first.password)) => app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-type": "application/pdf", ...headers }, payload })
|
||||
|
||||
await t.test("stores one unchanged two-page PDF as one document without extraction", async () => {
|
||||
const res = await send("scan.pdf")
|
||||
assert.equal(res.statusCode, 201, res.body)
|
||||
const row = (await pool.query("SELECT * FROM files")).rows[0]
|
||||
assert.equal(row.tenant, "1")
|
||||
assert.equal(row.mimeType, "application/pdf")
|
||||
assert.equal(row.extracted_text, null)
|
||||
assert.deepEqual(stored.get(row.path), pdf)
|
||||
assert.equal((await PDFDocument.load(stored.get(row.path)!)).getPageCount(), 2)
|
||||
const listing = await app.inject({ method: "GET", url: "/api/document-inbox" })
|
||||
assert.equal(listing.json().total, 1)
|
||||
assert.equal(listing.json().documents[0].name, "scan.pdf")
|
||||
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200)
|
||||
})
|
||||
await t.test("serialises simultaneous retries and preserves existing content on filename conflicts", async () => {
|
||||
const retry = await Promise.all([send("scan.pdf"), send("scan.pdf")])
|
||||
assert.deepEqual(retry.map(res => res.statusCode), [204, 204])
|
||||
const concurrent = await Promise.all([send("parallel.pdf"), send("parallel.pdf")])
|
||||
assert.deepEqual(concurrent.map(res => res.statusCode).sort(), [201, 204])
|
||||
assert.equal((await send("scan.pdf", samplePdf)).statusCode, 409)
|
||||
assert.equal((await send("scan.pdf", pdf, { ...basic(first.username, first.password), "if-none-match": "*" })).statusCode, 412)
|
||||
assert.equal(stored.size, 2)
|
||||
})
|
||||
await t.test("credentials bind writes, metadata and lists to a tenant", async () => {
|
||||
const otherHeaders = basic(second.username, second.password)
|
||||
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: otherHeaders })).statusCode, 404)
|
||||
assert.equal((await send("scan.pdf", pdf, otherHeaders)).statusCode, 201)
|
||||
const otherList = await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-tenant": "2" } })
|
||||
assert.equal(otherList.json().total, 1)
|
||||
const own = await app.inject({ method: "GET", url: "/api/document-inbox?limit=1&offset=1" })
|
||||
assert.equal(own.json().total, 2)
|
||||
assert.equal(own.json().documents.length, 1)
|
||||
const filtered = await app.inject({ method: "GET", url: "/api/document-inbox?search=parallel" })
|
||||
assert.equal(filtered.json().total, 1)
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox?limit=-1" })).statusCode, 400)
|
||||
})
|
||||
await t.test("storage and database failures leave no incomplete file or inbox entry", async () => {
|
||||
const before = stored.size
|
||||
storageFailure = true
|
||||
assert.equal((await send("failed.pdf")).statusCode, 500)
|
||||
storageFailure = false
|
||||
assert.equal(stored.size, before)
|
||||
assert.equal((await send("reject.pdf")).statusCode, 500)
|
||||
assert.equal(stored.size, before)
|
||||
assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('failed.pdf', 'reject.pdf')")).rows[0].count, "0")
|
||||
assert.equal((await send("failed.pdf")).statusCode, 201)
|
||||
})
|
||||
const targetFolder = randomUUID()
|
||||
const targetType = randomUUID()
|
||||
const alternativeType = randomUUID()
|
||||
const foreignFolder = randomUUID()
|
||||
const foreignType = randomUUID()
|
||||
const archivedFolder = randomUUID()
|
||||
const archivedType = randomUUID()
|
||||
await pool.query(`INSERT INTO filetags (id, tenant, name, archived) VALUES
|
||||
($1, 1, 'Rechnung', false), ($2, 1, 'Sonstiges', false), ($3, 2, 'Fremder Typ', false), ($4, 1, 'Archiviert', true)`,
|
||||
[targetType, alternativeType, foreignType, archivedType])
|
||||
await pool.query(`INSERT INTO folders (id, tenant, name, "standardFiletype", "standardFiletypeIsOptional", archived) VALUES
|
||||
($1, 1, 'Rechnungen', $2, false, false), ($3, 2, 'Fremder Ordner', null, true, false), ($4, 1, 'Archiviert', null, true, true)`,
|
||||
[targetFolder, targetType, foreignFolder, archivedFolder])
|
||||
const fileInto = (id: string, folder: string | null = targetFolder, type: string = targetType, headers = {}) =>
|
||||
app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder, type }, headers })
|
||||
await t.test("filing options and destinations exclude other tenants and archived entries", async () => {
|
||||
const options = await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })
|
||||
assert.equal(options.statusCode, 200)
|
||||
assert.deepEqual(options.json().folders.map((item: any) => item.id), [targetFolder])
|
||||
assert.equal(options.json().folders[0].standardFiletypeIsOptional, false)
|
||||
assert.deepEqual(options.json().types.map((item: any) => item.id).sort(), [targetType, alternativeType].sort())
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
for (const [folder, type] of [[foreignFolder, targetType], [archivedFolder, targetType], [targetFolder, foreignType],
|
||||
[null, archivedType], [targetFolder, alternativeType], [randomUUID(), targetType], [null, randomUUID()]]) {
|
||||
assert.equal((await fileInto(id, folder, type!)).statusCode, 400)
|
||||
}
|
||||
assert.equal((await fileInto(id, null, targetType, { "x-test-tenant": "2" })).statusCode, 404)
|
||||
assert.equal((await fileInto(randomUUID())).statusCode, 404)
|
||||
for (const payload of [{ folder: null }, { type: targetType }, { folder: "invalid", type: targetType }, { folder: null, type: null }]) {
|
||||
assert.equal((await app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload })).statusCode, 400)
|
||||
}
|
||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||
assert.equal((await fileInto(id)).statusCode, 423)
|
||||
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
||||
assert.equal((await fileInto(id)).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })).statusCode, 403)
|
||||
await pool.query("DELETE FROM auth_profiles")
|
||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1)
|
||||
const row = (await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0]
|
||||
assert.deepEqual(row, { folder: null, type: null })
|
||||
})
|
||||
await t.test("filing updates the original file once, keeps its content and cannot be undone by scanner retries", async () => {
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||
const countBefore = stored.size
|
||||
const responses = await Promise.all([fileInto(id, targetFolder, targetType, { "x-test-admin": "false" }), fileInto(id)])
|
||||
assert.deepEqual(responses.map(response => response.statusCode).sort(), [200, 409])
|
||||
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||
assert.equal(row.folder, targetFolder)
|
||||
assert.equal(row.type, targetType)
|
||||
assert.equal(row.updated_by, userId)
|
||||
assert.ok(row.updated_at)
|
||||
assert.equal(row.path, before.path)
|
||||
assert.equal(row.name, before.name)
|
||||
assert.deepEqual(stored.get(row.path), pdf)
|
||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0)
|
||||
assert.equal((await send("scan.pdf")).statusCode, 204)
|
||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0)
|
||||
assert.equal((await service.list(2, 50, 0, "scan.pdf")).total, 1)
|
||||
assert.equal(stored.size, countBefore)
|
||||
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200)
|
||||
const rootId = (await service.list(1, 50, 0, "parallel.pdf")).documents[0].id
|
||||
assert.equal((await fileInto(rootId, null, alternativeType)).statusCode, 200)
|
||||
assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [rootId])).rows[0], { folder: null, type: alternativeType })
|
||||
})
|
||||
await t.test("a failure marking the receipt rolls back file classification", async () => {
|
||||
const id = (await service.list(1, 50, 0, "failed.pdf")).documents[0].id
|
||||
await pool.query("ALTER TABLE scanner_inbox_documents ADD CONSTRAINT simulate_filing_failure CHECK (remote_filename <> 'failed.pdf' OR filed_at IS NULL)")
|
||||
assert.equal((await fileInto(id)).statusCode, 500)
|
||||
assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0], { folder: null, type: null })
|
||||
assert.equal((await service.list(1, 50, 0, "failed.pdf")).total, 1)
|
||||
await pool.query("ALTER TABLE scanner_inbox_documents DROP CONSTRAINT simulate_filing_failure")
|
||||
await pool.query("UPDATE files SET archived=true WHERE id=$1", [id])
|
||||
assert.equal((await fileInto(id)).statusCode, 404)
|
||||
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
|
||||
})
|
||||
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
|
||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||
assert.equal((await send("maintenance.pdf")).statusCode, 423)
|
||||
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-tenant": "0" } })).statusCode, 400)
|
||||
for (const [method, url] of [["GET", "/api/document-inbox/scanner"], ["POST", "/api/document-inbox/scanner/credentials"], ["DELETE", "/api/document-inbox/scanner"]] as const) {
|
||||
assert.equal((await app.inject({ method, url, headers: { "x-test-admin": "false" } })).statusCode, 403)
|
||||
}
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-admin": "false" } })).statusCode, 200)
|
||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox" })).statusCode, 403)
|
||||
await pool.query("DELETE FROM auth_profiles")
|
||||
})
|
||||
await t.test("revocation and rotation reject old credentials without removing documents", async () => {
|
||||
const oldIdentity = await service.authenticate(first.username, first.password)
|
||||
assert.equal((await app.inject({ method: "DELETE", url: "/api/document-inbox/scanner" })).statusCode, 204)
|
||||
assert.equal((await send("disabled.pdf")).statusCode, 401)
|
||||
const rotated = await app.inject({ method: "POST", url: "/api/document-inbox/scanner/credentials" })
|
||||
assert.equal(rotated.statusCode, 200)
|
||||
assert.equal(rotated.headers["cache-control"], "no-store")
|
||||
assert.equal(rotated.json().connection.username, first.username)
|
||||
assert.equal((await send("old.pdf")).statusCode, 401)
|
||||
await assert.rejects(service.accept(oldIdentity!, "late.pdf", pdf), { statusCode: 401 })
|
||||
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
|
||||
assert.equal((await service.list(1, 50, 0, "")).total, 2)
|
||||
})
|
||||
})
|
||||
160
backend/tests/selfhostUpdates.test.ts
Normal file
160
backend/tests/selfhostUpdates.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import Fastify from "fastify";
|
||||
import routes from "../src/routes/selfhostUpdates";
|
||||
import { createUpdaterClient } from "../src/modules/selfhost-updater";
|
||||
import authPlugin from "../src/plugins/auth";
|
||||
import { secrets } from "../src/utils/secrets";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const id = "12345678-1234-4123-a123-123456789abc";
|
||||
const headers = { authorization: "Bearer verified-jwt-token" };
|
||||
async function fixture(t: any, admin = true, authenticated = true) {
|
||||
const app = Fastify();
|
||||
const calls: unknown[] = [];
|
||||
let currentAdmin = admin;
|
||||
// Mirror the parent auth plugin's preHandler order, not an earlier onRequest hook.
|
||||
app.addHook("preHandler", async req => { if (authenticated) req.user = { user_id: id, is_admin: true } as any; });
|
||||
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: currentAdmin }] }) }) }) });
|
||||
await app.register(routes, { client: async (...args) => {
|
||||
calls.push(args); return { status: 202, body: { status: "queued", requestId: id } };
|
||||
} });
|
||||
t.after(() => app.close());
|
||||
return { app, calls, revoke: () => { currentAdmin = false; } };
|
||||
}
|
||||
test("all update endpoints require a current global admin, including after revocation", async t => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202);
|
||||
f.revoke();
|
||||
for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`],
|
||||
["GET", `/admin/updates/requests/${id}/logs`],
|
||||
["POST", "/admin/updates/check"], ["POST", "/admin/updates/apply"]] as const) {
|
||||
assert.equal((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403);
|
||||
}
|
||||
assert.equal(f.calls.length, 1);
|
||||
});
|
||||
test("admin log access uses the fixed request route and does not cache responses", async t => {
|
||||
const f = await fixture(t);
|
||||
const response = await f.app.inject({ url: `/admin/updates/requests/${id}/logs` });
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.equal(response.headers["cache-control"], "no-store");
|
||||
assert.deepEqual(f.calls[0], ["GET", `/v1/requests/${id}/logs`, undefined]);
|
||||
assert.equal((await f.app.inject({ url: "/admin/updates/requests/bad.id/logs" })).statusCode, 400);
|
||||
const anonymous = await fixture(t, true, false);
|
||||
assert.equal((await anonymous.app.inject({ url: `/admin/updates/requests/${id}/logs` })).statusCode, 401);
|
||||
});
|
||||
test("unauthenticated and cookie-only requests cannot start jobs", async t => {
|
||||
const anonymous = await fixture(t, true, false);
|
||||
assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401);
|
||||
const f = await fixture(t);
|
||||
for (const authorization of [undefined, "Bearer short", "bearer long-but-not-used-by-auth"]) {
|
||||
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check",
|
||||
headers: authorization ? { authorization } : {}, payload: { requestId: id } })).statusCode, 403);
|
||||
}
|
||||
assert.equal(f.calls.length, 0);
|
||||
});
|
||||
test("fixed routes reject injected options, malformed IDs and unreviewed apply requests", async t => {
|
||||
const f = await fixture(t);
|
||||
for (const payload of [{ requestId: id, directory: "/root" }, { requestId: id, version: "latest" },
|
||||
{ requestId: "../../etc/passwd" }, { requestId: id, acceptComposeReplacement: true }]) {
|
||||
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", headers, payload })).statusCode, 400);
|
||||
}
|
||||
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
|
||||
payload: { requestId: id, version: "3.2.0" } })).statusCode, 400);
|
||||
assert.equal(f.calls.length, 0);
|
||||
const response = await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
|
||||
payload: { requestId: id, version: "3.2.0", checkRequestId: "checked-request" } });
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.equal(response.headers["cache-control"], "no-store");
|
||||
assert.deepEqual(f.calls[0], ["POST", "/v1/apply", { requestId: id, version: "3.2.0", checkRequestId: "checked-request" }]);
|
||||
});
|
||||
|
||||
test("real authentication requires a valid header JWT even when a valid cookie exists", async t => {
|
||||
const app = Fastify();
|
||||
t.after(() => app.close());
|
||||
const previous = secrets.JWT_SECRET;
|
||||
secrets.JWT_SECRET = "update-route-test-secret";
|
||||
t.after(() => { secrets.JWT_SECRET = previous; });
|
||||
const token = jwt.sign({ user_id: id, tenant_id: null, is_admin: true }, secrets.JWT_SECRET, { expiresIn: "5m" });
|
||||
app.addHook("onRequest", async req => { req.cookies = { token }; });
|
||||
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: true }] }) }) }) });
|
||||
await app.register(authPlugin);
|
||||
let forwarded = 0;
|
||||
await app.register(routes, { client: async () => { forwarded++; return { status: 202, body: { status: "queued" } }; } });
|
||||
for (const authorization of ["Bearer x", "bearer ignored-token-with-valid-cookie", "Bearer invalid-long-jwt-token"]) {
|
||||
const result = await app.inject({ method: "POST", url: "/admin/updates/check", headers: { authorization }, payload: { requestId: id } });
|
||||
assert.ok([401, 403].includes(result.statusCode));
|
||||
}
|
||||
assert.equal(forwarded, 0);
|
||||
assert.equal((await app.inject({ method: "POST", url: "/admin/updates/check",
|
||||
headers: { authorization: `Bearer ${token}` }, payload: { requestId: id } })).statusCode, 202);
|
||||
assert.equal(forwarded, 1);
|
||||
});
|
||||
|
||||
async function socketFixture(t: any, handler: Parameters<typeof createServer>[0]) {
|
||||
const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`);
|
||||
const socket = `${directory}/control.sock`;
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>(resolve => server.listen(socket, resolve));
|
||||
t.after(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); await rm(directory, { recursive: true, force: true }); });
|
||||
return createUpdaterClient(socket, 150);
|
||||
}
|
||||
test("Unix client strips private fields and never forwards credentials or raw error messages", async t => {
|
||||
const client = await socketFixture(t, (req, res) => {
|
||||
assert.equal(req.headers.authorization, undefined);
|
||||
assert.equal(req.headers.cookie, undefined);
|
||||
if (req.url === "/v1/status") res.end(JSON.stringify({ apiVersion: 1, acceptingRequests: true,
|
||||
engine: { status: "idle", job: "/secret/path", error: "password" }, installed: {}, secret: "token" }));
|
||||
else { res.writeHead(409); res.end(JSON.stringify({ error: { code: "PLAN_CHANGED", message: "password" } })); }
|
||||
});
|
||||
const response = await client("GET", "/v1/status");
|
||||
assert.deepEqual(response.body, { apiVersion: 1, acceptingRequests: true, engine: { status: "idle" }, installed: {} });
|
||||
await assert.rejects(client("POST", "/v1/apply", {}), { code: "PLAN_CHANGED", status: 409,
|
||||
message: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen." });
|
||||
await assert.rejects(client("GET", "http://example.com"), { code: "INVALID_REQUEST" });
|
||||
});
|
||||
test("Unix client bounds response size, deadline, and protocol shape", async t => {
|
||||
for (const scenario of ["oversize", "timeout", "invalid"] as const) {
|
||||
await t.test(scenario, async t => {
|
||||
const client = await socketFixture(t, (_req, res) => {
|
||||
if (scenario === "oversize") res.end("x".repeat(70000));
|
||||
else if (scenario === "invalid") res.end('{"apiVersion":2}');
|
||||
});
|
||||
await assert.rejects(client("GET", "/v1/status"), (error: any) =>
|
||||
["UPDATER_UNAVAILABLE", "UPDATER_PROTOCOL"].includes(error.code) && !error.message.includes("ENOENT"));
|
||||
});
|
||||
}
|
||||
});
|
||||
test("provider status and plan cross the socket boundary without private storage configuration", async t => {
|
||||
const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify({
|
||||
apiVersion: 1, acceptingRequests: true, engine: { status: "recovered", phase: "resolved" }, installed: {},
|
||||
storage: { provider: "seaweedfs", migrationPending: false, service: { secret: "hidden" } },
|
||||
request: { status: "checked", plan: { updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
|
||||
storageProvider: "seaweedfs", storageImageRetained: true, storageBackupPaths: ["/private/path"] } },
|
||||
})));
|
||||
const { body } = await client("GET", "/v1/status") as { body: any };
|
||||
assert.deepEqual(body.storage, { provider: "seaweedfs", migrationPending: false });
|
||||
assert.equal(body.request.plan.storageProvider, "seaweedfs");
|
||||
assert.equal(body.request.plan.storageImageRetained, true);
|
||||
assert.ok(!JSON.stringify(body).includes("hidden"));
|
||||
assert.ok(!JSON.stringify(body).includes("private/path"));
|
||||
});
|
||||
test("Unix client validates bounded log responses and their request association", async t => {
|
||||
let payload: any = { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true,
|
||||
path: "/private/commands.log" }], private: "omitted" };
|
||||
const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify(payload)));
|
||||
const response = await client("GET", `/v1/requests/${id}/logs`);
|
||||
assert.deepEqual(response.body, { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true }] });
|
||||
for (const invalid of [
|
||||
{ ...payload, requestId: "different-request" },
|
||||
{ ...payload, logs: [{ source: "env", content: "secret", truncated: false }] },
|
||||
{ ...payload, logs: [{ source: "commands", content: "x".repeat(6001), truncated: false }] },
|
||||
]) {
|
||||
payload = invalid;
|
||||
await assert.rejects(client("GET", `/v1/requests/${id}/logs`), { code: "UPDATER_PROTOCOL" });
|
||||
}
|
||||
await assert.rejects(client("GET", `/v1/requests/${id}/logs?path=/etc/passwd`), { code: "INVALID_REQUEST" });
|
||||
});
|
||||
29
backend/tests/systemStatusStorage.test.ts
Normal file
29
backend/tests/systemStatusStorage.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { HeadBucketCommand } from "@aws-sdk/client-s3";
|
||||
import { checkFileStorage } from "../src/modules/system-status.service";
|
||||
|
||||
test("file storage health uses an authenticated S3 bucket request for every provider", async () => {
|
||||
const client = { send: async (command: any, options: any) => {
|
||||
assert.ok(command instanceof HeadBucketCommand);
|
||||
assert.deepEqual(command.input, { Bucket: "fedeo" });
|
||||
assert.ok(options.abortSignal instanceof AbortSignal);
|
||||
} };
|
||||
assert.equal((await checkFileStorage(client, "fedeo")).ok, true);
|
||||
});
|
||||
|
||||
test("unconfigured or denied S3 access is unhealthy and does not disclose SDK details", async () => {
|
||||
assert.equal((await checkFileStorage(null, "fedeo")).ok, false);
|
||||
const result = await checkFileStorage({ send: async () => { throw new Error("secret@private-host"); } }, "fedeo");
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(!JSON.stringify(result).includes("private-host"));
|
||||
});
|
||||
|
||||
test("S3 health aborts a hung request at its deadline", async () => {
|
||||
const client = { send: (_command: any, options: any) => new Promise((_resolve, reject) => {
|
||||
options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
||||
}) };
|
||||
const result = await checkFileStorage(client, "fedeo", 10);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /Zeitlimit/);
|
||||
});
|
||||
@@ -86,6 +86,9 @@ services:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
|
||||
container_name: fedeo-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Mount the directory so a service restart can replace the Unix socket.
|
||||
- /run/fedeo-updater:/run/fedeo-updater:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -7,3 +7,6 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
- [Bedienung](./bedienung/README.md)
|
||||
- [Fach- und Technikkonzept für das Lagersystem](./lagersystem-konzept.md)
|
||||
- [Zentraler Push-Server für Selfhost-Instanzen](./zentraler-push-server.md)
|
||||
- [Konzept zum Austausch von MinIO](./minio-austausch-konzept.md)
|
||||
- [MinIO-Umstellung: ausführbarer Pilot](./minio-umstellung-pilot.md)
|
||||
- [Konzept für föderierte Chats und externe Projektteilnehmer](./chat-foederation-konzept.md)
|
||||
|
||||
279
docs/chat-foederation-konzept.md
Normal file
279
docs/chat-foederation-konzept.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Konzept: Föderierte Chats und externe Projektteilnehmer
|
||||
|
||||
Stand: 26. September 2026. Status: Architekturvorschlag auf Basis des FEDEO-Codes bei Commit `df48c976`. Dieses Dokument beschreibt ein Zielbild; Föderation, Gastzugang und die genannten APIs sind noch nicht implementiert. Es wurden keine Einladungen verschickt und keine laufenden Instanzen verändert.
|
||||
|
||||
## 1. Empfehlung
|
||||
|
||||
FEDEO erhält eine gemeinsame Berechtigungsschicht für die Zusammenarbeit über Mandantengrenzen hinweg und einen darauf aufbauenden Transport zwischen FEDEO-Instanzen. Benutzer bleiben bei ihrer eigenen Instanz angemeldet. Ein Chat beziehungsweise Projekt hat genau einen verantwortlichen Mandanten und eine führende Instanz, die Mitgliedschaften, Freigaben und Änderungen verbindlich entscheidet.
|
||||
|
||||
Die gleiche Funktion soll drei Fälle abdecken:
|
||||
|
||||
| Fall | Verhalten |
|
||||
| --- | --- |
|
||||
| Zwei Mandanten derselben Instanz | Explizite Einladung und dieselben objektbezogenen Rechte; Aufruf intern ohne Netzwerktransport |
|
||||
| Mandanten verschiedener FEDEO-Instanzen | Gleiche Oberfläche; signierter Austausch zwischen den Backends |
|
||||
| Person ohne FEDEO-Konto | Eingeplanter Gastzugang beim Projekteigentümer, beschränkt auf ausdrücklich eingeladene Projekt-Unterräume |
|
||||
|
||||
Eine Einladung macht einen externen Benutzer **nicht zum regulären Benutzer des einladenden Mandanten**. Chatteilnahme und Zugriff auf Projektdaten sind getrennte Freigaben. Eine freigegebene Partnerinstanz erhält ebenfalls keine pauschalen Rechte auf einen Mandanten.
|
||||
|
||||
Gäste ohne eigenes FEDEO-Konto sind Teil des Zielumfangs. Ihre Zusammenarbeit findet in abgegrenzten Unterräumen eines Projekts statt. Dieselben Unterräume können auch Personen anderer FEDEO-Mandanten oder -Instanzen aufnehmen; die Art der Anmeldung verändert die Sichtbarkeitsgrenze nicht.
|
||||
|
||||
Der Vorschlag ist eine Föderation zwischen FEDEO-Instanzen mit zentraler Zuständigkeit je Ressource. Er bietet keine automatische Interoperabilität mit Matrix, Teams oder anderen Messengern und keine gleichberechtigten Schreibkopien auf allen Instanzen.
|
||||
|
||||
## 2. Nutzerabläufe
|
||||
|
||||
### Gemeinsamen Chat eröffnen
|
||||
|
||||
1. Benutzer wählt `Kommunikation → Neuer Chat → Externe Teilnehmer`.
|
||||
2. Er wählt einen bekannten Partnerkontakt oder fügt eine FEDEO-Kontaktadresse hinzu. Vor dem Absenden werden Person, Mandant und Instanz angezeigt.
|
||||
3. Er bestimmt ausdrücklich die internen und externen Teilnehmer. Die Auswahl einer Firma fügt nicht automatisch deren gesamte Belegschaft hinzu.
|
||||
4. Empfänger sieht eine Anfrage in seiner eigenen FEDEO-Instanz und nimmt sie im eingeladenen Mandantenkontext an.
|
||||
5. Nach Bestätigung durch die führende Instanz erscheint derselbe gemeinsame Chat auf beiden Seiten, mit Kennzeichnung des verantwortlichen Mandanten und der externen Teilnehmer.
|
||||
|
||||
Ein Partnerkontakt ist lediglich eine wiederverwendbare Adresse. Er berechtigt weder zum Zugriff auf andere Chats noch zur Suche im vollständigen Benutzerverzeichnis der Partnerfirma. Unbekannte Absender landen in einer begrenzten Anfrageansicht; Benutzer und Mandanten können Kontakte blockieren.
|
||||
|
||||
### Externe Person zu einem Projekt einladen
|
||||
|
||||
1. Projektverantwortlicher öffnet `Projekt → Zusammenarbeit → Unterraum erstellen` oder wählt einen bestehenden Unterraum.
|
||||
2. Er benennt den Unterraum, beispielsweise „Abstimmung Gerüstbau“, und wählt ausdrücklich die internen Teilnehmer.
|
||||
3. Über `Person einladen` wählt er einen FEDEO-Kontakt oder einen Gast ohne FEDEO-Konto, Rolle, gegebenenfalls Ablaufdatum und die konkret freigegebenen Inhalte.
|
||||
4. Jeder Unterraum hat einen eigenen Partnerchat. Optional werden eine begrenzte Projektübersicht und später einzelne Dokumente innerhalb dieses Unterraums freigegeben. Die Vorschau zeigt exakt, was der Empfänger sehen wird.
|
||||
5. FEDEO-Benutzer finden den Unterraum unter `Projekte → Mit mir geteilt`; Gäste öffnen eine reduzierte Gastoberfläche beim Eigentümer. Beide sehen nur den jeweiligen Unterraum und dessen freigegebene Inhalte.
|
||||
6. Verantwortliche können Teilnahme und Freigaben später einschränken, befristen oder entziehen. Unterraum und freigegebene Projektübersicht zeigen deren aktuellen Status.
|
||||
|
||||
Beispiel: Ein Elektrofachbetrieb lädt einen Mitarbeiter des Gerüstbauers in das Projekt „Schulgebäude“ ein. Dieser erhält den Partnerchat, den freigegebenen Ausführungstermin und einen Lageplan. Angebote, Einkaufspreise, interne Notizen und andere Projekte bleiben verborgen.
|
||||
|
||||
### Unterräume als eigenständige Zugriffsgrenze
|
||||
|
||||
```text
|
||||
Projekt Schulgebäude
|
||||
├── Interner Projektchat
|
||||
├── Unterraum: Gerüstbau → ausgewählte Mitarbeiter + Gerüstbauer
|
||||
├── Unterraum: Bauherr → ausgewählte Mitarbeiter + Bauherr
|
||||
└── Unterraum: Abnahme → ausdrücklich eingeladene Beteiligte
|
||||
```
|
||||
|
||||
Jeder Unterraum besitzt eine eigene ID, Teilnehmerliste, Historie und Freigaben. Ein Gast im Raum „Gerüstbau“ sieht weder den internen Projektchat noch Namen, Teilnehmer, Nachrichten oder Zähler der anderen Unterräume. Auch das übergeordnete Projekt ist nicht automatisch lesbar. Die Gastansicht zeigt einen ausdrücklich freigegebenen Anzeigenamen ohne verlinkten internen Projektpfad.
|
||||
|
||||
Die Zuordnung zum Projekt dient der internen Organisation und vererbt keine Leserechte nach oben, unten oder zu benachbarten Räumen. Interne Mitarbeiter werden ebenfalls ausdrücklich als Teilnehmer aufgenommen; die Berechtigung zur Verwaltung eines Unterraums ersetzt keine Chatmitgliedschaft. Für die erste Ausbaustufe reicht eine Ebene unter dem Projekt; beliebige Verschachtelung ist nicht vorgesehen.
|
||||
|
||||
Nachrichten werden nicht automatisch zwischen internem Chat und Unterräumen gespiegelt. Eine spätere Funktion zum Teilen einzelner Inhalte muss Zielraum und freigegebenen Inhalt ausdrücklich bestätigen lassen. Mehrere Firmen dürfen denselben Unterraum nutzen, wenn sie bewusst gemeinsam eingeladen wurden; dann ist dessen Teilnehmerliste für alle dort Berechtigten sichtbar.
|
||||
|
||||
## 3. Ausgangslage im Code
|
||||
|
||||
| Bestand | Bedeutung für den Entwurf |
|
||||
| --- | --- |
|
||||
| [`communication_rooms`](../backend/db/schema/communication_rooms.ts) | Räume gehören bisher genau einem lokalen Mandanten; `key` ist nur innerhalb dieses Mandanten eindeutig. |
|
||||
| [`communication_room_members`](../backend/db/schema/communication_room_members.ts) | Mitglieder referenzieren lokale `auth_users`; es fehlen Herkunftsmandant, externe Identität, Rolle und Widerrufszustand. |
|
||||
| [`communication_messages`](../backend/db/schema/communication_messages.ts), [`communication_room_reads`](../backend/db/schema/communication_room_reads.ts) | Autoren und Lesestatus sind lokale Benutzer; numerische Nachrichten-IDs taugen nicht als instanzübergreifende Identität. |
|
||||
| [`projects`](../backend/db/schema/projects.ts) | `profiles` enthält lokale Mitarbeiterprofile; externe Zusammenarbeit braucht eigene Freigaben statt fremder Mitarbeiterprofile. |
|
||||
| [`auth.ts`](../backend/src/plugins/auth.ts), [`resources/main.ts`](../backend/src/routes/resources/main.ts) | Anmeldung und Ressourcenabfragen arbeiten mit dem aktiven lokalen Mandanten. Externe Rechte dürfen diese allgemeine Mandantengrenze nicht aufweichen. |
|
||||
| [`communication.ts`](../backend/src/routes/communication.ts) | Raumzugriff ist an den aktiven Mandanten gebunden; Benachrichtigungsempfänger stammen aus dessen Benutzerverzeichnis. Beides benötigt eine explizite Zusammenarbeitsschicht. |
|
||||
|
||||
Vor einer Freigabe sind zwei vorhandene Verhaltensweisen zu korrigieren: Das Anlegen eines Raums mit bestehendem Schlüssel darf keine zusätzlichen Mitglieder in einen fremden Raum aufnehmen. Außerdem darf das Bereitstellen eines Projektchats nicht allein durch Kenntnis einer Projekt-ID zur Selbstaufnahme führen. Einladungen, Beitritt und das Lesen von Projektdaten benötigen jeweils eine objektbezogene Prüfung.
|
||||
|
||||
## 4. Zuständigkeit und Datenfluss
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UA[Benutzer A] --> A[FEDEO A: Anmeldung und Berechtigungen]
|
||||
UB[Benutzer B] --> B[FEDEO B: Anmeldung und Berechtigungen]
|
||||
A --> O[(Projekt und Chat bei Eigentümer A)]
|
||||
B --> P[(Freigegebene Chatkopie für Mandant B)]
|
||||
B -->|Signierte Befehle und Projektabfragen| A
|
||||
A -->|Bestätigte Ereignisse und freigegebene Ansichten| B
|
||||
```
|
||||
|
||||
- Ein gemeinsam genutzter Chat hat einen Eigentümer und eine feste führende Instanz. Projektchats werden beim Projekteigentümer geführt. Ein freier Partnerchat wird beim Ersteller geführt; das wird vor Annahme angezeigt.
|
||||
- Die führende Instanz prüft jeden Schreibbefehl und vergibt dessen verbindliche Reihenfolge. Partnerinstanzen dürfen bestätigte Chatereignisse für ihre ausdrücklich berechtigten lokalen Teilnehmer speichern.
|
||||
- Projektdaten bleiben beim Eigentümer. Die Partnerinstanz fragt eine eingeschränkte Ansicht an; es gibt keine Replikation der vollständigen `projects`-, Kunden- oder Belegtabellen.
|
||||
- Jede Partnerinstanz übernimmt Anmeldung, lokale Benutzeroberfläche, Lesestatus und Benachrichtigungen ihrer eigenen Benutzer. Zugangstoken der Benutzer verlassen die eigene Instanz nicht.
|
||||
- Mehrere Mandanten auf derselben Instanz durchlaufen denselben Berechtigungsdienst. Die Optimierung zum internen Aufruf darf keine zusätzlichen Rechte schaffen.
|
||||
|
||||
Die Entscheidung für eine führende Instanz vereinfacht Reihenfolge, Rollenwechsel und Widerruf. Sie hat einen bewussten Nachteil: Ist diese Instanz ausgefallen, können neue Nachrichten nur als ausstehend vorgemerkt werden; neue Projektabfragen und Freigaben sind nicht möglich. Bereits rechtmäßig empfangene Chatnachrichten können entsprechend der Aufbewahrungsregel weiter angezeigt werden. Es gibt in Version 1 keine automatische Übernahme durch eine andere Instanz.
|
||||
|
||||
## 5. Identitäten und Vertrauen
|
||||
|
||||
Eine Person handelt immer in einem konkreten Organisationskontext. Der gleiche lokale Benutzer in zwei Mandanten hat deshalb zwei getrennte Teilnehmeridentitäten.
|
||||
|
||||
| Identität | Vorgabe |
|
||||
| --- | --- |
|
||||
| Instanz | Dauerhafte öffentliche UUID, kanonische HTTPS-Adresse und eigene Signaturschlüssel |
|
||||
| Mandant | Dauerhafte öffentliche UUID plus Instanzidentität; lokale numerische ID bleibt intern |
|
||||
| Teilnehmer / Principal | FEDEO-Benutzer: Kombination aus Instanz-, Mandanten- und öffentlicher Benutzer-ID; Gast: ausstellende Eigentümerinstanz, Eigentümermandant und eigene stabile Gast-ID, mit Typ `guest` |
|
||||
| Ressource | Dauerhafte öffentliche UUID plus führende Instanz; z. B. Chat oder freigegebene Projektansicht |
|
||||
| Nachricht / Ereignis | Globale UUID; zusätzlich fortlaufende Sequenz innerhalb des führenden Chats |
|
||||
|
||||
Als Kontaktadresse kann eine kanonische HTTPS-Adresse dienen, beispielsweise `https://partner.example/federation/actors/<opaque-id>`. Anzeigenamen und E-Mail-Adressen sind veränderbare Attribute, keine Identitätsschlüssel. Gleichlautende E-Mail-Adressen auf zwei Instanzen führen nicht zu einer automatischen Zusammenführung. Öffentlich abrufbare Adressen dürfen keine offenen Mitarbeiterlisten ergeben.
|
||||
|
||||
Eine eigene Discovery-Datei `/.well-known/fedeo-federation` veröffentlicht Protokollversion, Instanz-ID, HTTPS-Endpunkt und öffentliche Schlüssel. Dieser Pfad ist ein FEDEO-eigener Vorschlag. Beim Erstkontakt werden HTTPS-Herkunft, Kontrolle des Endpunkts durch eine signierte Challenge und die Freigabe des Partners geprüft. Discovery allein verleiht kein Vertrauen.
|
||||
|
||||
Für Version 1 gilt eine Partner-Allowlist je Mandant innerhalb der vom Betreiber erlaubten Instanzen. Empfänger nehmen Anfragen zusätzlich selbst an. Eine bestätigte Transportverbindung ersetzt keine Raum- oder Projektfreigabe. Eine Partnerinstanz darf ausschließlich Identitäten ihrer eigenen Herkunft bestätigen; jeder Zugriff wird auf eine konkret eingeladene Kombination aus Instanz, Mandant und Benutzer begrenzt.
|
||||
|
||||
Das Vertrauensmodell setzt voraus, dass die Heimatinstanz ihre Benutzer korrekt authentifiziert. Ein kompromittierter Partneradministrator könnte im Namen dieser Benutzer handeln. Instanzsignaturen belegen den Absender-Server, nicht die persönliche Unterschrift des Menschen. Deshalb können Betreiber und Mandanten Partner sofort sperren; für höhere Anforderungen wäre später eine zusätzliche benutzergebundene Signatur nötig.
|
||||
|
||||
Schlüssel werden getrennt von JWT-, Push- und sonstigen API-Secrets verwaltet. Rotation erfolgt mit überlappend veröffentlichten Schlüsseln und Nachweis durch den bereits vertrauten Schlüssel; Verlust oder Kompromittierung verlangt eine explizite erneute Vertrauensprüfung. Domainwechsel benötigen einen dokumentierten Identitätsumzug. Ein Datenbankklon für Test oder Restore darf nicht parallel als dieselbe Instanz senden; vor Freischaltung sind Instanzidentität, Worker und Schlüssel zu prüfen.
|
||||
|
||||
## 6. Berechtigungen und Sichtbarkeit
|
||||
|
||||
Alle externen Rechte entstehen aus einer vom Eigentümer gespeicherten Freigabe: `resource + principal + role/scopes + status + revision + expires_at`. Bei FEDEO-Benutzern prüfen wir zusätzlich die lokale Mitgliedschaft im Heimatmandanten, dessen Partnerregeln und die Sperrzustände beider Seiten. Bei Gästen werden stattdessen Gastidentität und Gastsitzung beim Eigentümer geprüft; eine reguläre Mandantenmitgliedschaft ist keine Voraussetzung. Rollen der fremden Instanz werden niemals zu lokalen Administratorrechten übersetzt.
|
||||
|
||||
Unterraumzugriff verlangt eine aktive Freigabe auf genau diesen Unterraum. Chat, Projektansicht und Dateien bleiben getrennte Ressourcen; ein angebotenes Rollenpaket kann die benötigten Freigaben explizit bündeln. Für eine freigegebene Datei oder Ansicht müssen sowohl die Unterraumteilnahme als auch die konkrete Inhaltsfreigabe gültig sein. Das gilt auch bei direkten URLs, Suche, Benachrichtigungen und offenen Echtzeitverbindungen.
|
||||
|
||||
| Rolle | Erlaubte Fähigkeiten | Ausdrücklich nicht enthalten |
|
||||
| --- | --- | --- |
|
||||
| Chatteilnehmer | Den freigegebenen Chat ab Beitritt lesen, Nachrichten senden, eigene Lesemarke setzen | Projektdaten, andere Räume, Teilnehmer einladen |
|
||||
| Projektleser | Freigegebene Projektübersicht und einzeln freigegebene Dateien lesen | Vollständiges Projektobjekt, interne Notizen, Kundenakte, Belege |
|
||||
| Projektmitarbeiter | Projektleser; später zusätzlich ausdrücklich zugewiesene Aufgaben oder freigegebene Rückmeldungen bearbeiten | Allgemeines Projekt-Schreibrecht, Kalkulation, Rollenverwaltung |
|
||||
| Projektverantwortlicher | Auf Eigentümerseite Teilnehmer und Freigaben verwalten, soweit intern autorisiert | Automatische Rechte in Partnerorganisationen |
|
||||
|
||||
Die Rollen sind Vorlagen für konkrete Rechte. Projektlesen erzeugt keine Chatmitgliedschaft und Chatmitgliedschaft keine Projektfreigabe; der Einladungsdialog kann beide bewusst bündeln. Version 1 umfasst Textchat und eine lesbare Projektübersicht. Datei- und Aufgabenrechte werden erst angeboten, wenn die jeweiligen Prüfungen umgesetzt sind.
|
||||
|
||||
Die externe Projektübersicht verwendet eine Positivliste freigegebener Felder, etwa Anzeigename, vereinbarter Status und freigegebene Termine. Weder das komplette Projektobjekt noch eingebettete Beschreibungen, `notes`, `material`, `forms`, Kundenverknüpfungen oder Änderungsprotokolle werden ungeprüft ausgegeben. Eine neue interne Spalte wird dadurch nicht automatisch extern sichtbar. Auch Suche, Vorschaubilder, Zähler, Exporte und Benachrichtigungen müssen denselben Umfang einhalten.
|
||||
|
||||
Standard für externe Teilnehmer ist `history_from_seq = Beitrittssequenz`. Frühere interne Nachrichten werden nicht nachträglich freigegeben. Neue Mitglieder sehen nur die für sie zulässige Historie; erneute Einladung ist eine neue Freigabe mit neuem Beginn. Bei Einführung von Antworten, Reaktionen oder Bearbeitungen dürfen Referenzen keine zuvor unsichtbaren Nachrichten offenlegen. Die Teilnehmerliste eines Partnerchats zeigt die beteiligten Identitäten und Organisationen; darüber werden Einladende und Empfänger informiert.
|
||||
|
||||
Archivierte Unterräume erlauben bestehenden berechtigten Teilnehmern nur noch das Lesen. Gesperrte Unterräume erlauben keinen weiteren Zugriff. Projektarchivierung setzt zugehörige Unterräume auf nur lesbar; eine Projektsperre sperrt deren Zugriffe. Der Entzug einer Unterraumteilnahme blockiert alle darüber freigegebenen Inhalte und beendet betroffene Abonnements, auch wenn ein einzelner Datei-Grant noch gespeichert ist. Einladungen zu anderen Unterräumen bleiben davon unabhängig. Die vollständige Sperre einer Gastidentität beendet dagegen deren sämtliche Zugriffe beim betreffenden Eigentümermandanten.
|
||||
|
||||
## 7. Einladung und Entzug
|
||||
|
||||
Zustände: `pending → accepted | declined | expired | revoked`. `accepted` entsteht ausschließlich durch eine atomare Entscheidung beim Eigentümer; danach können die daraus entstandenen Freigaben unabhängig widerrufen oder eingeschränkt werden. Der Verlauf bleibt im Audit nachvollziehbar.
|
||||
|
||||
1. Eigentümer prüft die Einladungsberechtigung und legt Empfängeridentität, Ressourcen, bei Projektzusammenarbeit den konkreten Unterraum, Rechte, Historienbeginn, Ablauf und eine unveränderliche Angebotsrevision fest.
|
||||
2. Heimatinstanz des FEDEO-Empfängers zeigt die Anfrage nach Anmeldung im betreffenden Mandanten. Beim Annehmen bestätigt sie signiert Identität, Einladung und Angebotsrevision. Ein Gast wird über die eigene Gastanmeldung beim Eigentümer authentifiziert und bestätigt dort denselben Angebotsumfang. Die Zustimmung bezieht sich auf genau diesen Umfang.
|
||||
3. Eigentümer prüft Ablauf, Sperren, fortbestehende Rechte des Einladenden und die Identität erneut. Freigaben und Bestätigungsereignis werden in einer Transaktion erstellt. Wiederholte Annahme liefert dasselbe Ergebnis, keine zweite Mitgliedschaft.
|
||||
4. Beide Oberflächen zeigen erst nach Bestätigung `Aktiv`. Eine Änderung des Angebots vor Annahme erfordert erneute Zustimmung.
|
||||
5. Widerruf setzt den Zustand beim Eigentümer und erhöht die Berechtigungsrevision. Neue Befehle, Downloads und noch nicht versandte Inhaltszustellungen prüfen diesen Zustand erneut. Alte Annahmeereignisse oder Befehle dürfen eine entzogene Freigabe nicht reaktivieren.
|
||||
|
||||
Für Einladungen per Link gilt: einmaliges zufälliges Geheimnis, nur gehasht gespeichert, befristet und beim Einlösen atomar verbraucht. Ein Link allein ist kein Projektschlüssel. Die Annahme benötigt eine authentifizierte Empfängeridentität. Ist diese bei Erstellung noch unbekannt, wird sie nach Anmeldung dem Einladenden zur Bestätigung angezeigt; bis dahin werden keine Projektinhalte freigegeben. Tokens dürfen nicht in Zugriffslogs oder Referrer gelangen; Einlöseseite ohne Drittinhalte und Übergabe des Geheimnisses per POST.
|
||||
|
||||
Das Entfernen eines Mitarbeiters aus seinem Heimatmandanten sperrt dessen lokale Zusammenarbeit sofort; die Heimatinstanz meldet die Deaktivierung an die Eigentümer. Beim nächsten Befehl wird die lokale Mitgliedschaft ohnehin erneut geprüft. Es erfolgt keine stillschweigende Übertragung auf einen Nachfolger oder einen anderen Mandanten derselben Person.
|
||||
|
||||
Ein Entzug stoppt die weitere autorisierte Nutzung beim Eigentümer. Bereits ausgelieferte beziehungsweise bereits unterwegs befindliche Daten lassen sich nicht sicher zurückholen. Eine Partnerinstanz kann vor Eingang des Widerrufs noch ihre bisherige Chatkopie anzeigen; ein fremder Betreiber kann Kopien behalten. Die Software übernimmt Widerrufe, beendet Synchronisation und entfernt lokale Zugriffe nach Richtlinie, verspricht aber kein nachträgliches Löschen sämtlicher Kopien. Neue Projektabfragen und Dateiabrufe müssen den Eigentümer erreichen und bei Ausfall geschlossen scheitern.
|
||||
|
||||
## 8. Transport und Synchronisation
|
||||
|
||||
Browser und Mobile-App von FEDEO-Benutzern sprechen nur mit ihrer eigenen FEDEO-Instanz. Deren Backend übersetzt lokale Aktionen in geprüfte Föderationsbefehle. Gäste verwenden die Gastoberfläche der Eigentümerinstanz. Föderationsrouten erhalten eine eigene Authentifizierung; sie dürfen fremde Signaturen nicht in reguläre lokale Benutzer-JWTs oder einen frei wählbaren `tenant_id` umwandeln.
|
||||
|
||||
Für Transportauthentifizierung wird ein festes FEDEO-Profil auf Basis von [HTTP Message Signatures, RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) und [Content-Digest, RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) vorgeschlagen, mit Ed25519 als festgelegtem Signaturalgorithmus. Es signiert Methode, Ziel-URI, Inhaltstyp und Inhaltsdigest sowie Schlüssel-ID, Erstellungszeit, Ablauf und Nonce. Die konkrete Implementierung muss Referenztestvektoren bestehen; Zeitfenster und abgedeckte Komponenten sind verpflichtend. Der Digest wird gegen die tatsächlichen Body-Bytes geprüft. TLS bleibt obligatorisch. Signaturen sind keine Ende-zu-Ende-Verschlüsselung.
|
||||
|
||||
FEDEO-spezifisch wird jede Nachricht an Zielinstanz, handelnden Principal, Eigentümerressource, Freigabe-ID und Revision gebunden. Der Empfänger prüft diese Bindungen zusätzlich zur Signatur. Ein gültig signierter Auftrag für Mandant B darf weder für Mandant C noch für eine andere Ressource wiederverwendet werden. Unbekannte Ereignistypen oder nicht unterstützte Pflichtmerkmale werden abgelehnt; es gibt keine stille Herabstufung von Sicherheitsregeln.
|
||||
|
||||
### Verarbeitung einer Nachricht
|
||||
|
||||
1. Benutzer sendet mit einer stabilen Client-Auftrags-ID an seine Heimatinstanz. Diese prüft Sitzung, aktiven Mandanten und Freigabe und speichert den Auftrag dauerhaft als `pending`.
|
||||
2. Eigentümer erhält den signierten Befehl. Unter derselben Transaktionssperre wie Mitgliedschaftsänderungen prüft er die aktuell wirksame Freigabe, vergibt Nachrichten-UUID und nächste Raumsequenz und speichert Nachricht, Befehlsresultat und Outbox-Einträge atomar.
|
||||
3. Ein Worker stellt die bestätigten Ereignisse den berechtigten Heimatinstanzen zu. Vor jedem Versand prüft er die aktuellen Empfängerrechte; er verschickt keine ungefilterte Raumhistorie oder vollständigen internen Raumzustände.
|
||||
4. Empfangsinstanz speichert Ereignis und Empfangsbestätigung dauerhaft und dedupliziert anhand der Ereignis-ID. Erst die Eigentümerbestätigung ändert die UI-Anzeige von `Ausstehend` zu `Gesendet`; die serverseitige Annahme beim Empfänger bedeutet noch nicht, dass ein Mensch gelesen hat.
|
||||
5. Die Empfangsinstanz aktualisiert ihre Chatansicht und erzeugt höchstens eine lokale Benachrichtigung pro berechtigtem Empfänger und Ereignis.
|
||||
|
||||
Zustellung erfolgt mindestens einmal; Eindeutigkeit entsteht durch persistente Deduplizierung. Ein wiederholter Befehl mit derselben ID und demselben Inhalt liefert das gespeicherte Ergebnis. Dieselbe ID mit anderem Inhalt wird abgelehnt. Retries erhalten eine neue HTTP-Nonce und Signatur, behalten aber Befehls-/Ereignis-ID. So werden Transport-Replays abgewehrt, ohne legitime Wiederholung nach Timeout zu blockieren.
|
||||
|
||||
Raumsequenzen bestimmen die Reihenfolge, nicht Uhren verschiedener Server. Empfangsbestätigungen und Nachladen verwenden Cursor für den **empfängerbezogenen** Ereignisstrom; ausgelassene, nicht berechtigte Raumereignisse erzeugen keine fälschlich endlosen Lücken. Mitgliedschaftsrevisionen werden monoton verarbeitet. Eine verspätete alte Einladung überschreibt keinen neueren Widerruf. Inhalte werden nur an die im Zustellumschlag ausdrücklich berechtigten Principals des Zielmandanten gebunden; lokale Kopien werden nicht für andere Benutzer desselben Servers freigegeben.
|
||||
|
||||
Outbox und Inbox werden in PostgreSQL gespeichert. Ein Worker mit begrenzter Parallelität, exponentiellem Backoff und Jitter übernimmt Retries; dauerhafte Fehler werden sichtbar abgelegt. Empfangsbestätigung erfolgt erst nach dauerhafter Speicherung. Nach Ausfall werden berechtigte Änderungen per Cursor nachgeholt; bei abgelaufenem Cursor liefert der Eigentümer einen erneut gefilterten Snapshot mit aktueller Freigaberevision. Ein Restore erfordert vor Wiederaufnahme eine Reconciliation, damit alte Backups keine widerrufenen Mitgliedschaften wieder aktivieren.
|
||||
|
||||
Beim Restore startet der Föderationsbetrieb gesperrt. Kann der zuvor bestätigte Ereignis- und Widerrufsstand nicht lückenlos wiederhergestellt werden, bleiben alte externe Freigaben suspendiert und werden durch neue Einladungen ersetzt. Eine mit den Partnern bestätigte neue Wiederherstellungsgeneration entwertet alte ausstehende Befehle; Sequenzen dürfen nicht stillschweigend zurückgesetzt werden. Die Fortsetzung aus einem veralteten Backup ist damit keine automatische Wiederfreigabe.
|
||||
|
||||
## 9. Vorgeschlagenes Datenmodell und API-Grenzen
|
||||
|
||||
Die Namen sind Entwurfsvorgaben für additive Migrationen, keine fertigen Tabellen oder Endpunkte.
|
||||
|
||||
| Baustein | Wesentliche Daten und Regeln |
|
||||
| --- | --- |
|
||||
| `federation_instances`, Schlüssel und Partnerregeln | Globale Instanz-ID, verifizierte Herkunft, öffentliche Schlüssel, Vertrauenszustand; Freigaben/Sperren separat je lokalem Mandanten |
|
||||
| `collaboration_principals` | Typ `user` oder `guest`, Herkunftsinstanz, Mandantenkontext und öffentliche Benutzer-/Gast-ID, optional lokale Benutzerzuordnung; eindeutige Herkunft plus Typ; fremde Profile nur minimal speichern |
|
||||
| `collaboration_resources` | Globale Ressourcen-ID, Art, Eigentümerinstanz/-mandant, optional Verweis auf lokalen Chat oder lokales Projekt; Projekt-Unterraum als eigene Ressourcenart mit lokaler Projektzuordnung |
|
||||
| Unterraum-Inhaltszuordnung | Explizite Zuordnung von Chat, eingeschränkter Projektansicht und später Dateifreigaben zu genau einem Unterraum; dieselbe Datei kann über getrennte Freigaben in mehreren Räumen angeboten werden |
|
||||
| Gastidentitäten und Gastsitzungen | Stabile Gast-ID im Eigentümermandanten, verifizierter Anmeldekanal, Kontostatus und getrennte Sitzungen; keine allgemeinen Tenant-Rollen |
|
||||
| `collaboration_bindings` | Ressource plus empfangender lokaler Mandant; trennt lokale Spiegel desselben Raums und enthält den Synchronisationscursor |
|
||||
| `collaboration_grants` | Ressource, Principal, Rolle/Scopes, Status, Revision, Historienbeginn, Ablauf, Ersteller; einzelne Freigaben bleiben widerrufbar |
|
||||
| `collaboration_invitations` | Empfängerbindung, Angebotsrevision, angefragte Freigaben, Tokenhash soweit nötig, Zustand und Ablauf |
|
||||
| Föderationsereignisse, Inbox, Outbox, Befehlsresultate | Globale IDs, Raumsequenz, Empfänger-/Mandantenbindung, Idempotenz, Zustellzustand und nächste Wiederholung |
|
||||
| Nachrichtenergänzungen und Lesestatus | Nachrichten-UUID, Principal als Autor, Sequenz; Lesemarken je Principal/Binding; lokale Autor-IDs während Migration kompatibel halten |
|
||||
| Audit | Einladung, Annahme, Rechteänderung, Widerruf, Partner-/Schlüsseländerung mit verantwortlichem Principal und Zeit; keine Tokens oder vollständigen Chattexte |
|
||||
|
||||
Fremde Benutzer bekommen keinen künstlichen Eintrag mit Passwort in `auth_users` und keine Mitgliedschaft in `auth_tenant_users`. Beim Eigentümer darf eine externe Autoridentität deshalb nicht weiterhin einen obligatorischen lokalen `author_user_id` voraussetzen. Bestehende lokale Nachrichten behalten ihre Autorenverknüpfung; ein neuer Principal-Verweis ergänzt sie. Für lokale Teilnehmer wird der Principal mandantenspezifisch aufgebaut.
|
||||
|
||||
`communication_rooms.tenantId` bleibt der lokale Eigentümermandant. Fremd geführte Räume erhalten einen getrennten lokalen Binding-/Projektionsdatensatz statt eines vorgetäuschten lokalen Eigentümers. Datenbankconstraints sichern lokale Ressourcenverweise gegen einen anderen Mandanten ab. Globale Identitäten und Autorenschaft werden nicht aus frei eingereichten numerischen IDs abgeleitet.
|
||||
|
||||
Die Unterraum-Projektzuordnung muss auf denselben Eigentümermandanten verweisen. Sie wird nicht als Berechtigungsvererbung ausgewertet. Eine Inhaltsfreigabe bindet Quellressource, Unterraum und erlaubte Ansicht beziehungsweise Dateiversion; die Kenntnis der Quell-ID erlaubt keinen Zugriff über die internen APIs. Ein bestehender Unterraum wird in Version 1 nicht durch Änderung seiner Projekt-ID in ein anderes Projekt verschoben.
|
||||
|
||||
| API-Bereich | Zweck und Grenze |
|
||||
| --- | --- |
|
||||
| `/.well-known/fedeo-federation` | Öffentliche technische Discovery ohne Benutzer-, Raum- oder Projektverzeichnis |
|
||||
| `/federation/v1/commands` | Signierte Befehle an die führende Instanz; Fachberechtigungen bleiben zwingend |
|
||||
| `/federation/v1/inbox` | Bestätigte Ereignisse nur vom zuständigen Eigentümer; feste Empfängerbindung |
|
||||
| `/federation/v1/sync` | Berechtigtes Nachholen; kein frei lesbares globales Ereignisprotokoll |
|
||||
| `/federation/v1/shared-projects/...` | Signierte, principalgebundene Abfrage explizit freigegebener Projektfelder |
|
||||
| `/api/collaboration/...` | Lokale Benutzeroberfläche für Partner, Einladungen, Chats und geteilte Projekte; lokale Anmeldung plus objektbezogene Prüfung |
|
||||
| `/guest/api/...` | Eigene Gastauthentifizierung und ausschließlich aktiv freigegebene Unterräume/Inhalte; Gastsitzungen werden von regulären `/api`- und Föderationsrouten nicht als Benutzeranmeldung akzeptiert |
|
||||
|
||||
Ein zentraler `CollaborationPolicy`-Dienst prüft Aktion, handelnden Principal, Ressource, Heimatmandant und aktuelle Freigaben. Lokaler Transport und Netzwerktransport rufen denselben Fachdienst auf. Allgemeine Ressourcen-, Datei-, Export- und MCP-APIs bleiben für fremde Mandanten geschlossen; neue externe Funktionen müssen bewusst über die eingeschränkte Ansicht eingeführt werden.
|
||||
|
||||
Benachrichtigungen werden auf der Heimatinstanz an lokal berechtigte Nutzer erzeugt. Der Eigentümer adressiert externe Principals und greift nicht direkt auf deren Push-Tokens zu. Die vorhandene Push-Infrastruktur kann die Zustellung übernehmen; ihre Instanzregistrierung ersetzt keine Föderationsidentität oder fachliche Autorisierung. Standard für externe Push-Nachrichten ist ein neutraler Hinweis ohne Projekt- oder Nachrichtentext.
|
||||
|
||||
Für Gäste erzeugt die Eigentümerinstanz die Benachrichtigung nach derselben Unterraumprüfung. Links öffnen nur den freigegebenen Unterraum und benötigen eine gültige Gastsitzung. Gäste werden dafür nicht in reguläre Mitarbeiter-Empfängerlisten aufgenommen.
|
||||
|
||||
## 10. Betrieb, Dateien und Grenzen
|
||||
|
||||
- Öffentlich erreichbare HTTPS-Endpunkte sind Voraussetzung für den direkten Transport in Version 1. Für rein interne Installationen wäre später ein ausdrücklicher Relay-/Tunnelmodus nötig; ein zentraler Chatserver oder ein zentrales Adressbuch ist kein Pflichtbestandteil.
|
||||
- Ausgehende Ziele stammen ausschließlich aus geprüften Partnerkonfigurationen. URL-Schemas, Ports und sämtliche aufgelösten IPv4-/IPv6-Adressen werden geprüft; keine Weiterleitungen zu beliebigen Hosts und kein Zugriff auf Loopback, private Netze oder Cloud-Metadaten. Prüfungen müssen auch DNS-Wechsel zwischen Validierung und Verbindung abdecken. Geschlossene Firmennetze brauchen eine separat konfigurierte Netzfreigabe. Grundlage: [OWASP SSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html).
|
||||
- Größenlimits, feste Schemas, Zeitlimits und Quotas gelten pro Instanz, Mandant und Benutzer. Ein gestörter Partner darf keine globale Warteschlange blockieren. Metriken zeigen Zustellverzug, letzte Bestätigung, Retry-Status und Sperren, ohne Chattexte zu protokollieren.
|
||||
- Beim späteren Dateiaustausch bleibt der Speicher privat. Downloads laufen über einen freigabeprüfenden Dienst; keine dauerhaften öffentlichen S3-Links. Uploads sind begrenzt und vor Freigabe zu prüfen. Temporäre Downloadrechte werden an Principal, Ressource, Version und kurze Laufzeit gebunden. Sie ersetzen nicht die aktuelle Zugriffsprüfung beim Eigentümer.
|
||||
- Version 1 vertraut den beteiligten Servern und verwendet Transportverschlüsselung. Betreiber der führenden und empfangenden Instanzen können Chattexte verarbeiten. Ende-zu-Ende-Verschlüsselung ist eine separate Architekturentscheidung mit Folgen für Suche, Wiederherstellung, Moderation und Benachrichtigungen; sie darf nicht nachträglich als bereits vorhandene Eigenschaft dargestellt werden.
|
||||
- Aufbewahrung und Löschereignisse werden je Ressource dokumentiert. Löschanforderungen werden verteilt und protokolliert; Kopien bei fremden Betreibern oder bereits heruntergeladene Dateien sind technisch nicht sicher rückrufbar.
|
||||
|
||||
## 11. Gastzugang ohne FEDEO-Konto in Unterräumen
|
||||
|
||||
Der Projekteigentümer stellt eine reduzierte Gastoberfläche mit eigener Authentifizierung bereit. Gäste werden direkt in einen konkreten Projekt-Unterraum eingeladen. Nach überprüfter E-Mail-Adresse und Annahme entsteht ein Gast-Principal mit ausschließlich den angebotenen Unterraum- und Inhaltsfreigaben. Ein Gast erhält weder einen allgemeinen Mandantenzugang noch ein durchgereichtes internes Benutzer-Token.
|
||||
|
||||
Die Startansicht zeigt den Namen des Unterraums, dessen ausdrücklich freigegebenen Projektkontext, Chat und Teilnehmer sowie später freigegebene Dokumente oder Aufgaben. Hat dieselbe Gastidentität mehrere angenommene Einladungen beim Eigentümermandanten, kann sie ausschließlich zwischen diesen Unterräumen wechseln. Interne Projektbäume, andere Räume und Verzeichnisse werden nicht ausgegeben. Die Erstversion soll im Browser ohne Installation einer FEDEO-App nutzbar sein.
|
||||
|
||||
Ein Einladungslink ist einmalig; wiederholte Anmeldung braucht ein separates Verfahren, beispielsweise Passkey oder kurzlebigen einmaligen Anmeldecode. Verliert der Gast seinen Zugang, führt Wiederherstellung erneut über Identitätsprüfung; eine spätere gleichlautende Adresse darf keine alten Rechte automatisch übernehmen. Ein Wechsel vom Gast zur eigenen FEDEO-Instanz erfordert Zustimmung und eine explizite Neubindung der Freigaben.
|
||||
|
||||
Dieser Zugang ist Teil des geplanten Funktionsumfangs und verwendet denselben Berechtigungsdienst wie die Föderation. Die eingeschränkte Gastanmeldung wird als eigener Schritt auf dem lokalen Unterraummodell aufgebaut; sie benötigt keinen bereits fertiggestellten Server-Server-Transport. Die externe Projektzusammenarbeit gilt erst dann als vollständig, wenn sowohl FEDEO-Teilnehmer als auch Gäste ihre Unterräume nutzen können.
|
||||
|
||||
## 12. Architekturentscheidung gegenüber Alternativen
|
||||
|
||||
| Variante | Bewertung für FEDEO |
|
||||
| --- | --- |
|
||||
| Native FEDEO-Föderation mit führender Instanz je Ressource | Empfehlung bei Fokus auf FEDEO-Partner und Projektfreigaben. Nutzt die vorhandene Oberfläche; ein eigenes versioniertes Protokoll, Schlüsselverwaltung und Zustellbetrieb müssen dauerhaft gepflegt werden. |
|
||||
| Matrix erneut als Chatunterbau | Bietet standardisierte Serverföderation und replizierte Räume. FEDEO benötigt trotzdem eigene Projektfreigaben und ein Mapping zwischen Mandanten und Identitäten. Sinnvoll erneut zu prüfen, wenn Messenger-Interoperabilität, umfangreiche Chatfunktionen oder Verschlüsselung höchste Priorität haben. |
|
||||
| Zentraler FEDEO-Kommunikationsdienst | Vereinheitlicht den Betrieb, macht Kommunikation aber von einem gemeinsamen Dienst abhängig und verlagert Datenhaltung. Erfüllt das hier gewählte dezentrale Betriebsmodell nur eingeschränkt. |
|
||||
|
||||
Matrix definiert bereits einen umfangreichen Server-Server-Vertrag einschließlich Ereignissen, Zustandsabgleich und Autorisierung; die frühere Entfernung ist allein kein fachlicher Grund gegen eine Neubewertung. Unsere Empfehlung für die native Variante folgt dem engeren Ziel „FEDEO-übergreifende Projektzusammenarbeit“, nicht der Annahme, vollständige Messenger-Föderation sei trivial. [Matrix Server-Server API](https://spec.matrix.org/v1.19/server-server-api/)
|
||||
|
||||
## 13. Umsetzung in abgeschlossenen Schritten
|
||||
|
||||
| Schritt | Lieferumfang | Abnahme |
|
||||
| --- | --- | --- |
|
||||
| 0: Bestehende Grenzen absichern | Raumschlüsselkollisionen beheben, Projektchat-Beitritt autorisieren, internen und externen Chat trennen | Kein fremder Raumbeitritt durch Schlüssel oder Projekt-ID; bestehende lokale Chats funktionieren weiter |
|
||||
| 1a: Unterräume und Zusammenarbeit lokal | Principals, Freigaben, Einladungen, Unterräume mit eigenem Chat und externe Projektansicht zwischen zwei Mandanten derselben Instanz | Einladung und Widerruf funktionieren ohne allgemeine Mandantenmitgliedschaft; keine Rechtevererbung zum Projekt oder Nachbarraum |
|
||||
| 1b: Gäste im Unterraum | Gastidentität, getrennte Gastanmeldung und reduzierte Browseroberfläche für Personen ohne FEDEO-Konto | Gast kann eingeladenen Unterraum nutzen; direkte URLs und APIs öffnen weder Hauptprojekt noch andere Unterräume |
|
||||
| 2: Föderierter Pilot | Discovery, Partnerfreigabe, Signaturen, Worker, Idempotenz und Synchronisation zwischen zwei Instanzen | Gleicher fachlicher Ablauf über Netzwerk einschließlich Ausfall und erneuter Zustellung |
|
||||
| 3: Alltagstauglicher Betrieb | Web-/Mobile-Parität, Benachrichtigungen, Moderation, Quotas, Diagnose, Schlüsselrotation und Wiederherstellung | Ende-zu-Ende-Abnahme mit mehreren Mandanten und dokumentierter Wiederaufnahme |
|
||||
| 4: Erweiterungen | Freigegebene Dateien und gezielte Aufgabenbearbeitung in Unterräumen, gegebenenfalls erweiterte Chatfunktionen | Jede neue Fähigkeit erhält eigene Scope-, Widerrufs- und Datenabflusstests |
|
||||
|
||||
Migrationen erfolgen additiv mit Backfill für öffentliche IDs und lokale Principals. Bestehende Chat-APIs werden während des Übergangs adaptiert. Kein vorhandener Raum wird automatisch föderiert; externe Zusammenarbeit wird je Mandant aktiviert und startet bei Projekten mit explizit angelegten Unterräumen samt Partnerchat. Bei Abschaltung stoppen neue Einladungen und ausgehende Aktionen; Eigentümer entziehen aktive externe Freigaben und stellen Widerrufe nach Möglichkeit zu. Vorhandene fremde Kopien verschwinden dadurch nicht automatisch. Alte Spalten werden erst nach Clientumstellung und geprüftem Rückfallpfad entfernt.
|
||||
|
||||
## 14. Konkrete Abnahmeszenarien
|
||||
|
||||
1. A1 und B1 auf derselben Instanz sowie A1 und B2 auf verschiedenen Instanzen durchlaufen identische Einladung, Annahme, Nachricht und Entzug.
|
||||
2. Derselbe Benutzer in zwei Heimatmandanten erhält nur im eingeladenen Kontext Zugriff. Gleichlautende numerische IDs oder E-Mail-Adressen verbinden keine fremden Datensätze.
|
||||
3. Ein Chatgast kann weder Projektübersicht noch interne Ressourcen-, Datei-, Export- oder MCP-Endpunkte verwenden. Ein Projektleser erhält ausschließlich freigegebene Felder und Dokumente.
|
||||
4. Externe sehen keine alten internen Nachrichten, unsichtbaren Zitate, fremden Partnerchats oder unfreigegebenen Teilnehmerlisten. Eine erneute Einladung erweitert die Historie nicht unbeabsichtigt.
|
||||
5. Abgelaufene, weitergeleitete, doppelt eingelöste und während Annahme widerrufene Einladungen erzeugen keine unberechtigten Freigaben. Rechteverlust des Einladenden vor Annahme wird erkannt.
|
||||
6. Manipulierter Body, falscher Signaturschlüssel, falsche Zielinstanz, Nonce-Replay und fremder Principal werden abgelehnt. Ein gültiger Server darf keine andere Organisation impersonieren.
|
||||
7. Doppelte oder vertauschte Ereignisse, verlorene Bestätigungen und Neustarts erzeugen keine doppelten Nachrichten oder Benachrichtigungen. Ein alter Grant-Zustand überschreibt keinen Widerruf.
|
||||
8. Entzug während ausstehender Nachricht beziehungsweise Zustellung sperrt noch nicht autorisierte Nutzung. Die Oberfläche unterscheidet ausstehend, bestätigt, fehlgeschlagen und archiviert nachvollziehbar.
|
||||
9. Ein offline befindlicher Eigentümer führt zu sichtbarer Wartesituation; Wiederverbindung holt nur weiterhin berechtigte Inhalte nach. Lokale interne Chats bleiben unabhängig nutzbar.
|
||||
10. Discovery und Callback-Ziele können weder interne Dienste noch Metadaten-Endpunkte erreichen, auch bei IPv6, Redirects und DNS-Wechsel.
|
||||
11. Partnerblockade, Mitarbeiterdeaktivierung, Schlüsselrotation und Restore werden mit mehreren Mandanten getestet. Alte Backups oder verspätete Befehle stellen keine entzogenen Rechte wieder her.
|
||||
12. Ein Gastzugang wird erst aktiviert, wenn Gastanmeldung, Linkbindung und Trennung von normalen Mandantensitzungen separat geprüft sind.
|
||||
13. Ein Gast im Unterraum „Gerüstbau“ sieht weder Hauptprojekt noch Unterraum „Bauherr“, einschließlich deren Namen, Teilnehmer, Suchtreffern und Ungelesen-Zählern. Das gilt auch beim Erraten von IDs oder direkten API-Aufrufen.
|
||||
14. Interne Projektmitgliedschaft, Unterraumverwaltung und Chatmitgliedschaft werden getrennt geprüft. Eine Projektzuordnung oder manipulierte Inhaltszuordnung überträgt keine Leserechte.
|
||||
15. Entzug eines Unterraums beendet dessen Chat-, Datei- und Echtzeitzugriffe, lässt andere explizit freigegebene Unterräume aber unberührt. Globale Gastsperre sperrt alle zugehörigen Unterräume.
|
||||
16. Archivierung erlaubt nur weiterhin berechtigtes Lesen; Sperrung verweigert auch direkte Inhaltsabrufe. Ein Wechsel der Projektzuordnung kann keine vorhandene Unterraumhistorie offenlegen.
|
||||
|
||||
Als erster nutzbarer Meilenstein wird **ein abgegrenzter Projekt-Unterraum mit eigenem Chat und expliziten Einladungen** empfohlen. Zuerst wird die Mandantentrennung lokal geprüft, anschließend der Gastzugang ergänzt. Auf diesem gemeinsamen Modell baut der instanzübergreifende Transport auf.
|
||||
222
docs/minio-austausch-konzept.md
Normal file
222
docs/minio-austausch-konzept.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Konzept: MinIO durch SeaweedFS ersetzen
|
||||
|
||||
Stand: 25. September 2026. Status: Entscheidungsvorschlag auf Basis des FEDEO-Codes bei Commit `29155f91` und der unten verlinkten Herstellerquellen. Eine Migration, ein Kompatibilitätstest mit einem Ersatzserver oder eine Änderung an einer laufenden Instanz wurde für dieses Konzept nicht durchgeführt.
|
||||
|
||||
**Fortschreibung vom 27. September 2026:** Ein ausführbarer, isoliert getesteter
|
||||
Migrationspilot ist hinzugekommen. Durchführung, Testnachweise und noch offene
|
||||
Freigaben stehen in [MinIO-Umstellung: Pilot](minio-umstellung-pilot.md).
|
||||
Die folgenden Abschnitte bleiben das Zielbild für den vollständigen Standardbetrieb.
|
||||
|
||||
## 1. Empfehlung und Umfang
|
||||
|
||||
**SeaweedFS Open Source wird der bevorzugte Kandidat für den FEDEO-Dateispeicher.** Ziel ist ein einzelner Container auf dem bisherigen Docker-Host mit S3-Schnittstelle, privaten Buckets und persistentem lokalen Speicher. Die Freigabe erfolgt erst nach den unter Abschnitt 10 beschriebenen Tests mit einem festgelegten Release und Image-Digest.
|
||||
|
||||
„Eins zu eins“ bedeutet für FEDEO: gleiche Dateien, gleiche Objektschlüssel, gleiche Datenbankverweise, gleiche Mandantentrennung und gleiche Benutzerabläufe. Das interne Speicherformat wird durch eine kontrollierte Kopie über S3 umgestellt. Das Verzeichnis `minio/` wird weder umbenannt und wiederverwendet noch vom Ersatzserver geöffnet.
|
||||
|
||||
Zur Lösung gehören drei zusammenhängende Änderungen:
|
||||
|
||||
1. MinIO-Server und MinIO-Client `mc` aus dem künftigen Standardbetrieb entfernen.
|
||||
2. Installer, Update-Dienst, Datenübernahme und Wiederherstellung auf den neuen Speicher abstimmen.
|
||||
3. Freigegebene Speicher-Images und Hilfswerkzeuge unter FEDEO-Kontrolle verfügbar halten, damit Kundenupdates nicht direkt von einer fremden Registry abhängen.
|
||||
|
||||
Nicht Teil dieses Austauschs sind Hochverfügbarkeit, ein Kubernetes-Cluster, neue fachliche Dateifunktionen oder die Ablösung von S3 durch ein eigenes Dateisystem-Backend. Ein Container auf einem Host bleibt ein einzelner Ausfallpunkt; eine zweite Kopie auf demselben Datenträger ersetzt kein externes Backup.
|
||||
|
||||
## 2. Konkreter Anlass und heutiger Zustand
|
||||
|
||||
Der gemeldete Auftrag von 3.3.0 nach 3.4.1 scheiterte beim Abruf von `quay.io/minio/mc` mit `401 Unauthorized`. `phase: pulling`, `mutationStarted: false` und `recoveryRequired: false` zeigen einen Abbruch vor der Wartungsphase. Das Protokoll belegt ein Bezugsproblem des Client-Images, keinen Datenfehler im laufenden MinIO. Es belegt auch nicht, ob das benötigte Image bereits lokal vorhanden ist.
|
||||
|
||||
Der inzwischen im Repository vorhandene Cache-Fix kann passende lokale Digest-Images weiterverwenden. Er ist eine Überbrückung und garantiert weder Neuinstallationen noch künftige Sicherheitsupdates.
|
||||
|
||||
Die Anwendung hat bereits eine weitgehend herstellerneutrale Schnittstelle:
|
||||
|
||||
| Bereich | Befund im Repository | Konsequenz |
|
||||
| --- | --- | --- |
|
||||
| S3-Client | `backend/src/utils/s3.ts`: AWS SDK, `forcePathStyle: true`, Endpunkt/Region/Schlüssel/Bucket aus `S3_*` | Kein Austausch des SDK erforderlich |
|
||||
| Dateioperationen | `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` | Überschaubarer verbindlicher API-Vertrag |
|
||||
| Dateiablage | `backend/src/utils/files.ts`: Schlüssel wie `<Mandant>/filesbyid/<ID>/<Name>`, Content-Type und Länge | Schlüssel und Objektinhalt müssen unverändert bleiben |
|
||||
| WebDAV | `backend/src/webdav/server.ts`: GET und HEAD | Lesen und Dateigrößen gezielt prüfen |
|
||||
| Normale Dateilinks | `backend/src/routes/files.ts`: `/api/files/content/...` mit FEDEO-Token | Interne Speicheradresse bleibt vor dem Browser verborgen |
|
||||
| DATEV/SEPA | `backend/src/routes/exports.ts`: echte S3-Presigned-URLs, 24 Stunden gültig, zusätzlich gespeicherter Objektschlüssel | Signaturen und bereits gespeicherte Links gesondert behandeln |
|
||||
| Mandantenexport/-import | `backend/src/utils/tenantFullExport.ts` und `backend/src/routes/admin.ts` | Auch Archive, Vorlagen und temporäre Objekte übernehmen |
|
||||
| Systemstatus | `backend/src/modules/system-status.service.ts`: `/minio/health/live` | Herstellerneutralen Funktionscheck einführen |
|
||||
| Installation | Compose, `.env.example`, `scripts/selfhost-setup.sh`: MinIO und `mc` fest verdrahtet | Speicherbereitstellung und Initialisierung ersetzen |
|
||||
| Host-Updater | `scripts/selfhost-update.py`: feste Dienste, Mounts, Hostname `minio`, Zugangsdaten, Start-/Backup-/Restore-Logik | Expliziten Übergangsadapter entwickeln |
|
||||
|
||||
Im durchsuchten Anwendungscode gibt es keine Aufrufe für Bucket-Versionierung, Object Lock, Objekt-Tags oder S3-Multipart-Uploads. Das beweist nicht, dass diese Funktionen auf einer bestehenden Instanz nie extern aktiviert wurden. Die Migration muss den tatsächlichen Bucket prüfen. HTTP-Multipart beim Datei-Upload ist außerdem nicht S3-Multipart.
|
||||
|
||||
## 3. Auswahl des Ersatzes
|
||||
|
||||
| Kandidat | Eignung und Grenzen | Entscheidung für FEDEO |
|
||||
| --- | --- | --- |
|
||||
| **SeaweedFS Open Source** | S3, Betrieb aus einem Binary/Container, konfigurierbare Schlüssel; Apache-2.0 laut Projekt. Intern mehrere Speicherkomponenten. S3-Schlüssel werden auf eine Verzeichnisstruktur abgebildet. | Bevorzugter Kandidat; Schlüsselkompatibilität, Authentifizierung und vollständiges Backup sind Freigabebedingungen. [Projekt](https://github.com/seaweedfs/seaweedfs), [S3-Vertrag](https://github.com/seaweedfs/seaweedfs/wiki/Amazon-S3-API) |
|
||||
| **Garage** | Unterstützt den FEDEO-Kern aus S3, SigV4 und Presigned-URLs. Eigene Bucket-/Schlüsselrechte; keine vollständige AWS-Policy-, Versionierungs- oder Object-Lock-Schnittstelle. Das Einzelknoten-Tutorial rät wegen fehlender Redundanz vom Produktionseinsatz ab. | Alternative für bewusst kleinen Funktionsumfang; für den gewünschten Austausch weniger Spielraum bei bestehenden Sonderkonfigurationen. [Kompatibilität](https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/), [Betriebsgrenzen](https://garagehq.deuxfleurs.fr/documentation/quick-start/) |
|
||||
| **RustFS** | S3-orientiert, ähnliche Containerkonfiguration. Version 1.0.0 wurde am 16. September veröffentlicht; die direkte MinIO-Datenformat-Kompatibilität ist im aktuellen Projekt als Preview gekennzeichnet und nicht im Standardbuild aktiv. | Weiter beobachten. Die sehr junge stabile Version begründet hier eine längere Erprobung, nicht die Behauptung einer grundsätzlichen Untauglichkeit. Kein direkter Zugriff auf bestehende MinIO-Daten. [Release](https://github.com/rustfs/rustfs/releases/tag/1.0.0), [Datenformat](https://github.com/rustfs/rustfs/blob/main/docs/architecture/minio-file-format-compat.md) |
|
||||
|
||||
Die Entscheidung beruht auf dem FEDEO-Betriebsmodell und dem benötigten API-Umfang, nicht auf Hersteller-Benchmarks. Es gibt noch keine gemessene Aussage zu Geschwindigkeit oder Speicherverbrauch auf dieser Instanz. SeaweedFS-Enterprise-Funktionen sind keine Voraussetzung des Konzepts.
|
||||
|
||||
Zwei SeaweedFS-Eigenschaften verlangen konkrete Gegenmaßnahmen:
|
||||
|
||||
- Laut S3-Dokumentation können ein Objekt `a` und ein Objekt `a/b` nicht wie in einem flachen S3-Namensraum nebeneinander existieren; auch Löschoperationen auf Verzeichnissen haben besondere Semantik. Vorab sämtliche Schlüssel auf Präfixkonflikte, leere Verzeichnismarker, wiederholte Schrägstriche und problematische Pfadsegmente prüfen. Bei Verlust oder Normalisierung eines Schlüssels wird der Austausch blockiert, nicht stillschweigend umbenannt. [S3-Unterschiede](https://github.com/seaweedfs/seaweedfs/wiki/Amazon-S3-API)
|
||||
- Ohne konfigurierte Identität startet das S3-Gateway standardmäßig mit anonym erlaubten Operationen. Unser Startprogramm muss bei fehlenden oder leeren Zugangsdaten abbrechen, bevor der Server startet. Zusätzlich sind anonyme und falsch signierte Zugriffe Negativtests jeder Freigabe. [Authentifizierung](https://github.com/seaweedfs/seaweedfs/wiki/S3-Credentials)
|
||||
|
||||
## 4. Zielarchitektur
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[Browser / Mobile / Geräte] --> B[FEDEO-Backend und WebDAV]
|
||||
B --> D[(PostgreSQL: unveränderte Dateiverweise)]
|
||||
B --> S[S3-Endpunkt: Dateispeicher]
|
||||
S --> W[SeaweedFS: ein Container]
|
||||
W --> V[(Objekte und Metadaten)]
|
||||
M[Host-Updater / Migrationswerkzeug] --> S
|
||||
M --> O[Altes MinIO während der Übernahme]
|
||||
M --> A[(Geprüfte Backups und Übernahmeprotokoll)]
|
||||
```
|
||||
|
||||
Vorgeschlagene Betriebsnamen und Verzeichnisse:
|
||||
|
||||
| Bestandteil | Vorgabe |
|
||||
| --- | --- |
|
||||
| Compose-Dienst | `objectstore`, Container `fedeo-objectstore` |
|
||||
| S3 | Intern Port 9000; keine Veröffentlichung eines neuen Host-Ports |
|
||||
| Neuinstallation | `S3_ENDPOINT=http://objectstore:9000` |
|
||||
| Bestehende Installation | Endpunkt zunächst unverändert; nach dem Umschalten exklusiver Netzwerkalias `minio` auf dem neuen Dienst |
|
||||
| Zugangsdaten | Bestehende `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION` und `S3_BUCKET` übernehmen; exakte Werte im Kompatibilitätstest prüfen |
|
||||
| Neue Ablage | `./storage/seaweedfs/` für sämtliche persistenten Daten und Metadaten |
|
||||
| Konfiguration | `./storage/config/`, root-geschützte Konfiguration und getrennte Administratorzugänge |
|
||||
| Legacy-Ablage | `./minio/` bleibt bis nach erfolgreicher Abnahme erhalten |
|
||||
| Initialisierung | `storage-init` als Einmalprozess aus einem FEDEO-eigenen, gepinnten Hilfsimage; keine Abhängigkeit von `mc` |
|
||||
|
||||
SeaweedFS stellt einen Einzelprozess-/Container-Einstieg bereit. Die konkrete Startkonfiguration muss für den ausgewählten Release ausdrücklich Daten-, Metadaten- und Verwaltungszustand in die gesicherten Pfade legen. Keine Speicherung wichtiger Zustände in der beschreibbaren Container-Schicht. Unbenötigte APIs werden deaktiviert; Verwaltungs-, Filer- und Volume-Endpunkte sind für Anwendungscontainer nicht erreichbar. Dafür wird ein getrenntes Speichernetz beziehungsweise eine gezielte Bindung an Loopback innerhalb des Containers verwendet. [Betriebsoptionen](https://github.com/seaweedfs/seaweedfs/blob/master/README.md)
|
||||
|
||||
`storage-init` prüft Zugang, Bucket und private Zugriffe idempotent. Es erzeugt einen fehlenden Bucket, überschreibt aber keine fremden Policies oder bestehenden Inhalte. Die FEDEO-Anwendungsidentität erhält nur benötigte Rechte auf den konfigurierten Bucket. Verwaltung und Migration verwenden getrennte, geschützte Zugänge.
|
||||
|
||||
Bei Bestandsinstanzen dürfen `MINIO_ROOT_*` und `S3_*` nicht ohne Vergleich gleichgesetzt werden. Maßgeblich sind die tatsächlich vom Backend verwendeten Werte, einschließlich möglicher externer Secret-Verwaltung. Andere Anwendungen, weitere Buckets oder zusätzliche S3-Schlüssel führen zu einer erweiterten Bestandsaufnahme; deren Zugriff darf nicht durch die Stilllegung verloren gehen.
|
||||
|
||||
Der DNS-Alias wird nie gleichzeitig von Quelle und Ziel bedient. Während des Kopierens bleibt das Ziel unter einem anderen Namen erreichbar. Vor dem Umschalten wird das alte MinIO aus dem Anwendungsnetz genommen; danach werden Backend und sonstige S3-Clients neu gestartet, um bestehende Verbindungen zu schließen.
|
||||
|
||||
## 5. Verbindlicher Kompatibilitätsvertrag
|
||||
|
||||
Die Referenz ist der FEDEO-SDK-Stand aus dem Lockfile zusammen mit dem aktuellen MinIO-Verhalten. Der Test verwendet dieselben Dateipfade wie die Anwendung und zusätzlich Grenzfälle:
|
||||
|
||||
- PUT/GET/HEAD/DELETE; sofortiges Lesen nach Schreiben und Verhalten nach Löschen.
|
||||
- Unveränderte Bytes und Schlüssel, MIME-Typ, Länge sowie vorhandene benutzerdefinierte Metadaten und relevante HTTP-Metadaten wie `Content-Disposition`, `Cache-Control` und `Content-Encoding`.
|
||||
- SigV4, Path-Style, Region `eu-central-1` beziehungsweise der tatsächliche Bestandswert; SDK-Prüfsummen, Streaming und Sonderzeichen im Schlüssel.
|
||||
- Leere Objekte, Umlaute, Leerzeichen, große Dateien und die größten vorhandenen Exportarchive. Für Objekte außerhalb des getesteten Größenbereichs gibt es einen ausdrücklichen Abbruch vor der Migration.
|
||||
- Fehlercodes bei nicht vorhandenen Objekten, falschen Schlüsseln, anonymen Anfragen und fremden Buckets; ein HTTP-200 auf der Startseite ist kein ausreichender Healthcheck.
|
||||
- Bestehende Datenbank-IDs und Pfade bleiben gleich. Mandantenrechte werden weiterhin durch FEDEO geprüft.
|
||||
|
||||
Objekt-ETag, S3-Version-ID und serverseitiges `LastModified` sind keine portablen Identitäten und müssen nach der Kopie nicht gleich sein. Fachliche Zeitstempel in PostgreSQL bleiben erhalten. Inhalte werden über einen unabhängig berechneten SHA-256-Hash verglichen; ETags werden nicht pauschal als MD5 interpretiert. [AWS-Prüfsummen](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity-upload.html)
|
||||
|
||||
Für den ersten Migrationspfad gilt: keine aktivierte Versionierung, keine aufzubewahrenden früheren Versionen/Delete-Marker, kein Object Lock und keine nicht unterstützte Verschlüsselungs-, Tagging- oder Policy-Konfiguration. Falls die Bestandsprüfung solche Merkmale findet, wird ein eigener getesteter Adapter erforderlich. SeaweedFS-Unterstützung auf dem Papier ist keine Garantie, dass unser Kopierwerkzeug diese Merkmale verlustfrei überträgt.
|
||||
|
||||
DATEV-/SEPA-Exporte brauchen einen zusätzlichen Test: Bereits ausgegebene Signaturen bleiben nur bei passender Adresse, Region, Identität und unterstützten Signaturparametern gültig. Die ohnehin vorhandene Backend-Downloadstrecke soll im vorbereitenden Release auch für Exporte verwendet werden; alte gespeicherte URLs werden bei der Ausgabe aus `filePath` neu erzeugt. Für bereits an Nutzer ausgegebene Links werden Kompatibilität oder ein dokumentierter Übergang für ihre maximal 24-stündige Laufzeit nachgewiesen. Ein interner Alias allein macht interne URLs nicht browsererreichbar.
|
||||
|
||||
## 6. Images zuverlässig bereitstellen
|
||||
|
||||
Eine andere öffentliche Registry würde die jetzige Fehlerklasse nur verlagern. Für freigegebene Speicherstände gilt deshalb:
|
||||
|
||||
1. Einen konkreten nicht vorveröffentlichten Upstream-Release auswählen, Herkunft und Digest erfassen und sämtliche Abnahmetests ausführen. Die recherchierte aktuelle SeaweedFS-Version ist 4.47; das ist noch keine FEDEO-Freigabe. [Release](https://github.com/seaweedfs/seaweedfs/releases/tag/4.47)
|
||||
2. Das geprüfte OCI-Image unter einem unveränderlichen Tag in der FEDEO-Registry bereitstellen. Upstream- und Zieldigest getrennt dokumentieren; ein Registry-Transfer muss nicht dieselbe Manifestdarstellung erhalten.
|
||||
3. Speicher-, Initialisierungs- und Migrationsimage samt Digests in das Release-Manifest aufnehmen. Diese Infrastruktur-Einträge von den bestehenden Anwendungsimages unterscheiden.
|
||||
4. Release erst veröffentlichen, wenn jedes Zielimage aus Sicht einer Selfhost-Installation abrufbar ist. Korrekte Leserechte bei privaten Images prüfen.
|
||||
5. Vor jeder Wartung alle benötigten Images laden und verifizieren. Danach ausschließlich lokal starten. Keine neuen Registry-Abhängigkeiten während Backup, Kopie oder Wiederherstellung.
|
||||
6. Zusätzlich einen geprüften Offline-Export der notwendigen Images bereitstellen und Wiederherstellung ohne Upstream-Zugriff testen. Eine einzelne eigene Registry bleibt ansonsten ebenfalls ein Ausfallpunkt.
|
||||
|
||||
Spiegelung friert den Stand nur für einen Release ein. Sie braucht weiterhin einen gepflegten Sicherheitsupdate-Prozess und die zugehörigen Lizenz-/Herkunftsinformationen. `latest` ist keine Produktionsreferenz.
|
||||
|
||||
## 7. Datenübernahme bestehender Instanzen
|
||||
|
||||
Der erste unterstützte Weg ist eine Migration mit Wartungsfenster. Live-Vorkopieren wäre eine spätere Optimierung mit zusätzlichem Umgang mit Überschreibungen und Löschungen; für den ersten Wechsel wird keine unterbrechungsfreie Migration versprochen.
|
||||
|
||||
| Phase | Aktion | Bedingung zum Weitergehen |
|
||||
| --- | --- | --- |
|
||||
| Bestandsprüfung | Effektive Compose-Konfiguration, Images, Datenpfade, S3-Zugänge, alle Buckets, Objektanzahl/-größen und Sonderfunktionen erfassen | Standardfall bestätigt; unbekannte Schreiber oder Speichereigenschaften blockieren |
|
||||
| Vorbereitung | Zielimages und Offline-Rückfall bereitstellen; Ziel in separatem Netz und leerem Datenpfad starten | Quelle/Ziel eindeutig identifiziert; Authentifizierung und Testbucket funktionieren |
|
||||
| Schreibpause | Proxy, Backend, WebDAV und alle Hintergrundschreiber anhalten; externe Schreiber ausschließen | Keine fachlichen Schreibzugriffe mehr; PostgreSQL kann für den Dump laufen |
|
||||
| Sicherung | PostgreSQL-Dump und kalte MinIO-Sicherung samt Konfiguration erstellen und prüfen | Ein zusammengehöriger Wiederherstellungspunkt ist vorhanden |
|
||||
| Kopie | Altes MinIO ohne Anwendungszugriff wieder starten; gesamten betroffenen Bucket über S3 kopieren | Jedes Objekt im persistenten Migrationsjournal erfasst |
|
||||
| Prüfung | Quell- und Zielobjekte vollständig lesen, SHA-256 und Metadaten vergleichen; Schlüsselmenge, Anzahl und Bytes abgleichen | Keine Abweichung und keine unerklärten Zielobjekte |
|
||||
| Umschaltung | Altes MinIO stoppen und Neustart verhindern; Netzwerkalias/Compose wechseln | Ziel ist der einzige aktive Speicher für die Anwendung |
|
||||
| Abnahme | Funktionstests im Wartungsmodus mit deaktivierten Hintergrundschreibern | Download, PDF, WebDAV, Upload und Export sowie Berechtigungen stimmen |
|
||||
| Freigabe | Migration dauerhaft als abgeschlossen markieren; dann fachliche Schreiber und öffentlichen Zugriff freigeben | Journal und Backups referenzieren den aktiven Provider eindeutig |
|
||||
|
||||
Der neue, noch zu entwickelnde Migrationsprozess läuft als Host-Auftrag unter derselben exklusiven Sperre wie Updates. Er nutzt S3 über das bereits verwendete SDK in einem gepinnten FEDEO-Hilfsimage. Er kopiert mit begrenzter Parallelität und begrenztem RAM; große Objekte werden gestreamt oder in begrenztem lokalen Spool verarbeitet. S3-Multipart für große Objekte ist eine ausdrückliche Werkzeugfunktion und wird separat getestet.
|
||||
|
||||
Das root-lesbare Journal enthält Provider, Quell-/Zielidentität, Bucket, Image-Digests, Konfigurationsfingerprint, Phase, je Objekt Schlüssel/Größe/Hash/Metadatenvergleich sowie abgeschlossene Prüfungen. Es enthält keine Zugangsdaten. Abbruch und Wiederaufnahme sind idempotent; die Wiederaufnahme kontrolliert zuerst die Identitäten und den Schreibstopp. Sie vertraut einem alten „kopiert“-Eintrag erst nach erneuter Zielprüfung. Fehler, geänderte Quelle oder falsches Ziel führen zu einem Abbruch ohne Umschaltung.
|
||||
|
||||
Es wird der ganze zu migrierende Bucket inventarisiert, nicht nur `files.path` aus der Datenbank. Dadurch bleiben auch Exporte, Vorlagen und bislang nicht zugeordnete Objekte erhalten. Unbekannte weitere Buckets werden weder ignoriert noch gelöscht. Das Ziel ist zu Beginn leer und ausschließlich für diesen Auftrag vorgesehen.
|
||||
|
||||
## 8. Platzbedarf, Dauer und Rückkehr
|
||||
|
||||
Die Platzprüfung unterscheidet logische Objektbytes und physisch belegte Verzeichnisse. Kompression, Metadaten, Dateisystemreserve und Backup-Ziel werden berücksichtigt; eine Angabe wie „zweimal die Bucket-Größe reicht immer“ wäre falsch.
|
||||
|
||||
Für dieselbe Platte wird mindestens gerechnet mit:
|
||||
|
||||
`zusätzlich frei = neuer Speicher + altes kaltes Backup + Datenbankdump + Image-/Spoolbedarf + Reserve`
|
||||
|
||||
Die bereits vorhandenen MinIO-Daten bleiben zusätzlich belegt. Bei externem Backup entfällt dessen lokaler Platz nur, wenn wirklich direkt dorthin geschrieben und die Rücklesbarkeit geprüft wird. Verbindliche Zahlen gibt es nach Objektinventar, freiem Speicher und einem repräsentativen Probelauf.
|
||||
|
||||
Die Unterbrechung umfasst Backup, Kopie, vollständige Gegenprüfung und Neustart. Kopie und Hashprüfung erzeugen mehrere Lese-/Schreibdurchläufe; eine Laufzeit nur aus Dateigröße geteilt durch Netzwerkbandbreite wäre zu optimistisch.
|
||||
|
||||
**Bis zur Freigabe fachlicher Schreibzugriffe:** Quelle, ursprüngliche Compose-Datei, Zugangsdaten und Anwendung bleiben verfügbar. Ein Fehler erlaubt, das Ziel zu stoppen, den alten Netzwerkzustand wiederherzustellen und MinIO mit der unveränderten Anwendung zu starten. Testschreibvorgänge müssen in isolierten Testdaten stattfinden. Der Speichertausch wird nicht gleichzeitig mit fachlichen Datenbankschemamigrationen durchgeführt.
|
||||
|
||||
**Nach Freigabe fachlicher Schreibzugriffe:** Das alte MinIO ist veraltet. Ein bloßer Endpunktwechsel würde neue Dateien und Änderungen verlieren. Rückkehr erfordert erneut Wartung und eine vollständige, geprüfte Rückübernahme einschließlich Löschungen oder die bewusste Wiederherstellung eines zusammengehörigen DB-/Speicherbackups mit benanntem Datenverlustzeitraum. Kein automatischer Rückfall nach Aufnahme des normalen Betriebs.
|
||||
|
||||
Alte Daten und Container bleiben bis zur geprüften ersten vollständigen Sicherung und erfolgreichen Wiederherstellung des neuen Speichers erhalten. Aufräumen ist ein eigener administrativer Vorgang. Aufbewahrungsdauer wird anhand des verfügbaren Platzes festgelegt, nicht durch den Updater stillschweigend erzwungen.
|
||||
|
||||
SeaweedFS speichert Inhalt und Filer-Metadaten getrennt; beide müssen zusammenpassen. Für FEDEO wird zunächst ein kaltes Backup aller persistenten Bestandteile bei gestopptem Speicher implementiert. Der Restore-Test umfasst neue leere Verzeichnisse, vollständige Konfiguration, Schlüssel und genau das zugehörige Image. [Herstellerhinweise zu Backups](https://github.com/seaweedfs/seaweedfs/wiki/Data-Backup)
|
||||
|
||||
## 9. Änderungen im Projekt und Einführungsreihenfolge
|
||||
|
||||
| Arbeitspaket | Betroffene Stellen | Fertig, wenn … |
|
||||
| --- | --- | --- |
|
||||
| S3-Vertrag und Erprobung | neue Integrationstests, bestehender S3-Client | MinIO und ausgewählter Ersatz denselben FEDEO-Vertrag bestehen |
|
||||
| Speicherinitialisierung | neues FEDEO-Hilfsprogramm/-image, Compose | Bucket ohne `mc` privat und wiederholbar bereitsteht |
|
||||
| Installer | `scripts/selfhost-setup.sh`, `.env.example`, Compose | Neuinstallation und erneuter Start Daten/Zugänge erhalten |
|
||||
| Providerprüfung | `scripts/selfhost-update.py` | Explizite Adapter für Legacy-MinIO und SeaweedFS statt allgemeiner Lockerung der Mount-/Dienstprüfungen |
|
||||
| Datenübernahme | neuer Host-Ablauf plus Kopier-/Prüfwerkzeug | Journal, Sperre, Wiederaufnahme, Integritätsprüfung und Rückkehr funktionieren |
|
||||
| Backup/Restore | Host-Updater und Betriebsdokumentation | Kalte Sicherung und vollständiger Restore für beide Provider geprüft sind |
|
||||
| Oberfläche und API | `selfhost-update-service.py`, Backend-Updater-Modul/-Routen, `useSelfhostUpdates`, `SelfhostUpdateCard.vue` | Plan, Fortschritt, Sperren und bereinigte Fehlerdetails der Migration sichtbar sind |
|
||||
| Systemstatus | `system-status.service.ts`, Frontend-Systemstatus | „Dateispeicher“ über einen signierten S3-Funktionscheck bewertet wird |
|
||||
| Exportlinks | `backend/src/routes/exports.ts` und zugehörige Auslieferung | Neue und bestehende Exportlinks den Speicherwechsel überstehen |
|
||||
| Veröffentlichung | `scripts/release/publish.mjs`, Manifest, CI und Betriebsassets | Speicher-/Hilfsimages und Hostdateien vollständig geprüft ausgeliefert werden |
|
||||
|
||||
Vorgeschlagene Reihenfolge:
|
||||
|
||||
1. **Erprobung:** Datensatz und Restore in einer isolierten Umgebung testen. Keine produktive Quelle als Schreibziel verwenden.
|
||||
2. **Vorbereitender Release:** Herstellerneutrale Checks, Exportlinks, Initialisierung ohne `mc`, beide Provider-Adapter und Migrationsprotokolle liefern. Bestehende Instanzen behalten zunächst MinIO. Für deren Übernahme wird das tatsächlich vorhandene geprüfte MinIO-Image erhalten; der Übergang darf keinen erneuten Bezug des gesperrten `mc` verlangen.
|
||||
3. **Host-Dienst nachrüsten:** Die zusammengehörigen Host-Dateien aus diesem geprüften Stand installieren. Der aktuelle Host-Dienst ersetzt sich bei einem Anwendungsupdate nicht selbst. Ein Update der Weboberfläche allein reicht daher nicht.
|
||||
4. **Separater Migrationsauftrag:** Auf unverändertem Anwendung-/Datenbankschema den Speicherwechsel mit Vorschau, Platzbedarf und Wartungsfreigabe durchführen. Nach erfolgreicher Migration normale Anwendungsupdates wieder erlauben.
|
||||
5. **Neuer Standard:** Neue Installationen erhalten SeaweedFS direkt. Der Legacy-Adapter bleibt während der Übergangszeit zum Migrieren und Wiederherstellen verfügbar.
|
||||
|
||||
Die bisherigen Prüfschranken werden nicht pauschal entfernt: Nur ausdrücklich unterstützte Providerübergänge dürfen Dienste, Datenmounts und Bootstrap-Konfiguration ändern. PostgreSQL-, Mandanten- und sonstige Mount-Regeln bleiben streng. Ein ausgefallener Migrationsauftrag blockiert parallele Releases, bis sein Zustand geklärt ist.
|
||||
|
||||
API-Felder und Phasen wie `storage_copying` und `storage_verifying` müssen in Host-Dienst, Backend-Allowlist und Frontend gemeinsam eingeführt werden. Ein eigener Migrationsauftrag benötigt dieselben aktuellen globalen Adminrechte, bestätigten Bearer-Zugang und idempotenten Auftrags-IDs wie Updates. Er ist nicht über frei angegebene Shell-Befehle oder URLs konfigurierbar.
|
||||
|
||||
Providerwechsel, Journal und Schreibfreigabe benötigen einen dauerhaften Zustand vor dem tatsächlichen Start der Schreiber, damit auch ein Hostabsturz den Rückkehrpunkt nicht verschleiert. Bestehende `mutationStarted`-/`recoveryRequired`-Flags allein beschreiben diese Grenze nicht ausreichend.
|
||||
|
||||
Alte Updater können eine neue Compose-Struktur weiterhin ablehnen. Die Mindestversion des Host-Dienstes wird deshalb Bestandteil der Release-Vorschau. Die konkrete FEDEO-Versionsnummer folgt den Commit-Regeln; es wird keine Versionsnummer vorab versprochen und kein bestehender Release überschrieben. Inkompatible Änderungen an Betriebsverträgen müssen gegebenenfalls als Breaking Change mit Migrationshinweis gekennzeichnet werden.
|
||||
|
||||
## 10. Abnahmekriterien
|
||||
|
||||
Der Austausch ist erst freigegeben, wenn alle folgenden Punkte nachgewiesen sind:
|
||||
|
||||
1. Der vollständige S3-Vertrag aus Abschnitt 5 funktioniert mit dem exakten Backend-Lockfile und dem gepinnten Zielimage.
|
||||
2. Alle inventarisierten Objekte sind vorhanden; Schlüsselmenge, Größen, SHA-256 und zu erhaltende Metadaten stimmen. Kein stiller Ausschluss unbekannter Objekte.
|
||||
3. Upload, Download, PDF-Vorschau, ZIP, WebDAV, DATEV/SEPA, Mandantenexport und -import sowie Hintergrundverarbeitung funktionieren. Zwei Mandanten können weiterhin nicht gegenseitig auf ihre Dateien zugreifen.
|
||||
4. Anonymer Zugriff, falsche Schlüssel und Zugriff auf nicht freigegebene Buckets werden abgewiesen. Fehlende Start-Zugangsdaten führen zum Startabbruch.
|
||||
5. Datei-/Präfixkonflikte, unterstützte maximale Objektgröße und ungewöhnliche Schlüssel sind vorab erkannt beziehungsweise erfolgreich getestet.
|
||||
6. Abbrüche bei Kopie, Vollprüfung, DNS-Umschaltung und Neustart lassen sich ohne falschen Erfolg oder Verlust vorhandener Daten behandeln. Auch ein harter Host-Neustart wird geprüft.
|
||||
7. Rückkehr vor Schreibfreigabe und Wiederherstellung nach Schreibfreigabe sind praktisch getestet. Ein Restore nur anhand von Tar-Inhaltsverzeichnissen reicht nicht.
|
||||
8. Ein reguläres Anwendungsupdate auf dem neuen Speicher erstellt ein vollständiges Backup und kann bei Ausfall der fremden Registry mit bereitgestellten Images weiterarbeiten.
|
||||
9. Ein Neuaufbau aus externem Backup und Offline-Images funktioniert ohne Quay und ohne öffentlichen Upstream-Download.
|
||||
10. Gemessene Übernahmedauer und Platzreserve passen zur konkreten Instanz; der Probelauf nennt CPU/RAM, Objektanzahl, Größenverteilung und Datenträger. Unzureichende Werte blockieren die Freigabe.
|
||||
|
||||
Offen vor der Implementierung sind insbesondere Objektanzahl/-volumen dieser Instanz, größtes Objekt, freie Plattenkapazität, zusätzliche S3-Nutzer, aktivierte Bucket-Sonderfunktionen, Standort des externen Backups und das akzeptable Wartungsfenster. Diese Werte beeinflussen die Durchführung, nicht die Notwendigkeit der Integritätsprüfung.
|
||||
|
||||
## 11. Ergebnis für die betroffene Instanz
|
||||
|
||||
Die Instanz steht laut vorgelegtem Protokoll noch vor einer mutierenden Updatephase. Sie kann daher zunächst auf dem vorhandenen Speicher bleiben, während der Ersatz erprobt wird. Das Austauschkonzept benötigt keinen erfolgreichen Download des fehlgeschlagenen `mc`-Images. Voraussetzung der späteren Übernahme ist ein erreichbares, lesbares Quell-MinIO oder dessen geprüfte Wiederherstellung.
|
||||
|
||||
Der nächste Umsetzungsschritt ist ein reproduzierbarer S3-Kompatibilitäts- und Restore-Test mit SeaweedFS. Erst dessen Ergebnis rechtfertigt die Implementierung und Freigabe des produktiven Migrationspfads.
|
||||
307
docs/minio-umstellung-pilot.md
Normal file
307
docs/minio-umstellung-pilot.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# MinIO → SeaweedFS: betreuter Pilot auf einer Installer-Instanz
|
||||
|
||||
Stand: 28. September 2026. Dieser Ablauf ergänzt das
|
||||
[Austauschkonzept](minio-austausch-konzept.md) um ausführbare Werkzeuge.
|
||||
Er übernimmt den Speicher einer vorhandenen Installation, beispielsweise 3.3.0,
|
||||
mit unveränderten Anwendungsimages und ohne Datenbankschemamigration.
|
||||
|
||||
**Vor dem ersten Wartungsfenster nur `load-images` und `plan` ausführen.**
|
||||
Die Vorprüfung ermittelt die bislang unbekannten Größen und liefert eine
|
||||
kompakte Ausgabe zum gemeinsamen Prüfen. Sie stoppt keine Anwendungscontainer.
|
||||
Die fachliche Abnahme erfolgt zusätzlich auf der konkreten Instanz.
|
||||
|
||||
## Was der Pilot leistet und wo seine Grenze liegt
|
||||
|
||||
- Vorhandenes MinIO verwenden; kein Abruf von `minio/minio` oder `mc` erforderlich.
|
||||
- SeaweedFS 4.47 mit dokumentierter FEDEO-Metadatenkorrektur; Offline-Images,
|
||||
SHA-256-geprüftes Paket.
|
||||
- Alle Objekte des einzigen FEDEO-Buckets übernehmen, inklusive Exporte und
|
||||
Objekte ohne aktuellen Datenbankverweis. Schlüssel und Metadaten bleiben erhalten.
|
||||
- Alle bekannten Schreiber anhalten, ihre automatischen Neustarts sperren,
|
||||
PostgreSQL dumpen und MinIO kalt sichern. Die alten Daten bleiben erhalten.
|
||||
- Kopie, vollständigen Inhaltsvergleich und tatsächlichen SeaweedFS-Restore in
|
||||
ein frisches Verzeichnis prüfen. Unbekannte Zielobjekte werden nicht gelöscht.
|
||||
- Den S3-Vertrag zusätzlich mit dem **tatsächlich installierten Backendimage**
|
||||
ausführen. Dabei startet nur das Prüfprogramm, kein FEDEO-Backend.
|
||||
- Erst nach diesen Prüfungen den Alias `minio` exklusiv an SeaweedFS vergeben
|
||||
und die unveränderte Anwendung wieder starten.
|
||||
|
||||
**Der Pilot ist noch kein neuer Standardinstaller und kein reguläres
|
||||
FEDEO-Release.** Ältere Anwendungs-Updater kennen nur MinIO. Ihre Aufträge
|
||||
bleiben nach der Umstellung bewusst über `recoveryRequired` gesperrt. Dieser
|
||||
Entwicklungsstand enthält den SeaweedFS-Adapter und eine ausdrücklich geprüfte
|
||||
Übergabe über `adopt-updater` (unten). Diesen Zustand nicht mit dem alten
|
||||
`resolve` oder durch Löschen der Statusdatei aufheben. Ein alter Installer oder
|
||||
ein altes Release-Compose darf die umgestellte Installation nicht überschreiben.
|
||||
|
||||
FEDEO 3.3.0 kann im Systemstatus weiterhin den MinIO-spezifischen Health-Endpunkt
|
||||
abfragen und dadurch den Dateispeicher als gestört anzeigen. Die tatsächliche
|
||||
Funktionsprüfung erfolgt im Pilot über signierte S3-Operationen. Dieser
|
||||
Entwicklungsstand prüft den angemeldeten S3-Bucket auch im Systemstatus; dafür
|
||||
ist anschließend ein Anwendungsrelease mit dieser Änderung erforderlich.
|
||||
Der Speicherwechsel selbst bleibt eine eigene betreute Hostaktion. Der bisherige
|
||||
Standardinstaller wird durch die Migration nicht ersetzt.
|
||||
|
||||
### Warum das Pilotimage eine gezielte Korrektur enthält
|
||||
|
||||
Das unveränderte SeaweedFS 4.47 entfernt beim Speichern den expliziten MIME-Typ
|
||||
`application/octet-stream`. Bei einem `.pdf`-Schlüssel liefern HEAD und GET
|
||||
dadurch später `application/pdf`. Diese Abweichung wurde beim ersten betreuten
|
||||
Versuch durch den strengen Metadatenvergleich erkannt; die Instanz wurde vor
|
||||
Schreibfreigabe erfolgreich auf MinIO zurückgeführt.
|
||||
|
||||
`scripts/storage/Dockerfile.seaweedfs` baut deshalb die feste Herstellerrevision
|
||||
`c5073360007d28385a33426a42ac3e4ec504c5a3` mit
|
||||
`preserve-s3-content-type.patch` als `4.47-fedeo.1`. Die Korrektur bewahrt den
|
||||
expliziten Typ für S3-Einträge; die Normalisierung gewöhnlicher Dateisystemeinträge
|
||||
bleibt erhalten. Quellarchiv, Go-Buildimage und Hersteller-Basisimage sind per
|
||||
SHA-256 festgelegt. Gezielte Go-Tests laufen beim Build; das Paket enthält den
|
||||
Buildplan und Patch mit Prüfsummen. Der S3-Vertrag prüft den generischen MIME-Typ
|
||||
zusätzlich mit HEAD, GET und dem Original-Backend-SDK.
|
||||
|
||||
Dies ist eine eigene Buildvariante. Bei einem späteren SeaweedFS-Update muss die
|
||||
Korrektur weitergeführt oder durch einen nachgewiesenen Herstellerfix ersetzt
|
||||
werden; das ungeänderte Herstellerimage ist für diese 1:1-Übernahme nicht freigegeben.
|
||||
|
||||
## Voraussetzungen, die der Plan prüft
|
||||
|
||||
Linux amd64, Python ab 3.10, lokaler Docker-Daemon, Docker Compose und `tar`/`du`.
|
||||
Die Installation muss laufen und aus dem Installer stammen: lokale Verzeichnisse
|
||||
`postgres/` und `minio/`, interner Endpunkt `http://minio:9000`, S3-Zugang wie beim
|
||||
Installer aus `MINIO_ROOT_*`, keine externen Secret-Provider, keine zusätzlichen
|
||||
MinIO-Ports, Datenmounts oder unbekannten Container im internen Netz.
|
||||
Bekannte lokale Matrix-Dienste werden erhalten, während der Kopie angehalten und
|
||||
mit ihren Daten kalt gesichert. Andere Zusatzdienste/Compose-Overrides blockieren
|
||||
den Pilot und benötigen einen angepassten Plan.
|
||||
|
||||
Der Plan hält auch vorhandene Container-Healthzustände fest. Bereits vor der
|
||||
Migration als `unhealthy` bekannte Matrix-Nebendienste müssen beim Wiederanlauf
|
||||
wieder laufen, blockieren aber nicht allein durch denselben Healthfehler die
|
||||
FEDEO-Freigabe. FEDEO-Kerndienste und zuvor gesunde Matrix-Dienste müssen weiterhin
|
||||
ihre Healthchecks bestehen; erforderliche Dienstabhängigkeiten bleiben erhalten.
|
||||
Der Abschlusszustand nennt diese Altfehler unter `preexistingUnhealthyServices`.
|
||||
|
||||
Bereits durch ein früheres Update stillgelegte Matrix-Container dürfen weiterhin
|
||||
zum Compose-Projekt gehören, obwohl sie nicht mehr in dessen Konfiguration stehen.
|
||||
Der Pilot akzeptiert sie nur gestoppt und mit Neustartrichtlinie `no`, erhält ihre
|
||||
Container und Images und nimmt sie nicht wieder in Compose auf. Ihre lokalen
|
||||
Matrix-Bind-Verzeichnisse werden ebenfalls kalt gesichert. Die alten anonymen
|
||||
Redis-/Coturn-Volumes bleiben unverändert an ihren stillgelegten Containern;
|
||||
sie gehören nicht zum neuen Speicherdienst oder dessen Restore-Test.
|
||||
|
||||
Der S3-Prüfer blockiert zusätzliche Buckets, Versionierung einschließlich alter
|
||||
Versionen/Delete-Marker, Object Lock, konfigurierte Serververschlüsselung,
|
||||
Objekt-/Bucket-Tags, eigene Policies/ACLs, Lifecycle, Replikation, Benachrichtigungen,
|
||||
CORS und offene Multipart-Uploads. Unklare S3-Fehler werden nicht als „nicht vorhanden“
|
||||
gewertet. Datei-/Verzeichniskonflikte und normalisierungsgefährdete Schlüssel wie
|
||||
`a//b`, `a/../b` und Verzeichnismarker blockieren ebenfalls.
|
||||
|
||||
Die bekannten lokalen MinIO-Metadaten werden zusätzlich geprüft (`xl-single`,
|
||||
Metadatenformat 3). MinIO kann bei gleichzeitigem `a` und `a/b` einen Schlüssel
|
||||
aus der S3-Liste verbergen, obwohl er per GET erreichbar bleibt. Bei gestopptem
|
||||
MinIO muss deshalb die Menge lokaler `xl.meta`-Objektpfade exakt mit dem S3-Inventar
|
||||
übereinstimmen. Andere Speicherformate oder Abweichungen blockieren die Umschaltung.
|
||||
|
||||
Getestete Pilotgrenzen: **200.000 Objekte, höchstens 1 GiB je Objekt**.
|
||||
Die Kapazitätsabschätzung berücksichtigt Quellsicherung, neues und wiederhergestelltes
|
||||
Ziel, Metadatenreserve pro Objekt, DB-Dump, Offline-Images, Spool und 5 GiB Reserve.
|
||||
Nach dem Schreibstopp wird das Inventar erneut ermittelt. Es gibt keine Annahme,
|
||||
dass eine unbekannte freie Kapazität ausreichend ist.
|
||||
|
||||
Externe Schreiber lassen sich nicht allein anhand von Docker erkennen. Vor
|
||||
`migrate --writers-exclusive` muss feststehen, dass keine anderen Anwendungen,
|
||||
direkten DB-/S3-Clients oder manuellen Compose-Aufträge parallel schreiben.
|
||||
|
||||
## Paket erstellen und auf den Host übertragen
|
||||
|
||||
Im Entwicklungscheckout mit Docker:
|
||||
|
||||
```sh
|
||||
bash scripts/storage/build-pilot.sh
|
||||
```
|
||||
|
||||
Ergebnis: `.release/storage-pilot.tar.gz` samt `.sha256` und entpacktem Paket.
|
||||
Das Archiv enthält Hostprogramme, Dokumentation und beide benötigten Zielimages.
|
||||
Es enthält keine Instanz-Zugangsdaten, keine Kundendateien und kein neues MinIO.
|
||||
Die Build-Phase darf Images herunterladen; alle Schritte auf dem Zielhost starten
|
||||
Container mit `--pull never`.
|
||||
|
||||
Archiv und Prüfsumme über den üblichen Administrationsweg auf den Docker-Host
|
||||
kopieren. Dort die tatsächliche Prüfsumme mit der mitgelieferten vergleichen,
|
||||
beispielsweise bei Ablage unter `/root`:
|
||||
|
||||
```sh
|
||||
cd /root
|
||||
sha256sum storage-pilot.tar.gz
|
||||
tar -xzf storage-pilot.tar.gz
|
||||
cd storage-pilot
|
||||
sudo python3 pilot.py load-images
|
||||
sudo python3 pilot.py plan --directory /opt/fedeo
|
||||
```
|
||||
|
||||
Die Ausgabe von `plan` kann in den Supportdialog kopiert werden. Sie enthält
|
||||
Phase, Anwendungsversion, Objektanzahl, Objektbytes und benötigten/verfügbaren
|
||||
Platz; keine Passwörter oder Dateinamen. Falls der Installer in ein anderes
|
||||
Verzeichnis installiert wurde, bei jedem Befehl denselben Pfad angeben.
|
||||
|
||||
Ein Plan verändert keine Quelldateien oder fachlichen Daten. Er legt geschützte
|
||||
Migrationsdateien unter `.updates/storage-migration/` und einen lokalen Image-Tag
|
||||
zur Aufbewahrung des Hilfsimages an. Die `storage/`-Datenablage entsteht erst beim
|
||||
Migrationslauf. Das Paket bleibt bis zum Abschluss verfügbar; nicht mitten im
|
||||
Auftrag neu bauen oder austauschen.
|
||||
|
||||
## Wartungsfenster: kopieren, prüfen, dann freigeben
|
||||
|
||||
Erst nach Prüfung der Planausgabe und Abstimmung des Wartungsfensters:
|
||||
|
||||
```sh
|
||||
sudo python3 pilot.py migrate --directory /opt/fedeo --writers-exclusive
|
||||
sudo python3 pilot.py status --directory /opt/fedeo
|
||||
```
|
||||
|
||||
`migrate` endet bei `phase: verified`. Die Anwendung ist dann **weiter angehalten**.
|
||||
Es hat noch keine fachlichen Schreibzugriffe auf SeaweedFS gegeben.
|
||||
Der Lauf besitzt dieselbe exklusive Sperre wie der Host-Updater.
|
||||
|
||||
Die Umschaltung ist ein eigener Befehl:
|
||||
|
||||
```sh
|
||||
sudo python3 pilot.py activate --directory /opt/fedeo
|
||||
```
|
||||
|
||||
`activate` liest Quelle und Ziel nochmals vollständig, stoppt MinIO, entfernt
|
||||
dessen internen Netzwerkanschluss, sichert SeaweedFS kalt und startet einen
|
||||
separaten Restore-Container mit frisch entpackten Daten. Alle Objekt-Hashes und
|
||||
Metadaten müssen dort stimmen. Danach erhält das aktive Ziel allein den Alias
|
||||
`minio`, und die S3-Vertragstests laufen erneut, auch mit dem Original-Backend-SDK.
|
||||
|
||||
**Vor dem Start irgendeines fachlichen Schreibers** persistiert das Programm
|
||||
`writesReleased: true`. Erst danach werden Backend/Hintergrunddienste und zuletzt
|
||||
der öffentliche Proxy gestartet. Ein Fehler beim Neustart führt nicht zu einem
|
||||
automatischen Rückfall auf den nun möglicherweise veralteten Quellspeicher.
|
||||
|
||||
Anschließend auf der Instanz prüfen: Anmeldung, bestehender Download, PDF-Vorschau,
|
||||
neuer Upload mit erneutem Download, WebDAV, DATEV/SEPA und Mandantenexport/-import.
|
||||
Bei zwei Testmandanten auch die unveränderten Zugriffsgrenzen kontrollieren.
|
||||
Bereits ausgegebene S3-Links sind zusätzlich über ihren tatsächlichen Zugriffsweg
|
||||
zu prüfen; ein interner Docker-Alias macht interne URLs nicht öffentlich erreichbar.
|
||||
|
||||
## Fehler und Wiederaufnahme
|
||||
|
||||
| Zustand | Vorgehen |
|
||||
| --- | --- |
|
||||
| `planned` | Instanz läuft noch; Wartungsfenster vorbereiten. |
|
||||
| `freezing`, `backing_up`, `backed_up`, `copying`, `verifying`, `verified` | `migrate --writers-exclusive` setzt fort. Quelle, Schreibstopp, Backups und Inhalte werden erneut geprüft. |
|
||||
| `cutover`, `restore_testing` | `activate` setzt die noch nicht freigegebene Umschaltung fort; alternativ Rückkehr. |
|
||||
| `released` nach fehlgeschlagenem Start | `resume-start` versucht denselben Zielstand erneut. Keine einfache Rückkehr mehr. |
|
||||
| `complete` | SeaweedFS aktiv; alte Daten und Backups weiter aufbewahren. |
|
||||
| `rolled_back` | MinIO wieder aktiv. Vor einem neuen Versuch einen neuen betreuten Plan anlegen; bestehende Beweise nicht löschen. |
|
||||
|
||||
Vor der Schreibfreigabe ist die Rückkehr ausführbar:
|
||||
|
||||
```sh
|
||||
sudo python3 pilot.py rollback --directory /opt/fedeo
|
||||
```
|
||||
|
||||
Sie stoppt das Ziel, stellt Compose, den alten exklusiven Netzwerkanschluss und
|
||||
die ursprünglichen Neustartrichtlinien wieder her und startet die vorher laufenden
|
||||
Container. Quelldaten und DB bleiben unverändert. Nach `writesReleased: true`
|
||||
verweigert das Programm diesen Befehl. Dann braucht eine Rückkehr eine gesonderte
|
||||
Rückmigration einschließlich Löschungen oder die bewusste Wiederherstellung eines
|
||||
zusammengehörigen Backups.
|
||||
|
||||
## Protokolle, Sicherungen und Betriebsdaten
|
||||
|
||||
`status` ist die kompakte Ausgabe ohne Geheimnisse. Geschützte Einzelheiten liegen
|
||||
unter `/opt/fedeo/.updates/storage-migration/`:
|
||||
|
||||
- `state.json`: Phase, Container-/Image-Identitäten und dauerhafte Schreibgrenze.
|
||||
- `commands.log`, `progress.jsonl`: Hostfehler und letzter Werkzeugfortschritt.
|
||||
- `copy/inventory.json`, `copy/objects.jsonl`, `copy/verified.json`: vollständiges
|
||||
Inventar und überprüfte Objekt-Hashes. Diese Dateien enthalten Dateinamen/Metadaten.
|
||||
- `backup/database.dump`, `globals.sql`, `files.tar`, `images.tar`, `complete.json`:
|
||||
zusammengehörige Quellsicherung mit Prüfsummen. Der DB-Dump wird mit `pg_restore --list`
|
||||
geprüft; ein vollständiger fachlicher DB-Restore bleibt eine gesonderte DR-Prüfung.
|
||||
- `backup/seaweedfs.tar`: kalt gesicherter neuer Speicher samt Konfiguration;
|
||||
sein SHA-256 und erfolgreicher echter Restore werden im Zustand festgehalten.
|
||||
|
||||
Die neue persistente Ablage ist `storage/seaweedfs/`, die geschützte Konfiguration
|
||||
`storage/config/`. SeaweedFS bindet Verwaltung, Filer und Volume-APIs an Loopback
|
||||
im Container; im internen Docker-Netz liegt ausschließlich S3 auf Port 9000.
|
||||
Ein Startwrapper verweigert fehlende oder veränderte Zugangskonfiguration.
|
||||
Die Anwendungsidentität erhält nur Lese-, Schreib- und Listenrechte für ihren Bucket.
|
||||
|
||||
Für spätere Sicherungen **SeaweedFS anhalten und `storage/` vollständig sichern**,
|
||||
einschließlich Filer-Metadaten, Masterzustand, Objektdaten, Konfiguration und passendem
|
||||
Image. Die DB-Sicherung muss zum selben Schreibstopp gehören. Die Sicherungen auf
|
||||
demselben Host anschließend auf das vorgesehene externe Backupziel übernehmen.
|
||||
MinIO-Daten, alte Container, Test-Restores und Offline-Images werden vom Pilot
|
||||
nicht automatisch gelöscht.
|
||||
|
||||
## An den regulären Updater übergeben
|
||||
|
||||
Erst nach `phase: complete`, erfolgreicher Anmeldung und fachlicher Prüfung eines
|
||||
bestehenden Dokuments ausführen. Die Übergabe stoppt keine FEDEO-Container:
|
||||
|
||||
```sh
|
||||
sudo python3 pilot.py adopt-updater --directory /opt/fedeo
|
||||
sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py check --directory /opt/fedeo
|
||||
```
|
||||
|
||||
Das neu erstellte Paket enthält die vier zusammengehörigen Updater-Dienstdateien
|
||||
mit Prüfsummen. `adopt-updater` installiert zuerst diesen Host-Dienst und ruft
|
||||
danach `adopt-storage` auf. Damit wird die alte Sperre erst nach Installation des
|
||||
passenden Updaters freigegeben. Bereits ausgegebene Pilotarchive bleiben unverändert;
|
||||
für eine schon umgestellte Instanz ein neues geprüftes Paket bereitstellen.
|
||||
**Dort nur `adopt-updater` ausführen, nicht erneut planen, kopieren oder aktivieren.**
|
||||
Für diese reine Übergabe ist kein erneutes Laden der Images erforderlich.
|
||||
|
||||
Die Übernahme verlangt den abgeschlossenen Migrationszustand einschließlich
|
||||
Schreibfreigabe, vollständigem Kopiernachweis, erfolgreicher S3-Abnahmen und
|
||||
prüfsummengleicher Restore-Sicherung. Compose und `.env` müssen dem Pilotstand
|
||||
entsprechen. Sie prüft außerdem die tatsächlich laufenden Anwendungsimages,
|
||||
SeaweedFS-Image, lokale Mounts, Konfigurationsdateien und internen S3-Alias sowie
|
||||
das gestoppte und getrennte ursprüngliche MinIO. Ein aktueller Lesezugriff erfolgt
|
||||
mit dem installierten Backend-SDK. Fehler lassen die Updatesperre bestehen.
|
||||
|
||||
Danach sind `check` und `apply` über den Host-Dienst und die bestehende Oberfläche
|
||||
möglich. Der erste Prüfplan benötigt keine pauschale Compose-Übernahmefreigabe.
|
||||
Der Adapter erhält SeaweedFS samt Konfiguration, Datenpfaden, Zugangsdaten und
|
||||
dem während der Migration festgehaltenen PostgreSQL-Image. Er entfernt die alten
|
||||
MinIO-Dienste aus dem heruntergeladenen Release-Compose, bevor Images geladen
|
||||
werden. Ein Anwendungsupdate lädt oder startet daher weder MinIO noch `mc`.
|
||||
Ein Wechsel der SeaweedFS- oder PostgreSQL-Version ist ein separater Vorgang.
|
||||
|
||||
Vor jeder Datenbankschemamigration prüft das **Zielbackend-SDK** Schreiben,
|
||||
Lesen, MIME-Erhalt, Streaming, signierte Links, anonymen Zugriffsschutz und
|
||||
Löschen am aktiven Speicher. Ein Fehler hält die Anwendung angehalten und
|
||||
bewahrt den Wiederherstellungszustand. Die normale Anwendungsupdatesicherung
|
||||
enthält `storage/`, die Speicherbeschreibung und das exakte SeaweedFS-Image.
|
||||
Wiederherstellung und Grenzen stehen in [Selfhost-Updates](selfhost-updates.md).
|
||||
|
||||
## Nachgewiesene Tests und ausstehende Freigaben
|
||||
|
||||
Der lokale S3-Test verwendet das offizielle MinIO-Release-Binary vom 7. September
|
||||
2025 und SeaweedFS 4.47 mit der dokumentierten MIME-Korrektur. 1.005 Objekte / 1.073.752.750 Bytes,
|
||||
einschließlich 1-GiB-Grenzdatei, Pagination, Sonderzeichen, Metadaten, leeren
|
||||
Dateien, Streaming, 24-Stunden-Signaturen, fremdem Bucket und ungültigen Zugängen
|
||||
werden geprüft. Beschädigte Zielobjekte werden erkannt und beim Fortsetzen
|
||||
erneut kopiert; der kalte Restore wird vollständig gelesen und verglichen.
|
||||
Testhost: Linux, 16 logische CPUs, rund 29 GiB RAM für Docker, lokaler NVMe-Speicher.
|
||||
Die Laufzeit hängt von Objektanzahl, Datenmenge und Host ab.
|
||||
|
||||
Das Hilfs-Lockfile enthält den übernommenen vollständigen SDK-Abhängigkeitsbaum
|
||||
des Backends, nicht nur dieselbe oberste Versionsnummer. Beim Pilot wird außerdem
|
||||
das konkrete installierte Backendimage gegen das Ziel geprüft.
|
||||
|
||||
Die Host-Integrationstests verwenden echte Speicher- und PostgreSQL-Container,
|
||||
aber künstliche Anwendungsprozesse. Rückkehr vor Schreibfreigabe sowie vollständige
|
||||
Umschaltung mit Restore und gesperrter Rückkehr nach Schreibfreigabe wurden erfolgreich
|
||||
durchlaufen. Die fachliche Abnahme von FEDEO auf der
|
||||
konkreten Instanz, ein physischer Hostneustart während jeder Phase und die
|
||||
vollständige Wiederherstellung auf einem anderen Host sind damit noch nicht
|
||||
nachgewiesen. Der zusätzliche Test `scripts/update/tests/host_storage_integration.py`
|
||||
prüft Migration, Updater-Übernahme, zwei Anwendungsupdates und Wiederherstellung
|
||||
aus einer regulären Updatesicherung mit echten Speicher-/Datenbankcontainern
|
||||
und künstlichen Anwendungsimages. Deshalb bleibt der Speicherwechsel ein
|
||||
betreuter Pilot; die allgemeinen Freigabekriterien des Konzepts gelten weiter.
|
||||
51
docs/scanner-dokumentenablage.md
Normal file
51
docs/scanner-dokumentenablage.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Epson-Scanner und Dokumentenablage
|
||||
|
||||
Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scanner-Dateien des aktiven Mandanten, neueste zuerst. Die Seite aktualisiert sich alle zehn Sekunden; Suche, Öffnen und Herunterladen sind verfügbar. Jede übertragene Datei wird unverändert als ein Dokument gespeichert. Ein mehrseitiges PDF bleibt ein Dokument. Es gibt keine automatische Seitentrennung, OCR, Klassifizierung oder Rechnungsverarbeitung.
|
||||
|
||||
## In Dateien ablegen
|
||||
|
||||
1. Beim Dokument **Ablegen** wählen und bei Bedarf das Original öffnen.
|
||||
2. Einen vorhandenen FEDEO-Dateiordner oder **Dateien (Hauptebene)** auswählen. Unterordner erscheinen mit ihrem vollständigen Pfad.
|
||||
3. Einen **Dokumenttyp** aus den vorhandenen Dateitypen wählen. Neue Typen lassen sich unter **Stammdaten → Dateitypen** anlegen. Der Standardtyp eines Ordners wird vorausgewählt; ein vorgeschriebener Typ ist nicht änderbar.
|
||||
4. **In Dateien ablegen** speichert Ordner und Typ am bestehenden Original. Danach verschwindet das Dokument aus dem Eingang und ist unter **Dateien** im gewählten Ordner verfügbar. Es wird keine Kopie erzeugt und keine weitere Verarbeitung ausgelöst.
|
||||
|
||||
Nur aktive Ordner und Typen desselben Mandanten sind zulässig. Bei einem Fehler bleibt das Dokument im Eingang. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag anzulegen.
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. Als Administrator den gewünschten Mandanten auswählen und auf der Seite **Scanner einrichten → Scanner-Zugang erstellen** wählen.
|
||||
2. Zieladresse, Benutzername und das einmalig angezeigte Passwort übernehmen. Der Zugang gehört ausschließlich zu diesem Mandanten und kann nur Scanner-Dateien einliefern. Er verwendet kein persönliches FEDEO-Passwort. Ein neues Passwort ersetzt das alte; **Zugang deaktivieren** sperrt weitere Übertragungen.
|
||||
3. Am Epson ES-580W oder ES-C380W **Netzwerkordner/FTP → WebDAV (HTTPS)** wählen. In Web Config kann der Zieltyp **SharePoint (WebDAV)** heißen; dort bei separater Auswahl von HTTPS im Feld **Speichern unter** den Adressanteil ohne `https://` eintragen. Zieladresse und Zugangsdaten eintragen, Port 443 bzw. den Port der eigenen HTTPS-Adresse verwenden. Das Serverzertifikat muss vom Scanner akzeptiert werden.
|
||||
4. PDF, Duplex und beispielsweise 300 dpi einstellen. Das Ziel als **FEDEO** speichern. Dateinamen mit Datum/Uhrzeit und laufender Nummer verwenden. Für getrennte Dokumente jeweils eine eigene Datei übertragen; Vorder- und Rückseite können in einer PDF bleiben.
|
||||
|
||||
Die vollständige Adresse wird aus der konfigurierten Backend-Adresse gebildet, z. B. `https://fedeo.example/backend/scanner-inbox/`. Ein vorhandener `/backend`-Präfix muss erhalten bleiben. Es ist kein lokaler Rechner oder Geräte-Agent erforderlich.
|
||||
|
||||
Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pub/download/6371/epson637123eu.pdf) und [ES-C380W](https://download4.epson.biz/sec_pubs/es-c380w/useg/en/GUID-55C5993A-63B2-45C9-B3C9-9C6D6801CDF0.htm). Der tatsächliche Verbindungsablauf muss bei der Inbetriebnahme am Gerät geprüft werden; es wurde kein physischer Epson-Scanner getestet.
|
||||
|
||||
## Schnittstelle und Betrieb
|
||||
|
||||
- Datenbankmigrationen `0073_scanner_document_inbox` und `0074_scanner_inbox_filing` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt.
|
||||
- Der Scanner-Endpunkt liegt außerhalb der Browser-JWT-Anmeldung unter `/scanner-inbox/` und verwendet HTTP Basic mit eigenen zufälligen Zugangsdaten. Ausschließlich über HTTPS veröffentlichen. Passwörter werden nur als SHA-256-Hash gespeichert.
|
||||
- Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/<Dateiname>`. Der Empfangsordner existiert bereits; Unterordner, Lesen, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis.
|
||||
- `PUT` enthält genau eine vollständige Datei als Rohdaten, ohne Multipart-Formular. PDF, JPEG, PNG und TIFF werden anhand von Dateiendung und Signatur geprüft, maximal 100 MiB. Keine Prüfung der gesamten Dokumentstruktur auf Beschädigungen.
|
||||
- Erfolgreiches Speichern: `201`. Identische Wiederholung unter demselben Dateinamen: `204`, ohne zweiten Ablageeintrag. Anderer Inhalt unter demselben Namen: `409`; mit `If-None-Match: *` bei vorhandenem Namen: `412`. Gleicher Inhalt unter einem neuen Namen wird als weiteres Dokument akzeptiert.
|
||||
- Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; leerer Upload/ungültiger Pfad: `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden.
|
||||
- Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy.
|
||||
- Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich.
|
||||
- `GET /api/document-inbox/filing-options` liefert verfügbare Ordner und Typen. `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>" }` klassifiziert das Original und markiert den Eingang atomar als abgelegt. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.
|
||||
|
||||
## Lokaler Funktionstest
|
||||
|
||||
Mit Testzugangsdaten und einer Test-PDF gegen eine eigene Instanz:
|
||||
|
||||
```bash
|
||||
curl --user "$SCANNER_USER:$SCANNER_PASSWORD" -X PROPFIND -H 'Depth: 0' "$SCANNER_URL"
|
||||
curl --user "$SCANNER_USER:$SCANNER_PASSWORD" -H 'Content-Type: application/pdf' --upload-file test.pdf "${SCANNER_URL}test.pdf"
|
||||
```
|
||||
|
||||
Die Antwort ist zunächst `201`; eine identische Wiederholung ergibt `204`. Anschließend steht genau ein Dokument in der Ablage. Die automatisierten Integrationsprüfungen verwenden eine isolierte PostgreSQL-Datenbank und einen ersetzten Objektspeicher, niemals produktive Zugangsdaten:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx --test tests/scannerInbox.test.ts
|
||||
```
|
||||
@@ -1,25 +1,80 @@
|
||||
# Vollständige Selfhost-Updates
|
||||
|
||||
`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Der Dienst bietet bereits eine lokale Schnittstelle; Backend-Anbindung und Update-Button im Frontend folgen separat.
|
||||
`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Das Backend verbindet diese lokale Schnittstelle mit dem Update-Bereich unter Administration → Systemstatus.
|
||||
|
||||
## Voraussetzungen und Umfang
|
||||
|
||||
- Linux/amd64, Python ab 3.10, Docker Compose ab 2.24, `tar` und `du`.
|
||||
- Bestehendes Betriebsverzeichnis, standardmäßig `/opt/fedeo`, mit `.env` und `docker-compose.yml`; kein Git-Checkout.
|
||||
- Standard-Stack mit `traefik`, `db`, `minio`, `createbuckets`, `backend`, `frontend` und `node-exporter`. PostgreSQL und MinIO verwenden die lokalen Verzeichnisse `postgres/` und `minio/`.
|
||||
- Standard-Stack mit `traefik`, `db`, `minio`, `createbuckets`, `backend`, `frontend` und `node-exporter`. PostgreSQL und MinIO verwenden die lokalen Verzeichnisse `postgres/` und `minio/`. Nach geprüfter Pilotübernahme ersetzt `objectstore` (SeaweedFS) die beiden MinIO-Dienste; dessen Daten liegen unter `storage/`.
|
||||
- Als root auf dem Server ausführen: Docker-Zugriff und Leserechte für die Daten- und Zertifikatsverzeichnisse werden benötigt. Ein entfernter Docker-Daemon wird abgewiesen.
|
||||
- Die öffentliche HTTPS-Adresse muss vom Host erreichbar sein und ein gültiges Zertifikat haben.
|
||||
- Eine Wartungsunterbrechung für alle Mandanten einplanen. Vor dem Backup werden alle laufenden Dienste außer PostgreSQL angehalten, auch der öffentliche Proxy und alle im Backend laufenden Hintergrundaufgaben. Eine separate Wartungsseite folgt mit der späteren Oberflächenintegration.
|
||||
|
||||
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
|
||||
|
||||
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die sieben FEDEO-Dienste enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
|
||||
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die FEDEO-Dienste des jeweiligen Speicherproviders enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Bereits stillgelegte Matrix-Container bleiben erhalten und ihre lokalen Bind-Verzeichnisse werden weiter gesichert. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
|
||||
|
||||
Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen.
|
||||
|
||||
Die Stilllegung vor der Dateisicherung entspricht den Anforderungen an ein [PostgreSQL-Dateisystem-Backup](https://www.postgresql.org/docs/16/backup-file.html); die Container-Richtlinie wird mit [Docker `update --restart`](https://docs.docker.com/reference/cli/docker/container/update/) geändert.
|
||||
|
||||
Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf.
|
||||
Die Zielversion muss die Backend-/Frontend-Healthchecks enthalten; bei weiterhin aktivem MinIO zusätzlich feste MinIO-/mc-Digests. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf.
|
||||
|
||||
## Bereits auf SeaweedFS umgestellte Instanzen
|
||||
|
||||
Speicherumstellung und Anwendungsupdate bleiben getrennte Wartungsschritte.
|
||||
Für weitere Installer-Instanzen zuerst den [Migrationsablauf](minio-umstellung-pilot.md)
|
||||
mit Kopie, vollständiger Prüfung und echtem Restore durchführen. Er verwendet
|
||||
die vorhandenen MinIO-Images und benötigt keinen funktionierenden MinIO-Download.
|
||||
Der normale `apply`-Befehl startet keine verdeckte Speichermigration.
|
||||
Der neue Standardinstaller und Setup-Assistent erkennen vorbereitete bzw.
|
||||
durchgeführte Speicherumstellungen und verweigern dort das Überschreiben der
|
||||
Konfiguration oder ein pauschales Entfernen des Stacks. Alte Skriptstände besitzen
|
||||
diesen Schutz nicht und dürfen auf umgestellten Instanzen nicht mehr ausgeführt werden.
|
||||
|
||||
Nach Installation der **vier zusammengehörigen neuen Host-Dienstdateien** übernimmt
|
||||
der neue Updater einen vollständig abgeschlossenen Pilot:
|
||||
|
||||
```sh
|
||||
sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py adopt-storage --directory /opt/fedeo
|
||||
sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py check --directory /opt/fedeo
|
||||
```
|
||||
|
||||
Das neue Pilotpaket verbindet Dienstinstallation und Übernahme als `pilot.py
|
||||
adopt-updater`. Die Übernahme verändert keine Anwendungsimages und stoppt keine
|
||||
FEDEO-Dienste. Sie prüft Migrations-/Restore-Nachweise, Originalzustand der
|
||||
Anwendung, aktiven Speicher und S3-Lesezugriff und schreibt erst dann
|
||||
`.updates/storage-provider.json`. Die alte Sperre wird als letzter Schritt
|
||||
freigegeben. Bei Abbruch kann derselbe Befehl wiederholt werden. `resolve`
|
||||
verweigert ausdrücklich die Freigabe einer Speichermigration.
|
||||
Bei Installation aus dem Pilotpaket die CLI unter `/usr/local/lib/fedeo-updater/`
|
||||
verwenden. Eventuell noch vorhandene ältere Kopien unter `/opt/fedeo/scripts/`
|
||||
werden durch die Dienstinstallation nicht ersetzt.
|
||||
|
||||
Weitere Anwendungsupdates erzeugen das Release-Compose mit dem übernommenen
|
||||
SeaweedFS-Dienst. Image, Mounts, Startkonfiguration, interner Alias und Zugangsdaten
|
||||
bleiben erhalten; MinIO und `mc` werden weder geladen noch gestartet. Das bei der
|
||||
Pilotmigration festgehaltene PostgreSQL-Image bleibt ebenfalls erhalten. Eine
|
||||
geänderte PostgreSQL-Referenz im Release blockiert die Anwendung bis zur separaten
|
||||
Datenbankmigration. Ein Anwendungsupdate aktualisiert **nicht** die SeaweedFS-Version.
|
||||
|
||||
Das Backup enthält den Datenbankdump und die bei gestopptem SeaweedFS gesicherte
|
||||
vollständige Ablage `storage/` in `files.tar`, zusätzlich `storage-image.tar` und
|
||||
`config/.updates/storage-provider.json`. Der Platzbedarf berücksichtigt das Image.
|
||||
Vor den SQL-Migrationen wird das neue Backendimage mit seinem tatsächlichen SDK
|
||||
gegen S3 geprüft (PUT/HEAD/GET/DELETE, Streaming, MIME, 24-Stunden-Signatur und
|
||||
anonymer Zugriffsschutz). Ein unterbrochener Test protokolliert seinen eindeutigen
|
||||
Prüfschlüssel; `resolve` bereinigt ausschließlich diesen mit passender Prüfmetadatenmarkierung,
|
||||
nachdem zurückgebliebene Prüfcontainer entfernt wurden und die Anwendung wieder
|
||||
erreichbar ist. Unbekannte Dateien werden nicht gelöscht.
|
||||
|
||||
Die Oberfläche neuer FEDEO-Releases zeigt den erkannten Speicher im Updateplan
|
||||
und unterscheidet die ausstehende Speicherübergabe von einem fehlgeschlagenen
|
||||
Anwendungsupdate. Ältere Backends können den neuen Host-Dienst weiterhin nutzen;
|
||||
zusätzliche Planinformationen werden dort ausgeblendet. Nach einem Apprelease
|
||||
mit dem neuen Systemstatus ersetzt ein authentifizierter S3-Bucket-Check den
|
||||
MinIO-spezifischen Health-Endpunkt.
|
||||
|
||||
## Bereitstellung
|
||||
|
||||
@@ -75,10 +130,42 @@ Der Installer legt die Python-Module root-eigen unter `/usr/local/lib/fedeo-upda
|
||||
bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo --print-unit
|
||||
```
|
||||
|
||||
Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die spätere Backend-Anbindung muss Systemadministratoren prüfen und darf nur diese kontrollierte Schnittstelle verwenden. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Dieser Stand richtet noch keine Backend-Route und keinen Frontend-Button ein.
|
||||
Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die Selfhost-Compose-Datei bindet `/run/fedeo-updater` schreibgeschützt unter demselben Pfad im Backend ein. Das Verzeichnis statt der einzelnen Socket-Datei bleibt auch bei einem Socket-Austausch durch Dienstneustarts nutzbar. Die Standard-Backend-Container laufen als root; bei einer eigenen unprivilegierten Konfiguration benötigt der Backend-Prozess zusätzlich die Host-GID von `fedeo-updater`. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Ohne installierten Dienst bleibt die Anwendung nutzbar; der Update-Bereich meldet den Dienst als nicht erreichbar.
|
||||
|
||||
Registry-Anmeldung bei privaten Images als root durchführen. Für private Releases kann `/etc/fedeo/updater.env` root-lesbar (`0600`) `FEDEO_RELEASE_TOKEN=...` enthalten; danach den Dienst neu starten. Dieser Token wird nicht an API-Clients ausgegeben. Die Unit verwendet `/root` als Home-Verzeichnis für die Docker-Anmeldung. Der Dienst ersetzt sich bei einem FEDEO-Update nicht selbst; neue Dienstdateien zusammen bereitstellen und den Dienstinstaller erneut ausführen.
|
||||
|
||||
## Updates im Frontend und einmaliger Übergang
|
||||
|
||||
Systemadministratoren öffnen **Administration → Systemstatus → FEDEO-Updates**. „Nach Updates suchen“ prüft den neuesten stabilen Release. Die Oberfläche zeigt den laufenden Backend-Stand, die geprüfte Zielversion, den Commit, den geschätzten Backup-Platz und gegebenenfalls stillzulegende Matrix-Dienste. „Update auf … installieren“ öffnet die Bestätigung für die Wartungsunterbrechung aller Mandanten. Erst „Jetzt installieren“ nach Bestätigung startet den Auftrag.
|
||||
|
||||
Der Fortschritt wird automatisch abgefragt. Während des Stack-Neustarts ist die Verbindung vorübergehend unterbrochen; der Host arbeitet weiter. Nach Erfolg lädt „Neue Version laden“ die Oberfläche neu. Auftrags-IDs bleiben im Browser gespeichert und der Dienst liefert auch nach seinem Neustart den letzten Auftrag. Geht die Startantwort verloren, fragt die Oberfläche zuerst dieselbe ID ab. „Auftrag erneut senden“ verwendet dieselbe ID und denselben Inhalt; es wird kein zweites Update gestartet. Nach einer erneuten Anmeldung die Systemstatus-Seite wieder öffnen. Auch ohne Browser-Speicher lässt sich der aktuelle Auftrag über den Dienststatus verfolgen.
|
||||
|
||||
Für eine bestehende Instanz auf `3.1.0` ist noch ein einmaliger Übergang nötig, **nachdem ein Release mit diesem Update-Bereich veröffentlicht wurde**:
|
||||
|
||||
1. Die vier zusammengehörigen Host-Dateien aus dessen geprüften Release-Assets nach `/opt/fedeo/scripts/` übernehmen: `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`.
|
||||
2. Den Dienstinstaller erneut ausführen, damit auch `/usr/local/lib/fedeo-updater/` den aktuellen Stand erhält.
|
||||
3. Einmal per CLI auf diesen Release aktualisieren. `X.Y.Z` durch die veröffentlichte Version ersetzen:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo
|
||||
sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version X.Y.Z
|
||||
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version X.Y.Z
|
||||
```
|
||||
|
||||
Der aktuelle Updater erlaubt dabei gezielt das zusätzliche, schreibgeschützte Standard-Socket-Verzeichnis am Backend. Änderungen an bestehenden Daten-Mounts sowie abweichende oder schreibbare neue Kontroll-Mounts bleiben gesperrt. Ältere Updater besitzen diese Übergangsregel noch nicht. Ein bereits erfolgreich verwalteter Compose-Stand benötigt keine erneute `--accept-compose-replacement`-Freigabe. Falls der Prüfplan sie verlangt, zunächst die Compose-Differenz auf dem Host prüfen wie oben beschrieben.
|
||||
|
||||
Danach laufen weitere Anwendungs-Releases einschließlich Compose und Migrationen über den Button. Änderungen am Host-Dienst selbst benötigen weiterhin die gemeinsame Aktualisierung der Host-Dateien und den Dienstinstaller.
|
||||
|
||||
Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/requests/:id/logs`, `/api/admin/updates/check` und `/api/admin/updates/apply` prüfen bei jeder Anfrage die aktuellen globalen Administratorrechte aus der Datenbank. Mandantenadministratoren haben keinen Zugriff. Startanfragen benötigen zusätzlich den bereits von der Authentifizierung geprüften Bearer-JWT; Cookie-Anmeldung allein kann keine Hostaktion auslösen. Das Backend verwendet ausschließlich den festen Unix-Socket, begrenzt Antwortgröße und Wartezeit und liefert nur erlaubte Status-/Planfelder und bereinigte Protokollauszüge aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host.
|
||||
|
||||
### Update-Protokoll in der Oberfläche
|
||||
|
||||
Unter **FEDEO-Updates → Update-Protokoll anzeigen** sehen Systemadministratoren die Fehlerdetails des Host-Dienstes, den Update-Verlauf und die Werkzeugausgaben (beispielsweise Docker-Pull- oder Migrationsfehler). Bei fehlgeschlagenen oder unterbrochenen Aufträgen öffnet sich die Ansicht automatisch. Solange sie geöffnet ist, wird sie zusammen mit dem Auftragsstatus aktualisiert; „Protokoll aktualisieren“ fragt sie zusätzlich sofort ab. Während der Wartungsunterbrechung bleibt die letzte geladene Ausgabe sichtbar.
|
||||
|
||||
Pro Quelle werden höchstens die letzten 6000 UTF-8-Bytes angezeigt; gekürzte Ausgaben sind markiert. Die Bereinigung erfolgt auf dem Host vor der Übertragung: erkannte Kennwörter, Tokens, Authentifizierungsheader und URL-Zugangsdaten sowie bekannte geheime Werte aus der Umgebung und den aktuellen/gesicherten Konfigurationsdateien werden ersetzt. Die Originalprotokolle bleiben unverändert geschützt auf dem Host. Nicht als Zugangsdaten erkennbare Anwendungsdaten können weiterhin enthalten sein.
|
||||
|
||||
Bestehende Installationen benötigen für diese Ansicht **auch einen aktualisierten Host-Dienst**, zusätzlich zu Backend und Frontend. Die vier zusammengehörigen Host-Dateien aus einem geprüften Stand bereitstellen und `selfhost-update-service-install.sh` erneut ausführen, wie unter „Host-Dienst installieren“ beschrieben. Ein Anwendungsupdate allein ersetzt den Host-Dienst nicht. Die Oberfläche erkennt ältere Dienste und zeigt dafür einen konkreten Hinweis. Bereits vorhandene Aufträge bleiben lesbar; ältere Protokolle werden anhand der gespeicherten Auftrags-ID zugeordnet. Nach Aktualisierung des Host-Dienstes lässt sich auch vor einem Anwendungsupdate direkt `GET /v1/requests/<requestId>/logs` über den Unix-Socket verwenden.
|
||||
|
||||
## Lokale Dienst-API
|
||||
|
||||
Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `Content-Type: application/json`, höchstens 4096 Byte und eine vom Client erzeugte `requestId` (8–80 Zeichen, Buchstaben/Ziffern/Unterstrich/Bindestrich). Für jede neue Aktion eine neue ID verwenden; bei einem Verbindungsfehler dieselbe ID und denselben Inhalt wiederholen. Das liefert denselben Auftrag statt eines zweiten Updates, auch nach einem Dienstneustart. Eine andere Anfrage mit derselben ID wird mit `409 REQUEST_CONFLICT` abgelehnt.
|
||||
@@ -88,6 +175,7 @@ Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `C
|
||||
| `GET /v1/status` | Installierter Stand, Zustand der Update-Engine und aktueller Dienstauftrag |
|
||||
| `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` |
|
||||
| `GET /v1/requests/<requestId>` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt |
|
||||
| `GET /v1/requests/<requestId>/logs` | Bereinigte, begrenzte Auszüge aus Dienst-, Ablauf- und Werkzeugprotokoll; nur für diesen Auftrag |
|
||||
| `POST /v1/apply` | Asynchrones Update; `requestId`, feste `version` und erfolgreiche `checkRequestId` |
|
||||
|
||||
Beispiel für eine Prüfung des neuesten Releases:
|
||||
@@ -127,9 +215,9 @@ Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem
|
||||
1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates.
|
||||
2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen.
|
||||
3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`.
|
||||
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen.
|
||||
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images mit exakt gepinntem SHA-256-Digest nur laden, wenn sie lokal fehlen (`pull --policy missing`); vorhandene Tags ohne passenden Digest reichen dafür nicht aus. Bewegliche Infrastruktur-Tags weiterhin aktualisieren (`pull --policy always`). Ein fehlendes, nicht herunterladbares Image beendet den Auftrag vor der Wartung. Backup-Platz erneut prüfen. Während der anschließenden Infrastrukturstarts und Migrationen erfolgt kein weiterer Pull (`up`/`run --pull never`), auch nicht bei einer abweichenden Pull-Policy in einer Override-Datei.
|
||||
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
|
||||
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
|
||||
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, den gestoppten Dateispeicher und Zertifikate sichern. Bei SeaweedFS gehören dessen vollständige Ablage, Konfiguration und Image dazu. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
|
||||
Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr.
|
||||
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
|
||||
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
|
||||
@@ -139,6 +227,8 @@ Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `st
|
||||
|
||||
## Fehler und Wiederherstellung
|
||||
|
||||
Bei einer nicht erreichbaren Registry können bereits lokal vorhandene, exakt passende Digest-Images weiterverwendet werden. Das hilft beispielsweise bei einem `401 Unauthorized` der MinIO-Registry, wenn sowohl MinIO als auch `mc` mit den Ziel-Digests bereits auf diesem Host liegen. Der Host-Updater muss diese Pull-Logik bereits enthalten; bestehende Dienstinstallationen nach Austausch des Hostskripts mit dem Dienstinstaller aktualisieren. Veröffentlichte Release-Assets werden nicht nachträglich verändert. Für Instanzen ohne diese Images bleibt eine geprüfte Bezugsquelle notwendig. Der lokale Cache ersetzt weder Sicherheitsupdates noch eine dauerhafte Versorgung mit gepflegten Images.
|
||||
|
||||
- **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen.
|
||||
- **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt.
|
||||
- **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein.
|
||||
@@ -150,7 +240,7 @@ Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster:
|
||||
|
||||
1. Alle schreibenden Dienste und den Proxy angehalten lassen; keinen neuen Update-Auftrag starten.
|
||||
2. Prüfsummen aus `backup/complete.json` prüfen. Ursprüngliche Compose-Datei, `.env` und gegebenenfalls Override aus `backup/config/` wiederherstellen. `backup/containers.json` dokumentiert die ursprünglichen Image-IDs; insbesondere bewegliche Infrastruktur-Tags vor dem Start auf die gesicherten Image-Stände festlegen.
|
||||
3. Falls Migrationen/Infrastrukturänderungen bereits begonnen haben: PostgreSQL und Dateispeicher aus dem **zusammengehörigen** Backup wiederherstellen. Der PostgreSQL-Dump ist für `pg_restore` geeignet; `globals.sql` enthält Rollen und Kennwörter und muss beim Wiederaufbau der Datenbank berücksichtigt werden. MinIO und Zertifikate bei gestoppten Diensten aus `files.tar` wiederherstellen. Die konkrete Rücksicherung bestehender Daten erfolgt bewusst manuell und ersetzt Daten.
|
||||
3. Falls Migrationen/Infrastrukturänderungen bereits begonnen haben: PostgreSQL und Dateispeicher aus dem **zusammengehörigen** Backup wiederherstellen. Der PostgreSQL-Dump ist für `pg_restore` geeignet; `globals.sql` enthält Rollen und Kennwörter und muss beim Wiederaufbau der Datenbank berücksichtigt werden. MinIO bzw. die vollständige SeaweedFS-Ablage `storage/` und Zertifikate bei gestoppten Diensten aus `files.tar` wiederherstellen. Bei SeaweedFS auch die Speicherbeschreibung aus `backup/config/.updates/storage-provider.json` zurückspielen und das gesicherte Image mit `docker image load -i backup/storage-image.tar` laden. Die konkrete Rücksicherung bestehender Daten erfolgt bewusst manuell und ersetzt Daten. Der vor der Speicherumstellung erhaltene MinIO-Bestand gehört nicht zu einem aktuellen Anwendungsbackup; ihn keinesfalls als Abkürzung reaktivieren.
|
||||
4. Wiederhergestellten Stack mit dessen Compose-Dateien starten und Funktionen prüfen. Alternativ den Fehler der Zielversion beheben und diese erfolgreich starten.
|
||||
5. Erst nach erfolgreicher Wiederherstellung die Sperre aufheben:
|
||||
|
||||
@@ -167,6 +257,15 @@ Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus dem
|
||||
```bash
|
||||
npm test
|
||||
npm run commits:check
|
||||
cd backend && node --import tsx --test tests/selfhostUpdates.test.ts tests/systemStatusStorage.test.ts
|
||||
cd ../frontend && node --test tests/selfhost-updates.test.cjs
|
||||
```
|
||||
|
||||
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.
|
||||
|
||||
`python3 -B scripts/update/tests/host_storage_integration.py` prüft zusätzlich
|
||||
MinIO-Migration, Speicherübernahme, zwei Anwendungsupdates und Restore der
|
||||
regulären SeaweedFS-Updatesicherung mit echten Docker-Containern. Anwendung und
|
||||
Release-/HTTP-Antworten sind isolierte Testdaten. Benötigte Images und Aufbau
|
||||
stehen in `scripts/storage/README.md`. Die fachliche Abnahme des veröffentlichten
|
||||
FEDEO-Releases auf einer Testinstanz bleibt zusätzlich erforderlich.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,7 +38,14 @@ const installCommand = computed(() => 'bash ' + filename.value)
|
||||
const tokenReady = computed(() => /^fedeo_agent_[a-f0-9]{64}$/.test(token.value.trim()))
|
||||
const online = computed(() => agentSetupReady(agent.value, checkingSince.value, now.value))
|
||||
const scannerOptions = computed(() => (agent.value?.scannerNames || []).map(value => ({ label: value, value })))
|
||||
const readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && scannerOptions.value.some(item => item.value === selectedScanner.value))
|
||||
const setupScanDefaults = computed(() => profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
|
||||
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false,
|
||||
backgroundCleanup: true, ocr: true, ocrLanguage: 'deu+eng', ...agent.value?.scanDefaults,
|
||||
})
|
||||
const processingMissing = computed(() =>
|
||||
(setupScanDefaults.value.format === 'pdf' && setupScanDefaults.value.ocr && !agent.value?.capabilities?.ocr) ||
|
||||
(setupScanDefaults.value.backgroundCleanup && !agent.value?.capabilities?.backgroundCleanup))
|
||||
const readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && !processingMissing.value && scannerOptions.value.some(item => item.value === selectedScanner.value))
|
||||
const endpointPreview = computed(() => {
|
||||
try { return agentBackendUrl(backendUrl.value) + '/instance-agent/heartbeat' } catch { return '' }
|
||||
})
|
||||
@@ -129,10 +136,7 @@ const finishSetup = async () => {
|
||||
if (!agent.value || !readyToSave.value || busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const scanDefaults = profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
|
||||
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false, ...agent.value.scanDefaults,
|
||||
}
|
||||
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults })
|
||||
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults: setupScanDefaults.value })
|
||||
saved.value = true
|
||||
token.value = ''
|
||||
stopPolling()
|
||||
@@ -212,7 +216,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="space-y-4 rounded-lg bg-muted/40 p-5">
|
||||
<h3 class="font-semibold text-highlighted">Installation herunterladen</h3>
|
||||
<p class="text-sm text-muted">Die Datei enthält die Agent-Software und deine Konfiguration. Sie prüft Node.js ab Version 20, SANE und Python/Pillow und installiert fehlende Systempakete mit sudo.</p>
|
||||
<p class="text-sm text-muted">Die Datei enthält die Agent-Software und deine Konfiguration. Sie installiert bei Bedarf Node.js ab Version 20, SANE, Bildverarbeitung und OCR für Deutsch/Englisch mit sudo.</p>
|
||||
<p class="text-sm text-muted">Die Installation erfolgt in deinem Benutzerverzeichnis. Starte die Datei auf dem Rechner mit dem Scanner.</p>
|
||||
<UAlert v-if="configurationError" color="error" variant="soft" :title="configurationError" />
|
||||
<UButton icon="i-heroicons-arrow-down-tray" :disabled="!tokenReady || busy" @click="downloadInstaller">Linux-Installation herunterladen</UButton>
|
||||
@@ -253,6 +257,8 @@ onBeforeUnmount(() => {
|
||||
<UAlert v-if="!online" color="warning" variant="soft" title="Agent ist derzeit nicht verbunden" description="Agent starten und die Verbindung erneut prüfen." />
|
||||
<UAlert v-else-if="!agent?.capabilities?.scan || !scannerOptions.length" color="warning" variant="soft" title="Noch kein Scanner verfügbar"
|
||||
description="USB-Verbindung und Stromversorgung prüfen. scanimage -L muss den Scanner für denselben Linux-Benutzer anzeigen." />
|
||||
<UAlert v-else-if="processingMissing" color="warning" variant="soft" title="Scan-Verarbeitung noch nicht bereit"
|
||||
description="Für dieses Profil fehlen OCR oder Hintergrundbereinigung. Die aktuelle Linux-Installation aus Schritt 2 ausführen und anschließend den Agenten neu starten." />
|
||||
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
|
||||
<UFormField label="Scanner" required>
|
||||
<USelectMenu v-model="selectedScanner" :items="scannerOptions" value-key="value" label-key="label" class="w-full" placeholder="Erkannten Scanner auswählen" />
|
||||
@@ -263,7 +269,7 @@ onBeforeUnmount(() => {
|
||||
{ label: 'Bisherige Einstellungen / allgemeiner Scanner', value: 'existing' }
|
||||
]" value-key="value" label-key="label" class="w-full" />
|
||||
</UFormField>
|
||||
<p class="text-sm text-muted">Das iX1600-Profil speichert den gesamten Stapel mit Vorder- und Rückseiten in einem PDF. Weitere Einstellungen kannst du anschließend in der Scannerverwaltung ändern.</p>
|
||||
<p class="text-sm text-muted">Das iX1600-Profil speichert Vorder- und Rückseiten als durchsuchbares PDF. Erkannter Scannerhintergrund außerhalb des Papiers wird weiß; das A4-Format bleibt erhalten.</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Scanner erneut suchen</UButton>
|
||||
<UButton :disabled="!readyToSave" :loading="busy" @click="finishSetup">Einrichtung abschließen</UButton>
|
||||
|
||||
595
frontend/components/DashboardHome.vue
Normal file
595
frontend/components/DashboardHome.vue
Normal file
@@ -0,0 +1,595 @@
|
||||
<script setup>
|
||||
import "gridstack/dist/gridstack.min.css"
|
||||
|
||||
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
|
||||
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
|
||||
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
|
||||
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
|
||||
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
|
||||
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
|
||||
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
|
||||
|
||||
const toast = useToast()
|
||||
const tempStore = useTempStore()
|
||||
|
||||
const gridElement = ref(null)
|
||||
const grid = shallowRef(null)
|
||||
const isEditMode = ref(false)
|
||||
const manageCardsOpen = ref(false)
|
||||
const widgets = ref([])
|
||||
|
||||
let gridStackClass = null
|
||||
let persistTimeout = null
|
||||
const isSyncingGrid = ref(false)
|
||||
|
||||
const DASHBOARD_WIDGETS = [
|
||||
{
|
||||
id: "income-expense",
|
||||
title: "Einnahmen und Ausgaben",
|
||||
description: "Umsatz- und Ausgabenentwicklung",
|
||||
component: markRaw(DisplayIncomeAndExpenditure),
|
||||
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
|
||||
minW: 4,
|
||||
minH: 4
|
||||
},
|
||||
{
|
||||
id: "open-balances",
|
||||
title: "Buchhaltung",
|
||||
description: "Offene Rechnungen und Entwurfsstatus",
|
||||
component: markRaw(DisplayOpenBalances),
|
||||
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "bankaccounts",
|
||||
title: "Bank",
|
||||
description: "Kontostand und offene Zuordnungen",
|
||||
component: markRaw(DisplayBankaccounts),
|
||||
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
title: "Projekte",
|
||||
description: "Aktuelle Projektphasen",
|
||||
component: markRaw(DisplayProjectsInPhases),
|
||||
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
title: "Aufgaben",
|
||||
description: "Offene Aufgaben des aktuellen Nutzers",
|
||||
component: markRaw(DisplayOpenTasks),
|
||||
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "tax-summary",
|
||||
title: "USt aktuell",
|
||||
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
|
||||
component: markRaw(DisplayTaxSummary),
|
||||
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "bwa-summary",
|
||||
title: "BWA aktuell",
|
||||
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
|
||||
component: markRaw(DisplayBWASummary),
|
||||
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
}
|
||||
]
|
||||
|
||||
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
|
||||
|
||||
function getDefaultDashboardWidgets() {
|
||||
return DASHBOARD_WIDGETS.map((definition) => ({
|
||||
id: definition.id,
|
||||
x: definition.defaultLayout.x,
|
||||
y: definition.defaultLayout.y,
|
||||
w: definition.defaultLayout.w,
|
||||
h: definition.defaultLayout.h,
|
||||
visible: true
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeNumber(value, fallback) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function serializeWidgets(input) {
|
||||
return input.map((widget) => ({
|
||||
id: widget.id,
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: widget.w,
|
||||
h: widget.h,
|
||||
visible: widget.visible
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeDashboardWidgets(storedWidgets) {
|
||||
const storedById = new Map(
|
||||
(Array.isArray(storedWidgets) ? storedWidgets : [])
|
||||
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
|
||||
.map((widget) => [widget.id, widget])
|
||||
)
|
||||
|
||||
return DASHBOARD_WIDGETS.map((definition) => {
|
||||
const stored = storedById.get(definition.id) || {}
|
||||
|
||||
return {
|
||||
id: definition.id,
|
||||
x: normalizeNumber(stored.x, definition.defaultLayout.x),
|
||||
y: normalizeNumber(stored.y, definition.defaultLayout.y),
|
||||
w: normalizeNumber(stored.w, definition.defaultLayout.w),
|
||||
h: normalizeNumber(stored.h, definition.defaultLayout.h),
|
||||
visible: typeof stored.visible === "boolean" ? stored.visible : true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function widgetsSignature(input) {
|
||||
return JSON.stringify(serializeWidgets(input))
|
||||
}
|
||||
|
||||
function getWidgetLayout(id) {
|
||||
return widgets.value.find((widget) => widget.id === id)
|
||||
}
|
||||
|
||||
function updateWidget(id, patch) {
|
||||
widgets.value = widgets.value.map((widget) => {
|
||||
if (widget.id !== id) return widget
|
||||
return { ...widget, ...patch }
|
||||
})
|
||||
}
|
||||
|
||||
function persistWidgets() {
|
||||
tempStore.modifySettings("dashboard", {
|
||||
version: 1,
|
||||
widgets: serializeWidgets(widgets.value)
|
||||
})
|
||||
}
|
||||
|
||||
function schedulePersistWidgets() {
|
||||
clearTimeout(persistTimeout)
|
||||
persistTimeout = setTimeout(() => {
|
||||
persistWidgets()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
async function ensureGridStack() {
|
||||
if (gridStackClass) return gridStackClass
|
||||
const gridStackModule = await import("gridstack")
|
||||
gridStackClass = gridStackModule.GridStack
|
||||
return gridStackClass
|
||||
}
|
||||
|
||||
function gridOptionsFor(widget) {
|
||||
const definition = widgetDefinitions[widget.id]
|
||||
|
||||
return {
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: widget.w,
|
||||
h: widget.h,
|
||||
minW: definition.minW,
|
||||
minH: definition.minH,
|
||||
id: widget.id
|
||||
}
|
||||
}
|
||||
|
||||
function handleGridChange(_event, items = []) {
|
||||
if (isSyncingGrid.value || !isEditMode.value) return
|
||||
|
||||
let hasChanges = false
|
||||
|
||||
items.forEach((item) => {
|
||||
const widgetId = item.el?.dataset?.widgetId || item.id
|
||||
if (!widgetId) return
|
||||
|
||||
const current = getWidgetLayout(widgetId)
|
||||
if (!current) return
|
||||
|
||||
const nextPatch = {
|
||||
x: normalizeNumber(item.x, current.x),
|
||||
y: normalizeNumber(item.y, current.y),
|
||||
w: normalizeNumber(item.w, current.w),
|
||||
h: normalizeNumber(item.h, current.h)
|
||||
}
|
||||
|
||||
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
|
||||
return
|
||||
}
|
||||
|
||||
updateWidget(widgetId, nextPatch)
|
||||
hasChanges = true
|
||||
})
|
||||
|
||||
if (hasChanges) schedulePersistWidgets()
|
||||
}
|
||||
|
||||
function syncGridInteractivity() {
|
||||
if (!grid.value) return
|
||||
grid.value.enableMove(isEditMode.value)
|
||||
grid.value.enableResize(isEditMode.value)
|
||||
}
|
||||
|
||||
async function syncGridWithDom() {
|
||||
if (!import.meta.client) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!gridElement.value) return
|
||||
|
||||
const GridStack = await ensureGridStack()
|
||||
|
||||
if (!grid.value) {
|
||||
isSyncingGrid.value = true
|
||||
grid.value = GridStack.init(
|
||||
{
|
||||
column: 12,
|
||||
float: true,
|
||||
margin: 16,
|
||||
cellHeight: 96,
|
||||
disableOneColumnMode: false,
|
||||
handle: ".dashboard-widget-drag-handle",
|
||||
resizable: {
|
||||
handles: "all"
|
||||
}
|
||||
},
|
||||
gridElement.value
|
||||
)
|
||||
grid.value.on("change", handleGridChange)
|
||||
syncGridInteractivity()
|
||||
isSyncingGrid.value = false
|
||||
}
|
||||
|
||||
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
|
||||
|
||||
isSyncingGrid.value = true
|
||||
grid.value.batchUpdate()
|
||||
|
||||
;[...grid.value.engine.nodes].forEach((node) => {
|
||||
const widgetId = node.el?.dataset?.widgetId || node.id
|
||||
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
|
||||
if (node.el) grid.value.removeWidget(node.el, false, false)
|
||||
}
|
||||
})
|
||||
|
||||
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
|
||||
|
||||
domWidgets.forEach((element) => {
|
||||
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
|
||||
if (!widget) return
|
||||
|
||||
if (!element.gridstackNode) {
|
||||
grid.value.makeWidget(element)
|
||||
}
|
||||
|
||||
grid.value.update(element, gridOptionsFor(widget))
|
||||
})
|
||||
|
||||
grid.value.batchUpdate(false)
|
||||
isSyncingGrid.value = false
|
||||
}
|
||||
|
||||
function addWidget(id) {
|
||||
const widget = getWidgetLayout(id)
|
||||
if (!widget || widget.visible) return
|
||||
|
||||
updateWidget(id, { visible: true })
|
||||
persistWidgets()
|
||||
}
|
||||
|
||||
function toggleEditMode() {
|
||||
isEditMode.value = !isEditMode.value
|
||||
if (!isEditMode.value) manageCardsOpen.value = false
|
||||
}
|
||||
|
||||
function removeWidget(id) {
|
||||
if (visibleWidgets.value.length <= 1) {
|
||||
toast.add({
|
||||
title: "Letzte Karte",
|
||||
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
|
||||
color: "orange"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updateWidget(id, { visible: false })
|
||||
persistWidgets()
|
||||
}
|
||||
|
||||
function resetDashboard() {
|
||||
widgets.value = getDefaultDashboardWidgets()
|
||||
persistWidgets()
|
||||
toast.add({
|
||||
title: "Dashboard zurückgesetzt",
|
||||
description: "Das Standardlayout wurde wiederhergestellt.",
|
||||
color: "primary"
|
||||
})
|
||||
}
|
||||
|
||||
const visibleWidgets = computed(() =>
|
||||
widgets.value
|
||||
.filter((widget) => widget.visible)
|
||||
.map((widget) => ({
|
||||
...widgetDefinitions[widget.id],
|
||||
...widget
|
||||
}))
|
||||
)
|
||||
|
||||
const hiddenWidgets = computed(() =>
|
||||
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => tempStore.settings?.dashboard,
|
||||
(storedDashboard) => {
|
||||
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
|
||||
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
|
||||
widgets.value = nextWidgets
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
visibleWidgets,
|
||||
async () => {
|
||||
await syncGridWithDom()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(isEditMode, () => {
|
||||
syncGridInteractivity()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
|
||||
await syncGridWithDom()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(persistTimeout)
|
||||
if (grid.value) {
|
||||
grid.value.off("change", handleGridChange)
|
||||
grid.value.destroy(false)
|
||||
grid.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Home">
|
||||
<template #right>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
|
||||
:color="isEditMode ? 'primary' : 'gray'"
|
||||
:variant="isEditMode ? 'solid' : 'ghost'"
|
||||
@click="toggleEditMode"
|
||||
>
|
||||
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="isEditMode && hiddenWidgets.length > 0"
|
||||
icon="i-heroicons-plus"
|
||||
color="white"
|
||||
variant="soft"
|
||||
@click="manageCardsOpen = true"
|
||||
>
|
||||
Karte hinzufügen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="isEditMode"
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
@click="resetDashboard"
|
||||
>
|
||||
Standardlayout
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="isEditMode"
|
||||
icon="i-heroicons-squares-2x2"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
@click="manageCardsOpen = true"
|
||||
>
|
||||
Karten verwalten
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
|
||||
<div
|
||||
v-for="widget in visibleWidgets"
|
||||
:key="widget.id"
|
||||
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
|
||||
:data-widget-id="widget.id"
|
||||
:gs-x="widget.x"
|
||||
:gs-y="widget.y"
|
||||
:gs-w="widget.w"
|
||||
:gs-h="widget.h"
|
||||
:gs-min-w="widget.minW"
|
||||
:gs-min-h="widget.minH"
|
||||
>
|
||||
<div class="grid-stack-item-content dashboard-grid-item">
|
||||
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
|
||||
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
|
||||
{{ widget.title }}
|
||||
</div>
|
||||
<p class="mt-1 text-sm">
|
||||
{{ widget.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard-widget-header-actions">
|
||||
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
|
||||
<UButtonGroup v-if="isEditMode" size="xs">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
icon="i-heroicons-arrows-pointing-out"
|
||||
class="dashboard-widget-drag-handle"
|
||||
/>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
icon="i-heroicons-x-mark"
|
||||
:disabled="visibleWidgets.length <= 1"
|
||||
@click="removeWidget(widget.id)"
|
||||
/>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-widget-body">
|
||||
<component
|
||||
:is="widget.component"
|
||||
v-bind="widget.id === 'income-expense'
|
||||
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
|
||||
: {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
|
||||
<p class="text-sm">
|
||||
Es sind aktuell keine Dashboard-Karten sichtbar.
|
||||
</p>
|
||||
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
|
||||
Karte hinzufügen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="manageCardsOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold">Dashboard-Karten</h2>
|
||||
<p class="text-sm">
|
||||
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
|
||||
</p>
|
||||
</div>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
|
||||
Zurücksetzen
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="definition in DASHBOARD_WIDGETS"
|
||||
:key="definition.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ definition.title }}</p>
|
||||
<p class="text-sm">{{ definition.description }}</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="getWidgetLayout(definition.id)?.visible"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
icon="i-heroicons-minus"
|
||||
:disabled="visibleWidgets.length <= 1"
|
||||
@click="removeWidget(definition.id)"
|
||||
>
|
||||
Entfernen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-plus"
|
||||
@click="addWidget(definition.id)"
|
||||
>
|
||||
Hinzufügen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-grid {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.dashboard-grid-item {
|
||||
height: 100%;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.dashboard-widget-header {
|
||||
flex: 0 0 auto;
|
||||
padding: 1rem 1rem 0.875rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-header-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-header-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
:deep(.grid-stack-item-content) {
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -34,6 +34,9 @@ const scanForm = reactive({
|
||||
source: "ADF Duplex",
|
||||
paperSize: "A4",
|
||||
postprocess: false,
|
||||
backgroundCleanup: true,
|
||||
ocr: true,
|
||||
ocrLanguage: "deu+eng",
|
||||
postprocessProfile: "document"
|
||||
})
|
||||
|
||||
@@ -49,7 +52,8 @@ watch(() => scanForm.format, (format) => {
|
||||
const applyIx1600Preset = () => {
|
||||
Object.assign(scanForm, {
|
||||
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
|
||||
paperSize: "A4", postprocess: false, postprocessProfile: "document"
|
||||
paperSize: "A4", postprocess: false, postprocessProfile: "document",
|
||||
backgroundCleanup: true, ocr: true, ocrLanguage: "deu+eng"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,6 +87,9 @@ const applyAgentDefaults = (agent) => {
|
||||
scanForm.source = agent.scanDefaults?.source || "ADF Duplex"
|
||||
scanForm.paperSize = agent.scanDefaults?.paperSize || "A4"
|
||||
scanForm.postprocess = Boolean(agent.scanDefaults?.postprocess)
|
||||
scanForm.backgroundCleanup = agent.scanDefaults?.backgroundCleanup !== false
|
||||
scanForm.ocr = agent.scanDefaults?.ocr !== false
|
||||
scanForm.ocrLanguage = agent.scanDefaults?.ocrLanguage || "deu+eng"
|
||||
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
|
||||
if (isFeederScan.value) scanForm.format = "pdf"
|
||||
|
||||
@@ -147,6 +154,11 @@ const waitForJob = async (jobId) => {
|
||||
|
||||
const startScan = async () => {
|
||||
if (!selectedAgentId.value || scanInProgress.value) return
|
||||
if ((scanForm.format === "pdf" && scanForm.ocr && !selectedAgent.value?.capabilities?.ocr) ||
|
||||
(scanForm.backgroundCleanup && !selectedAgent.value?.capabilities?.backgroundCleanup)) {
|
||||
toast.add({ title: "Agent aktualisieren", description: "Für OCR und Hintergrundbereinigung benötigt der Scanner-Rechner die aktuelle Agent-Installation mit Tesseract und OpenCV.", color: "error" })
|
||||
return
|
||||
}
|
||||
|
||||
scanInProgress.value = true
|
||||
statusMessage.value = "Scan-Auftrag wird erstellt..."
|
||||
@@ -166,6 +178,9 @@ const startScan = async () => {
|
||||
source: scanForm.source || null,
|
||||
paperSize: scanForm.paperSize,
|
||||
postprocess: scanForm.postprocess,
|
||||
backgroundCleanup: scanForm.backgroundCleanup,
|
||||
ocr: scanForm.format === "pdf" && scanForm.ocr,
|
||||
ocrLanguage: scanForm.ocrLanguage,
|
||||
postprocessProfile: scanForm.postprocessProfile
|
||||
},
|
||||
target: {
|
||||
@@ -319,10 +334,23 @@ loadAgents()
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<UCheckbox v-model="scanForm.backgroundCleanup" label="Scannerhintergrund weiß machen" :disabled="scanInProgress" />
|
||||
<p class="text-xs text-muted">Erkannte Flächen außerhalb von Bon oder Dokument werden weiß. Das Seitenformat bleibt erhalten; bei unsicheren Rändern bleibt der Scan unverändert.</p>
|
||||
<UCheckbox v-model="scanForm.ocr" label="Durchsuchbares PDF (OCR)" :disabled="scanForm.format !== 'pdf' || scanInProgress" />
|
||||
<UFormField v-if="scanForm.ocr && scanForm.format === 'pdf'" label="OCR-Sprache">
|
||||
<USelectMenu v-model="scanForm.ocrLanguage" :items="[
|
||||
{ label: 'Deutsch und Englisch', value: 'deu+eng' },
|
||||
{ label: 'Deutsch', value: 'deu' }, { label: 'Englisch', value: 'eng' }
|
||||
]" value-key="value" label-key="label" :disabled="scanInProgress" />
|
||||
</UFormField>
|
||||
<p class="text-xs text-muted">Texterkennung erfolgt vor dem Upload auf dem Scanner-Rechner, auch für normale Dateien ohne Eingangsbeleg.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<UCheckbox
|
||||
v-model="scanForm.postprocess"
|
||||
label="OpenCV-Korrektur"
|
||||
label="Zuschnitt und Ausrichtung zusätzlich korrigieren"
|
||||
:disabled="scanInProgress"
|
||||
/>
|
||||
<UFormField label="Profil">
|
||||
|
||||
@@ -52,6 +52,11 @@ const links = computed(() => {
|
||||
to: "/wiki",
|
||||
icon: "i-heroicons-book-open"
|
||||
} : null,
|
||||
featureEnabled("files") ? {
|
||||
label: "Dokumentenablage",
|
||||
to: "/organisation/dokumentenablage",
|
||||
icon: "i-heroicons-inbox-arrow-down"
|
||||
} : null,
|
||||
featureEnabled("files") ? {
|
||||
label: "Dateien",
|
||||
to: "/files",
|
||||
|
||||
109
frontend/components/SelfhostUpdateCard.vue
Normal file
109
frontend/components/SelfhostUpdateCard.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
|
||||
logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending } = useSelfhostUpdates()
|
||||
const build = useRuntimeConfig().public
|
||||
const confirmOpen = ref(false)
|
||||
const confirmed = ref(false)
|
||||
const phaseLabels: Record<string, string> = {
|
||||
preparing: 'Update vorbereiten', validating: 'Release und Konfiguration prüfen', checked: 'Prüfung abgeschlossen',
|
||||
pulling: 'Images herunterladen', maintenance: 'Dienste anhalten und Backup erstellen', backed_up: 'Backup abgeschlossen',
|
||||
applying: 'Compose und Konfiguration aktualisieren', retiring_matrix: 'Alte Matrix-Dienste stilllegen',
|
||||
migrating: 'Datenbank migrieren', starting: 'Dienste starten', verifying: 'Version und Erreichbarkeit prüfen',
|
||||
complete: 'Abgeschlossen', resolved: 'Wiederherstellung bestätigt',
|
||||
}
|
||||
const statusLabels: Record<string, string> = {
|
||||
queued: 'Auftrag angenommen', running: 'Auftrag läuft', checked: 'Update verfügbar', up_to_date: 'FEDEO ist aktuell',
|
||||
succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen',
|
||||
idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen',
|
||||
}
|
||||
const logLabels = { service: 'Fehlerdetails', events: 'Update-Verlauf', commands: 'Werkzeugausgaben' }
|
||||
const progress = computed(() => job.value?.progress || (busy.value ? overview.value?.engine : null))
|
||||
const statusLabel = computed(() => phaseLabels[progress.value?.phase || ''] || statusLabels[job.value?.status || 'idle'])
|
||||
const backupSize = computed(() => {
|
||||
const bytes = job.value?.plan?.estimatedBackupBytes
|
||||
return bytes === undefined ? 'Wird vor dem Start geprüft' : `${(bytes / 1024 ** 3).toLocaleString('de-DE', { maximumFractionDigits: 1 })} GiB`
|
||||
})
|
||||
const openConfirmation = () => { confirmed.value = false; confirmOpen.value = true }
|
||||
const start = async () => {
|
||||
if (!confirmed.value || !canApply.value) return
|
||||
confirmOpen.value = false
|
||||
await apply()
|
||||
}
|
||||
const reload = () => window.location.reload()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-arrow-down-tray" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">FEDEO-Updates</h2>
|
||||
</div>
|
||||
<UBadge color="neutral" variant="soft">Installiert: {{ overview?.backend.version || build.fedeoVersion }}</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted">Aktualisiert die gesamte Selfhost-Instanz einschließlich Docker Compose und Datenbank. Vor Änderungen wird ein Backup erstellt.</p>
|
||||
<div role="status" aria-live="polite" class="space-y-3">
|
||||
<UAlert v-if="message" :color="busy ? 'warning' : 'neutral'" variant="soft" :title="message" />
|
||||
<UAlert v-if="recovery && overview?.storage?.migrationPending" color="warning" title="Speicherumstellung auf dem Host abschließen" description="Updates bleiben gesperrt, bis die geprüfte Migration an den neuen Host-Updater übergeben wurde. Die bestehende FEDEO-Version kann danach regulär aktualisiert werden." />
|
||||
<UAlert v-else-if="recovery" color="error" title="Wiederherstellung auf dem Host erforderlich" description="Weitere Updates sind gesperrt. Den letzten Auftrag und die Backups auf dem Host prüfen." />
|
||||
<UAlert v-if="job?.error" color="error" :title="job.error.message" />
|
||||
<UAlert v-if="job?.plan?.requiresComposeAdoption" color="warning" title="Compose-Datei zuerst auf dem Host übernehmen" description="Die bestehende Konfiguration benötigt eine manuelle Prüfung mit dem CLI-Updater. Anschließend hier erneut nach Updates suchen." />
|
||||
<p v-if="job || busy" class="text-sm font-medium">{{ statusLabel }}</p>
|
||||
<UProgress v-if="busy && !pending" aria-label="Update-Fortschritt" />
|
||||
</div>
|
||||
<dl v-if="job?.plan" class="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div><dt class="text-muted">Geprüfte Version</dt><dd class="font-medium">{{ job.plan.version }}</dd></div>
|
||||
<div><dt class="text-muted">Commit</dt><dd class="font-mono" :title="job.plan.revision">{{ job.plan.revision?.slice(0, 8) }}</dd></div>
|
||||
<div><dt class="text-muted">Geschätztes Backup</dt><dd>{{ backupSize }}</dd></div>
|
||||
</dl>
|
||||
<p v-if="job?.plan?.storageProvider === 'seaweedfs'" class="text-sm text-muted">Dateispeicher: SeaweedFS. Die geprüfte Speicherversion bleibt erhalten und wird zusammen mit der Datenbank gesichert.</p>
|
||||
<p v-else-if="job?.plan?.storageProvider === 'minio'" class="text-sm text-muted">Dateispeicher: MinIO. Eine Umstellung auf SeaweedFS erfolgt als eigener, geprüfter Wartungsschritt auf dem Host.</p>
|
||||
<p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p>
|
||||
<p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p>
|
||||
<section v-if="job?.requestId && !accessDenied" aria-label="Update-Protokoll" class="space-y-3 rounded-lg border border-default p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<UButton color="neutral" variant="ghost" icon="i-heroicons-document-text" :aria-expanded="logsOpen" @click="toggleLogs">
|
||||
{{ logsOpen ? 'Update-Protokoll ausblenden' : 'Update-Protokoll anzeigen' }}
|
||||
</UButton>
|
||||
<UButton v-if="logsOpen && overview?.capabilities?.requestLogs" color="neutral" variant="outline" size="xs" :loading="logsLoading" :disabled="!connected" @click="refreshLogs">Protokoll aktualisieren</UButton>
|
||||
</div>
|
||||
<div v-if="logsOpen" class="space-y-3">
|
||||
<p class="text-xs text-muted">Die letzten Protokolleinträge werden automatisch aktualisiert. Erkannte Zugangsdaten sind ausgeblendet.</p>
|
||||
<UAlert v-if="logsMessage" color="warning" variant="soft" :title="logsMessage" />
|
||||
<p v-else-if="!logEntries.length" class="text-sm text-muted">{{ logsLoading ? 'Protokoll wird geladen …' : 'Für diesen Auftrag liegen noch keine Protokolleinträge vor.' }}</p>
|
||||
<div v-for="entry in logEntries" :key="entry.source" class="space-y-1">
|
||||
<h3 class="text-sm font-medium">{{ logLabels[entry.source] }}</h3>
|
||||
<p v-if="entry.truncated" class="text-xs text-muted">Gekürzte Anzeige der letzten Einträge. Das vollständige Protokoll liegt auf dem Host.</p>
|
||||
<pre tabindex="0" :aria-label="logLabels[entry.source]" class="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-elevated p-3 text-xs">{{ entry.content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton icon="i-heroicons-magnifying-glass" :disabled="!available" :loading="submitting && pending?.command === 'check'" @click="check">Nach Updates suchen</UButton>
|
||||
<UButton v-if="canApply" icon="i-heroicons-arrow-down-tray" @click="openConfirmation">Update auf {{ job?.plan?.version }} installieren</UButton>
|
||||
<UButton v-if="pending && connected && !accessDenied" color="warning" :loading="submitting" @click="sendPending">Auftrag erneut senden</UButton>
|
||||
<UButton v-if="!connected && !accessDenied" color="neutral" variant="outline" :loading="refreshing" @click="refresh">Verbindung prüfen</UButton>
|
||||
<UButton v-if="job?.command === 'apply' && job.status === 'succeeded'" color="success" @click="reload">Neue Version laden</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
<UModal v-model:open="confirmOpen" title="FEDEO-Update installieren" description="Dieses Update betrifft die gesamte Instanz und alle Mandanten.">
|
||||
<template #body>
|
||||
<div class="space-y-4 text-sm">
|
||||
<p>Version <strong>{{ job?.plan?.version }}</strong> wird installiert. Dabei werden die Dienste vorübergehend angehalten, Daten gesichert und Datenbankmigrationen ausgeführt.</p>
|
||||
<p>Die Verbindung kann währenddessen abbrechen. Der Host setzt den Auftrag fort; diese Seite fragt den Status automatisch wieder ab.</p>
|
||||
<p>Falls die Migration fehlschlägt, kann eine manuelle Wiederherstellung auf dem Host erforderlich sein.</p>
|
||||
<UCheckbox v-model="confirmed" label="Die Wartungsunterbrechung für alle Mandanten ist abgestimmt." />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<UButton color="neutral" variant="outline" @click="confirmOpen = false">Abbrechen</UButton>
|
||||
<UButton :disabled="!confirmed || !canApply" :loading="submitting" @click="start">Jetzt installieren</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -144,6 +144,9 @@ export type InstanceAgent = {
|
||||
resolution?: number
|
||||
mode?: string
|
||||
source?: string | null
|
||||
backgroundCleanup?: boolean
|
||||
ocr?: boolean
|
||||
ocrLanguage?: string
|
||||
[key: string]: any
|
||||
}
|
||||
lastSeenAt?: string | null
|
||||
|
||||
189
frontend/composables/useSelfhostUpdates.ts
Normal file
189
frontend/composables/useSelfhostUpdates.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
export type UpdateState = {
|
||||
status: string; requestId?: string; command?: 'check' | 'apply'; phase?: string;
|
||||
version?: string; revision?: string; recoveryRequired?: boolean; updatedAt?: string;
|
||||
}
|
||||
export type UpdateJob = UpdateState & {
|
||||
progress?: UpdateState; error?: { code: string; message: string };
|
||||
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean;
|
||||
requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[];
|
||||
storageProvider?: 'minio' | 'seaweedfs'; storageImageRetained?: boolean };
|
||||
}
|
||||
export type UpdateLogs = { requestId: string; logs: { source: 'service' | 'events' | 'commands'; content: string; truncated: boolean }[] }
|
||||
type Overview = {
|
||||
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
|
||||
installed: { version?: string }; backend: { version: string; revision: string };
|
||||
capabilities?: { requestLogs: boolean };
|
||||
storage?: { provider: 'seaweedfs'; migrationPending: boolean };
|
||||
}
|
||||
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }
|
||||
|
||||
export const useSelfhostUpdates = () => {
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
const overview = ref<Overview | null>(null)
|
||||
const job = ref<UpdateJob | null>(null)
|
||||
const pending = ref<Submission | null>(null)
|
||||
const connected = ref(false)
|
||||
const submitting = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const message = ref('')
|
||||
const accessDenied = ref(false)
|
||||
const logs = ref<UpdateLogs | null>(null)
|
||||
const logsOpen = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const logsMessage = ref('')
|
||||
const logEntries = computed(() => logs.value?.requestId === job.value?.requestId ? logs.value?.logs || [] : [])
|
||||
let openedFailureId: string | undefined
|
||||
let stopped = false
|
||||
let generation = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const storageKey = `fedeo:update:${auth.user?.user_id || auth.user?.id || auth.user?.email}`
|
||||
const active = (value?: UpdateState | null) => ['queued', 'running'].includes(value?.status || '')
|
||||
const recovery = computed(() => !!overview.value?.engine.recoveryRequired && !active(overview.value?.engine))
|
||||
const busy = computed(() => submitting.value || !!pending.value || active(job.value) || active(overview.value?.engine))
|
||||
const available = computed(() => connected.value && !accessDenied.value && overview.value?.acceptingRequests && !busy.value && !recovery.value)
|
||||
const canApply = computed(() => !!(available.value && job.value?.command === 'check' && job.value.status === 'checked'
|
||||
&& job.value.plan?.updateAvailable && !job.value.plan.requiresComposeAdoption))
|
||||
|
||||
const persist = () => {
|
||||
// Losing localStorage must not lose the durable host job; status also returns the latest request.
|
||||
try { localStorage.setItem(storageKey, JSON.stringify({ requestId: job.value?.requestId, pending: pending.value })) } catch {}
|
||||
}
|
||||
const api = <T>(path: string, options = {}) => $api<T>(`/api/admin/updates/${path}`, {
|
||||
retry: 0, timeout: 8000, onRequestError: () => {}, onResponseError: () => {}, ...options,
|
||||
})
|
||||
const errorStatus = (error: any) => error?.statusCode || error?.response?.status
|
||||
const report = (error: any) => {
|
||||
if ([401, 403].includes(errorStatus(error))) {
|
||||
accessDenied.value = true
|
||||
message.value = 'Bitte erneut als Systemadministrator anmelden.'
|
||||
} else if (pending.value) {
|
||||
message.value = 'Der Start ist noch nicht bestätigt. Der Auftragsstatus wird weiter geprüft.'
|
||||
} else if (job.value?.command === 'apply' && active(job.value)) {
|
||||
message.value = 'Verbindung unterbrochen. Der Host führt das Update weiter aus; die Verbindung wird automatisch erneut geprüft.'
|
||||
} else {
|
||||
message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.'
|
||||
}
|
||||
}
|
||||
const refreshLogs = async () => {
|
||||
const id = job.value?.requestId
|
||||
if (!logsOpen.value || !id || stopped || accessDenied.value || logsLoading.value) return
|
||||
if (logs.value?.requestId !== id) { logs.value = null; logsMessage.value = '' }
|
||||
if (!overview.value?.capabilities?.requestLogs) {
|
||||
logsMessage.value = 'Dieser Host-Update-Dienst unterstützt die Protokollansicht noch nicht. Die zusammengehörigen Host-Update-Dateien aktualisieren und den Dienstinstaller erneut ausführen. Bis dahin liegen die Protokolle auf dem Host unter .updates/.'
|
||||
return
|
||||
}
|
||||
const currentGeneration = generation
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const result = await api<UpdateLogs>(`requests/${encodeURIComponent(id)}/logs`)
|
||||
if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return
|
||||
logs.value = result
|
||||
logsMessage.value = ''
|
||||
} catch (error: any) {
|
||||
if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return
|
||||
logsMessage.value = error?.data?.error?.message || 'Das Update-Protokoll konnte nicht geladen werden. Die Abfrage wird automatisch wiederholt.'
|
||||
if ([401, 403].includes(errorStatus(error))) { logs.value = null; report(error) }
|
||||
} finally { logsLoading.value = false }
|
||||
}
|
||||
const toggleLogs = async () => { logsOpen.value = !logsOpen.value; await refreshLogs() }
|
||||
const refresh = async () => {
|
||||
if (refreshing.value || stopped || accessDenied.value) return
|
||||
refreshing.value = true
|
||||
const currentGeneration = generation
|
||||
try {
|
||||
const latest = await api<Overview>('status')
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
overview.value = latest
|
||||
// A durable submission may have succeeded even when its HTTP response was lost.
|
||||
const ownId = pending.value?.body.requestId || job.value?.requestId
|
||||
if (ownId) {
|
||||
try {
|
||||
const result = await api<UpdateJob>(`requests/${encodeURIComponent(ownId)}`)
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
job.value = result
|
||||
pending.value = null
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
if (errorStatus(error) !== 404) throw error
|
||||
if (!pending.value) job.value = null
|
||||
}
|
||||
}
|
||||
if (!pending.value && latest.request && (active(latest.request) || !job.value)) job.value = latest.request
|
||||
connected.value = true
|
||||
message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : ''
|
||||
persist()
|
||||
if (['failed', 'interrupted'].includes(job.value?.status || '') && openedFailureId !== job.value?.requestId) {
|
||||
openedFailureId = job.value?.requestId
|
||||
logsOpen.value = true
|
||||
}
|
||||
await refreshLogs()
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
connected.value = false
|
||||
report(error)
|
||||
} finally { refreshing.value = false }
|
||||
}
|
||||
const sendPending = async () => {
|
||||
if (!pending.value || submitting.value || accessDenied.value) return
|
||||
generation++
|
||||
logs.value = null
|
||||
logsMessage.value = ''
|
||||
submitting.value = true
|
||||
const submission = pending.value
|
||||
try {
|
||||
job.value = await api<UpdateJob>(submission.command, { method: 'POST', body: submission.body })
|
||||
pending.value = null
|
||||
connected.value = true
|
||||
message.value = ''
|
||||
} catch (error: any) {
|
||||
// Only a definite rejection releases the ID. Timeouts/5xx keep it for idempotent recovery.
|
||||
if ([400, 401, 403, 404, 409, 413, 415].includes(errorStatus(error))) {
|
||||
pending.value = null
|
||||
job.value = null
|
||||
message.value = error?.data?.error?.message || 'Der Auftrag wurde abgelehnt. Bitte erneut prüfen.'
|
||||
if ([401, 403].includes(errorStatus(error))) report(error)
|
||||
} else {
|
||||
connected.value = false
|
||||
report(error)
|
||||
}
|
||||
} finally { submitting.value = false; persist() }
|
||||
await refreshLogs()
|
||||
}
|
||||
const check = async () => {
|
||||
if (!available.value) return
|
||||
pending.value = { command: 'check', body: { requestId: crypto.randomUUID() } }
|
||||
job.value = null
|
||||
persist()
|
||||
await sendPending()
|
||||
}
|
||||
const apply = async () => {
|
||||
if (!canApply.value) return
|
||||
pending.value = { command: 'apply', body: {
|
||||
requestId: crypto.randomUUID(), version: job.value!.plan!.version, checkRequestId: job.value!.requestId!,
|
||||
} }
|
||||
persist()
|
||||
await sendPending()
|
||||
}
|
||||
const poll = async () => {
|
||||
await refresh()
|
||||
if (!stopped && !accessDenied.value) timer = setTimeout(poll, busy.value || !connected.value ? 3000 : 15000)
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null')
|
||||
const validId = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(value)
|
||||
if (validId(saved?.requestId)) job.value = { status: 'unknown', requestId: saved.requestId }
|
||||
const p = saved?.pending
|
||||
if (p && ['check', 'apply'].includes(p.command) && validId(p.body?.requestId)
|
||||
&& (p.command === 'check' || (validId(p.body?.checkRequestId) && /^\d+\.\d+\.\d+$/.test(p.body?.version)))) {
|
||||
pending.value = { command: p.command, body: { requestId: p.body.requestId,
|
||||
...(p.command === 'apply' ? { version: p.body.version, checkRequestId: p.body.checkRequestId } : {}) } }
|
||||
}
|
||||
} catch {}
|
||||
void poll()
|
||||
})
|
||||
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
|
||||
return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
|
||||
logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending }
|
||||
}
|
||||
@@ -31,6 +31,9 @@ const editState = reactive({
|
||||
source: "",
|
||||
paperSize: "A4",
|
||||
postprocess: false,
|
||||
backgroundCleanup: true,
|
||||
ocr: true,
|
||||
ocrLanguage: "deu+eng",
|
||||
postprocessProfile: "document",
|
||||
})
|
||||
|
||||
@@ -69,13 +72,17 @@ const applyAgentToForm = (agent: InstanceAgent | null) => {
|
||||
editState.source = agent.scanDefaults?.source || ""
|
||||
editState.paperSize = agent.scanDefaults?.paperSize || "A4"
|
||||
editState.postprocess = Boolean(agent.scanDefaults?.postprocess)
|
||||
editState.backgroundCleanup = agent.scanDefaults?.backgroundCleanup !== false
|
||||
editState.ocr = agent.scanDefaults?.ocr !== false
|
||||
editState.ocrLanguage = agent.scanDefaults?.ocrLanguage || "deu+eng"
|
||||
editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
|
||||
}
|
||||
|
||||
const applyIx1600Preset = () => {
|
||||
Object.assign(editState, {
|
||||
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
|
||||
paperSize: "A4", postprocess: false, postprocessProfile: "document"
|
||||
paperSize: "A4", postprocess: false, postprocessProfile: "document",
|
||||
backgroundCleanup: true, ocr: true, ocrLanguage: "deu+eng"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,6 +139,9 @@ const saveAgent = async () => {
|
||||
source: editState.source || null,
|
||||
paperSize: editState.paperSize,
|
||||
postprocess: editState.postprocess,
|
||||
backgroundCleanup: editState.backgroundCleanup,
|
||||
ocr: editState.format === "pdf" && editState.ocr,
|
||||
ocrLanguage: editState.ocrLanguage,
|
||||
postprocessProfile: editState.postprocessProfile,
|
||||
},
|
||||
})
|
||||
@@ -312,8 +322,21 @@ onMounted(async () => {
|
||||
]" value-key="value" label-key="label" />
|
||||
</UFormField>
|
||||
|
||||
<div class="space-y-3">
|
||||
<UCheckbox v-model="editState.backgroundCleanup" label="Scannerhintergrund weiß machen" />
|
||||
<p class="text-xs text-muted">Erkannte Flächen außerhalb von Bon oder Dokument werden weiß. Das Seitenformat bleibt erhalten; bei unsicheren Rändern bleibt der Scan unverändert.</p>
|
||||
<UCheckbox v-model="editState.ocr" label="Durchsuchbares PDF (OCR)" :disabled="editState.format !== 'pdf'" />
|
||||
<UFormField v-if="editState.ocr && editState.format === 'pdf'" label="OCR-Sprache">
|
||||
<USelectMenu v-model="editState.ocrLanguage" :items="[
|
||||
{ label: 'Deutsch und Englisch', value: 'deu+eng' },
|
||||
{ label: 'Deutsch', value: 'deu' }, { label: 'Englisch', value: 'eng' }
|
||||
]" value-key="value" label-key="label" />
|
||||
</UFormField>
|
||||
<p class="text-xs text-muted">Texterkennung erfolgt vor dem Upload auf dem Scanner-Rechner, auch für normale Dateien ohne Eingangsbeleg.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" />
|
||||
<UCheckbox v-model="editState.postprocess" label="Zuschnitt und Ausrichtung zusätzlich korrigieren" />
|
||||
<UFormField label="Profil">
|
||||
<USelectMenu
|
||||
v-model="editState.postprocessProfile"
|
||||
|
||||
@@ -96,6 +96,7 @@ onMounted(async () => {
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<div class="space-y-6">
|
||||
<SelfhostUpdateCard v-if="auth.user?.is_admin" />
|
||||
<UAlert
|
||||
:icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
|
||||
:color="overallStatus === 'ok' ? 'success' : 'warning'"
|
||||
|
||||
@@ -169,7 +169,7 @@ const selectAccount = (id) => {
|
||||
const manualAllocationSum = ref(itemInfo.value.amount || 0)
|
||||
const allocationDescription = ref("")
|
||||
const allocationCostcentre = ref(null)
|
||||
const allocationTaxKey = ref(null)
|
||||
const allocationTaxKey = ref("40")
|
||||
const allocationAmounts = computed(() => getBankAllocationAmounts({
|
||||
bankstatement: itemInfo.value.id,
|
||||
account: accountToSave.value,
|
||||
@@ -256,7 +256,7 @@ const saveAllocation = async (allocation) => {
|
||||
customerAccountToSave.value = null
|
||||
ownAccountToSave.value = null
|
||||
allocationCostcentre.value = null
|
||||
allocationTaxKey.value = null
|
||||
allocationTaxKey.value = "40"
|
||||
allocationBookingMode.value = "expense"
|
||||
allocationDepreciationMonths.value = 36
|
||||
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
|
||||
|
||||
@@ -35,6 +35,7 @@ const guard = ref(true)
|
||||
|
||||
const itemInfo = ref({
|
||||
type: "invoices",
|
||||
state: "Entwurf",
|
||||
taxType: "Standard",
|
||||
customer: null,
|
||||
contact: null,
|
||||
@@ -332,6 +333,13 @@ const normalizeEntityId = (value) => {
|
||||
}
|
||||
return typeof value === "object" ? (value.id ?? null) : value
|
||||
}
|
||||
const normalizeUnitId = (unit) => {
|
||||
const value = unit && typeof unit === "object" ? unit.id : unit
|
||||
if (typeof value !== "number" && typeof value !== "string") return null
|
||||
|
||||
const id = Number(value)
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null
|
||||
}
|
||||
const normalizeCreatedDocumentRow = (row) => {
|
||||
let normalizedRow = row
|
||||
|
||||
@@ -358,6 +366,7 @@ const normalizeCreatedDocumentRow = (row) => {
|
||||
return {
|
||||
...normalizedRow,
|
||||
id: normalizedRow.id || uuidv4(),
|
||||
unit: normalizeUnitId(normalizedRow.unit),
|
||||
costCentre: normalizedRow.costCentre || normalizedRow.costcentre || null,
|
||||
purchasePrice: Number(normalizedRow.purchasePrice || 0),
|
||||
linkedEntitys: Array.isArray(normalizedRow.linkedEntitys) ? normalizedRow.linkedEntitys : [],
|
||||
@@ -688,6 +697,7 @@ const setupPage = async () => {
|
||||
}
|
||||
|
||||
|
||||
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
|
||||
loaded.value = true
|
||||
|
||||
}
|
||||
@@ -2034,19 +2044,8 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
|
||||
if (row.service) {
|
||||
console.log(service)
|
||||
if(service.unit) {
|
||||
row.unit = service.unit
|
||||
} else {
|
||||
let selectedService = services.value.find(i => i.id === row.service)
|
||||
console.log(selectedService)
|
||||
if(selectedService.unit?.id) {
|
||||
row.unit = selectedService.unit.id
|
||||
} else {
|
||||
row.unit = selectedService.unit
|
||||
}
|
||||
}
|
||||
row.unit = normalizeUnitId(service?.unit ?? services.value.find(i => i.id === row.service)?.unit)
|
||||
|
||||
//row.unit = service.unit ? service.unit : services.value.find(i => i.id === row.service).unit
|
||||
row.inputPrice = ((service.sellingPriceComposed.total || service.sellingPrice) ? (service.sellingPriceComposed.total || service.sellingPrice) : (services.value.find(i => i.id === row.service).sellingPriceComposed.total || services.value.find(i => i.id === row.service).sellingPrice))
|
||||
row.description = service.description ? service.description : (services.value.find(i => i.id === row.service) ? services.value.find(i => i.id === row.service).description : "")
|
||||
row.costCentre = service.costcentre || services.value.find(i => i.id === row.service)?.costcentre || itemInfo.value.costcentre
|
||||
@@ -2060,7 +2059,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
|
||||
if (row.product) {
|
||||
console.log("Product Detected")
|
||||
row.unit = product.unit ? product.unit : products.value.find(i => i.id === row.product).unit
|
||||
row.unit = normalizeUnitId(product?.unit ?? products.value.find(i => i.id === row.product)?.unit)
|
||||
row.inputPrice = (product.selling_price ? product.selling_price : products.value.find(i => i.id === row.product).selling_price)
|
||||
row.costCentre = row.costCentre || itemInfo.value.costcentre
|
||||
//row.price = Number((row.originalPrice * (1 + itemInfo.value.customSurchargePercentage /100)).toFixed(2))
|
||||
@@ -2094,7 +2093,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
<ArchiveButton
|
||||
color="error"
|
||||
type="createddocuments"
|
||||
v-if="!isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')"
|
||||
v-if="itemInfo.id && !isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')"
|
||||
variant="outline"
|
||||
@confirmed="useEntities('createddocuments').update(itemInfo.id,{archived: true}),
|
||||
router.push('/')"
|
||||
|
||||
@@ -1,598 +1,27 @@
|
||||
<script setup>
|
||||
import { setPageLayout } from "#app"
|
||||
import "gridstack/dist/gridstack.min.css"
|
||||
|
||||
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
|
||||
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
|
||||
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
|
||||
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
|
||||
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
|
||||
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
|
||||
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
|
||||
import DashboardHome from "~/components/DashboardHome.vue"
|
||||
|
||||
setPageLayout("default")
|
||||
|
||||
const toast = useToast()
|
||||
const tempStore = useTempStore()
|
||||
|
||||
const gridElement = ref(null)
|
||||
const grid = shallowRef(null)
|
||||
const isEditMode = ref(false)
|
||||
const manageCardsOpen = ref(false)
|
||||
const widgets = ref([])
|
||||
|
||||
let gridStackClass = null
|
||||
let persistTimeout = null
|
||||
const isSyncingGrid = ref(false)
|
||||
|
||||
const DASHBOARD_WIDGETS = [
|
||||
{
|
||||
id: "income-expense",
|
||||
title: "Einnahmen und Ausgaben",
|
||||
description: "Umsatz- und Ausgabenentwicklung",
|
||||
component: markRaw(DisplayIncomeAndExpenditure),
|
||||
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
|
||||
minW: 4,
|
||||
minH: 4
|
||||
},
|
||||
{
|
||||
id: "open-balances",
|
||||
title: "Buchhaltung",
|
||||
description: "Offene Rechnungen und Entwurfsstatus",
|
||||
component: markRaw(DisplayOpenBalances),
|
||||
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "bankaccounts",
|
||||
title: "Bank",
|
||||
description: "Kontostand und offene Zuordnungen",
|
||||
component: markRaw(DisplayBankaccounts),
|
||||
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
title: "Projekte",
|
||||
description: "Aktuelle Projektphasen",
|
||||
component: markRaw(DisplayProjectsInPhases),
|
||||
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
title: "Aufgaben",
|
||||
description: "Offene Aufgaben des aktuellen Nutzers",
|
||||
component: markRaw(DisplayOpenTasks),
|
||||
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "tax-summary",
|
||||
title: "USt aktuell",
|
||||
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
|
||||
component: markRaw(DisplayTaxSummary),
|
||||
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
id: "bwa-summary",
|
||||
title: "BWA aktuell",
|
||||
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
|
||||
component: markRaw(DisplayBWASummary),
|
||||
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
|
||||
minW: 3,
|
||||
minH: 3
|
||||
}
|
||||
]
|
||||
|
||||
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
|
||||
|
||||
function getDefaultDashboardWidgets() {
|
||||
return DASHBOARD_WIDGETS.map((definition) => ({
|
||||
id: definition.id,
|
||||
x: definition.defaultLayout.x,
|
||||
y: definition.defaultLayout.y,
|
||||
w: definition.defaultLayout.w,
|
||||
h: definition.defaultLayout.h,
|
||||
visible: true
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeNumber(value, fallback) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function serializeWidgets(input) {
|
||||
return input.map((widget) => ({
|
||||
id: widget.id,
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: widget.w,
|
||||
h: widget.h,
|
||||
visible: widget.visible
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeDashboardWidgets(storedWidgets) {
|
||||
const storedById = new Map(
|
||||
(Array.isArray(storedWidgets) ? storedWidgets : [])
|
||||
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
|
||||
.map((widget) => [widget.id, widget])
|
||||
)
|
||||
|
||||
return DASHBOARD_WIDGETS.map((definition) => {
|
||||
const stored = storedById.get(definition.id) || {}
|
||||
|
||||
return {
|
||||
id: definition.id,
|
||||
x: normalizeNumber(stored.x, definition.defaultLayout.x),
|
||||
y: normalizeNumber(stored.y, definition.defaultLayout.y),
|
||||
w: normalizeNumber(stored.w, definition.defaultLayout.w),
|
||||
h: normalizeNumber(stored.h, definition.defaultLayout.h),
|
||||
visible: typeof stored.visible === "boolean" ? stored.visible : true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function widgetsSignature(input) {
|
||||
return JSON.stringify(serializeWidgets(input))
|
||||
}
|
||||
|
||||
function getWidgetLayout(id) {
|
||||
return widgets.value.find((widget) => widget.id === id)
|
||||
}
|
||||
|
||||
function updateWidget(id, patch) {
|
||||
widgets.value = widgets.value.map((widget) => {
|
||||
if (widget.id !== id) return widget
|
||||
return { ...widget, ...patch }
|
||||
})
|
||||
}
|
||||
|
||||
function persistWidgets() {
|
||||
tempStore.modifySettings("dashboard", {
|
||||
version: 1,
|
||||
widgets: serializeWidgets(widgets.value)
|
||||
})
|
||||
}
|
||||
|
||||
function schedulePersistWidgets() {
|
||||
clearTimeout(persistTimeout)
|
||||
persistTimeout = setTimeout(() => {
|
||||
persistWidgets()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
async function ensureGridStack() {
|
||||
if (gridStackClass) return gridStackClass
|
||||
const gridStackModule = await import("gridstack")
|
||||
gridStackClass = gridStackModule.GridStack
|
||||
return gridStackClass
|
||||
}
|
||||
|
||||
function gridOptionsFor(widget) {
|
||||
const definition = widgetDefinitions[widget.id]
|
||||
|
||||
return {
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: widget.w,
|
||||
h: widget.h,
|
||||
minW: definition.minW,
|
||||
minH: definition.minH,
|
||||
id: widget.id
|
||||
}
|
||||
}
|
||||
|
||||
function handleGridChange(_event, items = []) {
|
||||
if (isSyncingGrid.value || !isEditMode.value) return
|
||||
|
||||
let hasChanges = false
|
||||
|
||||
items.forEach((item) => {
|
||||
const widgetId = item.el?.dataset?.widgetId || item.id
|
||||
if (!widgetId) return
|
||||
|
||||
const current = getWidgetLayout(widgetId)
|
||||
if (!current) return
|
||||
|
||||
const nextPatch = {
|
||||
x: normalizeNumber(item.x, current.x),
|
||||
y: normalizeNumber(item.y, current.y),
|
||||
w: normalizeNumber(item.w, current.w),
|
||||
h: normalizeNumber(item.h, current.h)
|
||||
}
|
||||
|
||||
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
|
||||
return
|
||||
}
|
||||
|
||||
updateWidget(widgetId, nextPatch)
|
||||
hasChanges = true
|
||||
})
|
||||
|
||||
if (hasChanges) schedulePersistWidgets()
|
||||
}
|
||||
|
||||
function syncGridInteractivity() {
|
||||
if (!grid.value) return
|
||||
grid.value.enableMove(isEditMode.value)
|
||||
grid.value.enableResize(isEditMode.value)
|
||||
}
|
||||
|
||||
async function syncGridWithDom() {
|
||||
if (!import.meta.client) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!gridElement.value) return
|
||||
|
||||
const GridStack = await ensureGridStack()
|
||||
|
||||
if (!grid.value) {
|
||||
isSyncingGrid.value = true
|
||||
grid.value = GridStack.init(
|
||||
{
|
||||
column: 12,
|
||||
float: true,
|
||||
margin: 16,
|
||||
cellHeight: 96,
|
||||
disableOneColumnMode: false,
|
||||
handle: ".dashboard-widget-drag-handle",
|
||||
resizable: {
|
||||
handles: "all"
|
||||
}
|
||||
},
|
||||
gridElement.value
|
||||
)
|
||||
grid.value.on("change", handleGridChange)
|
||||
syncGridInteractivity()
|
||||
isSyncingGrid.value = false
|
||||
}
|
||||
|
||||
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
|
||||
|
||||
isSyncingGrid.value = true
|
||||
grid.value.batchUpdate()
|
||||
|
||||
;[...grid.value.engine.nodes].forEach((node) => {
|
||||
const widgetId = node.el?.dataset?.widgetId || node.id
|
||||
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
|
||||
if (node.el) grid.value.removeWidget(node.el, false, false)
|
||||
}
|
||||
})
|
||||
|
||||
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
|
||||
|
||||
domWidgets.forEach((element) => {
|
||||
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
|
||||
if (!widget) return
|
||||
|
||||
if (!element.gridstackNode) {
|
||||
grid.value.makeWidget(element)
|
||||
}
|
||||
|
||||
grid.value.update(element, gridOptionsFor(widget))
|
||||
})
|
||||
|
||||
grid.value.batchUpdate(false)
|
||||
isSyncingGrid.value = false
|
||||
}
|
||||
|
||||
function addWidget(id) {
|
||||
const widget = getWidgetLayout(id)
|
||||
if (!widget || widget.visible) return
|
||||
|
||||
updateWidget(id, { visible: true })
|
||||
persistWidgets()
|
||||
}
|
||||
|
||||
function toggleEditMode() {
|
||||
isEditMode.value = !isEditMode.value
|
||||
if (!isEditMode.value) manageCardsOpen.value = false
|
||||
}
|
||||
|
||||
function removeWidget(id) {
|
||||
if (visibleWidgets.value.length <= 1) {
|
||||
toast.add({
|
||||
title: "Letzte Karte",
|
||||
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
|
||||
color: "orange"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updateWidget(id, { visible: false })
|
||||
persistWidgets()
|
||||
}
|
||||
|
||||
function resetDashboard() {
|
||||
widgets.value = getDefaultDashboardWidgets()
|
||||
persistWidgets()
|
||||
toast.add({
|
||||
title: "Dashboard zurückgesetzt",
|
||||
description: "Das Standardlayout wurde wiederhergestellt.",
|
||||
color: "primary"
|
||||
})
|
||||
}
|
||||
|
||||
const visibleWidgets = computed(() =>
|
||||
widgets.value
|
||||
.filter((widget) => widget.visible)
|
||||
.map((widget) => ({
|
||||
...widgetDefinitions[widget.id],
|
||||
...widget
|
||||
}))
|
||||
)
|
||||
|
||||
const hiddenWidgets = computed(() =>
|
||||
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => tempStore.settings?.dashboard,
|
||||
(storedDashboard) => {
|
||||
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
|
||||
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
|
||||
widgets.value = nextWidgets
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
visibleWidgets,
|
||||
async () => {
|
||||
await syncGridWithDom()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(isEditMode, () => {
|
||||
syncGridInteractivity()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
|
||||
await syncGridWithDom()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(persistTimeout)
|
||||
if (grid.value) {
|
||||
grid.value.off("change", handleGridChange)
|
||||
grid.value.destroy(false)
|
||||
grid.value = null
|
||||
}
|
||||
})
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Home">
|
||||
<template #right>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
|
||||
:color="isEditMode ? 'primary' : 'gray'"
|
||||
:variant="isEditMode ? 'solid' : 'ghost'"
|
||||
@click="toggleEditMode"
|
||||
>
|
||||
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="isEditMode && hiddenWidgets.length > 0"
|
||||
icon="i-heroicons-plus"
|
||||
color="white"
|
||||
variant="soft"
|
||||
@click="manageCardsOpen = true"
|
||||
>
|
||||
Karte hinzufügen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="isEditMode"
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
@click="resetDashboard"
|
||||
>
|
||||
Standardlayout
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="isEditMode"
|
||||
icon="i-heroicons-squares-2x2"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
@click="manageCardsOpen = true"
|
||||
>
|
||||
Karten verwalten
|
||||
</UButton>
|
||||
</div>
|
||||
<DashboardHome v-if="auth.activeTenant > 0" :key="auth.activeTenant" />
|
||||
<template v-else>
|
||||
<UDashboardNavbar title="Home" />
|
||||
<UDashboardPanelContent>
|
||||
<UCard class="mx-auto mt-8 max-w-lg">
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-lg font-semibold">Mandant auswählen</h1>
|
||||
<template v-if="auth.tenants.length">
|
||||
<p class="text-sm text-muted">Wähle zuerst einen Mandanten, um dessen Dashboard zu laden.</p>
|
||||
<TenantDropdown class="w-full" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
|
||||
<div
|
||||
v-for="widget in visibleWidgets"
|
||||
:key="widget.id"
|
||||
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
|
||||
:data-widget-id="widget.id"
|
||||
:gs-x="widget.x"
|
||||
:gs-y="widget.y"
|
||||
:gs-w="widget.w"
|
||||
:gs-h="widget.h"
|
||||
:gs-min-w="widget.minW"
|
||||
:gs-min-h="widget.minH"
|
||||
>
|
||||
<div class="grid-stack-item-content dashboard-grid-item">
|
||||
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
|
||||
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
|
||||
{{ widget.title }}
|
||||
</div>
|
||||
<p class="mt-1 text-sm">
|
||||
{{ widget.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard-widget-header-actions">
|
||||
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
|
||||
<UButtonGroup v-if="isEditMode" size="xs">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
icon="i-heroicons-arrows-pointing-out"
|
||||
class="dashboard-widget-drag-handle"
|
||||
/>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
icon="i-heroicons-x-mark"
|
||||
:disabled="visibleWidgets.length <= 1"
|
||||
@click="removeWidget(widget.id)"
|
||||
/>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-widget-body">
|
||||
<component
|
||||
:is="widget.component"
|
||||
v-bind="widget.id === 'income-expense'
|
||||
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
|
||||
: {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
|
||||
<p class="text-sm">
|
||||
Es sind aktuell keine Dashboard-Karten sichtbar.
|
||||
</p>
|
||||
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
|
||||
Karte hinzufügen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="manageCardsOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold">Dashboard-Karten</h2>
|
||||
<p class="text-sm">
|
||||
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
|
||||
</p>
|
||||
</div>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
|
||||
Zurücksetzen
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="definition in DASHBOARD_WIDGETS"
|
||||
:key="definition.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ definition.title }}</p>
|
||||
<p class="text-sm">{{ definition.description }}</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="getWidgetLayout(definition.id)?.visible"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
icon="i-heroicons-minus"
|
||||
:disabled="visibleWidgets.length <= 1"
|
||||
@click="removeWidget(definition.id)"
|
||||
>
|
||||
Entfernen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-plus"
|
||||
@click="addWidget(definition.id)"
|
||||
>
|
||||
Hinzufügen
|
||||
</UButton>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted">Deinem Benutzer ist noch kein Mandant zugeordnet.</p>
|
||||
<UButton v-if="auth.user?.is_admin" to="/administration/tenants" variant="outline">Mandanten verwalten</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-grid {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.dashboard-grid-item {
|
||||
height: 100%;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.dashboard-widget-header {
|
||||
flex: 0 0 auto;
|
||||
padding: 1rem 1rem 0.875rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-header-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-header-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-widget-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
:deep(.grid-stack-item-content) {
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
380
frontend/pages/organisation/dokumentenablage.vue
Normal file
380
frontend/pages/organisation/dokumentenablage.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
|
||||
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string }
|
||||
type ScannerConnection = { username: string; active: boolean; password?: string }
|
||||
type FilingFolder = { id: string; name: string; parent: string | null; standardFiletype: string | null; standardFiletypeIsOptional: boolean }
|
||||
type FilingType = { id: string; name: string }
|
||||
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
const config = useRuntimeConfig()
|
||||
const fileActions = useFiles()
|
||||
const toast = useToast()
|
||||
const documents = ref<InboxDocument[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 50
|
||||
const search = ref("")
|
||||
const loading = ref(false)
|
||||
const error = ref("")
|
||||
const setupOpen = ref(false)
|
||||
const setupLoading = ref(false)
|
||||
const setupError = ref("")
|
||||
const connection = ref<ScannerConnection | null>(null)
|
||||
const password = ref("")
|
||||
const confirmReset = ref(false)
|
||||
const scannerPath = ref("/scanner-inbox/")
|
||||
const origin = ref("")
|
||||
const filingOpen = ref(false)
|
||||
const filingDocument = ref<InboxDocument | null>(null)
|
||||
const filingLoading = ref(false)
|
||||
const filingSaving = ref(false)
|
||||
const filingError = ref("")
|
||||
const filingFolders = ref<FilingFolder[]>([])
|
||||
const filingTypes = ref<FilingType[]>([])
|
||||
const filingFolder = ref("root")
|
||||
const filingType = ref("")
|
||||
const selectedFolder = computed(() => filingFolders.value.find(folder => folder.id === filingFolder.value))
|
||||
const fixedType = computed(() => Boolean(selectedFolder.value?.standardFiletype && !selectedFolder.value.standardFiletypeIsOptional))
|
||||
const folderOptions = computed(() => {
|
||||
const byId = new Map(filingFolders.value.map(folder => [folder.id, folder]))
|
||||
const path = (folder: FilingFolder) => {
|
||||
const parts = [folder.name]
|
||||
const visited = new Set([folder.id])
|
||||
let parent = folder.parent ? byId.get(folder.parent) : undefined
|
||||
while (parent && !visited.has(parent.id)) {
|
||||
parts.unshift(parent.name)
|
||||
visited.add(parent.id)
|
||||
parent = parent.parent ? byId.get(parent.parent) : undefined
|
||||
}
|
||||
return parts.join(" / ")
|
||||
}
|
||||
return [{ id: "root", name: "Dateien (Hauptebene)" }, ...filingFolders.value
|
||||
.map(folder => ({ id: folder.id, name: path(folder) })).sort((a, b) => a.name.localeCompare(b.name, "de"))]
|
||||
})
|
||||
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !filingSaving.value
|
||||
&& (filingFolder.value === "root" || selectedFolder.value)
|
||||
&& filingTypes.value.some(type => type.id === filingType.value)
|
||||
&& (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype)))
|
||||
const isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
||||
const scannerUrl = computed(() => {
|
||||
if (!origin.value) return ""
|
||||
const base = String(config.public.apiBase || "").replace(/\/+$/, "")
|
||||
return new URL(`${base}${scannerPath.value}`, origin.value).href
|
||||
})
|
||||
let requestVersion = 0
|
||||
let setupVersion = 0
|
||||
let filingVersion = 0
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let disposed = false
|
||||
|
||||
const load = async (silent = false) => {
|
||||
const version = ++requestVersion
|
||||
clearTimeout(pollTimer)
|
||||
if (!auth.activeTenant) {
|
||||
documents.value = []
|
||||
total.value = 0
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const result = await $api<{ documents: InboxDocument[]; total: number }>("/api/document-inbox", {
|
||||
query: { limit: pageSize, offset: (page.value - 1) * pageSize, search: search.value.trim() },
|
||||
})
|
||||
if (disposed || version !== requestVersion) return
|
||||
if (page.value > 1 && (page.value - 1) * pageSize >= result.total) {
|
||||
page.value = Math.max(1, Math.ceil(result.total / pageSize))
|
||||
return load()
|
||||
}
|
||||
documents.value = result.documents
|
||||
total.value = result.total
|
||||
error.value = ""
|
||||
pollTimer = setTimeout(() => load(true), 10000)
|
||||
} catch (cause: any) {
|
||||
if (disposed || version !== requestVersion) return
|
||||
error.value = cause?.data?.error || "Die Dokumentenablage konnte nicht geladen werden."
|
||||
} finally {
|
||||
if (version === requestVersion) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openFiling = async (entry: InboxDocument) => {
|
||||
filingOpen.value = true
|
||||
filingDocument.value = entry
|
||||
filingFolder.value = "root"
|
||||
filingType.value = ""
|
||||
filingFolders.value = []
|
||||
filingTypes.value = []
|
||||
filingError.value = ""
|
||||
filingLoading.value = true
|
||||
filingSaving.value = false
|
||||
const version = ++filingVersion
|
||||
try {
|
||||
const result = await $api<{ folders: FilingFolder[]; types: FilingType[] }>("/api/document-inbox/filing-options")
|
||||
if (disposed || version !== filingVersion) return
|
||||
filingFolders.value = result.folders
|
||||
filingTypes.value = result.types
|
||||
} catch (cause: any) {
|
||||
if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Ordner und Dokumenttypen konnten nicht geladen werden."
|
||||
} finally {
|
||||
if (version === filingVersion) filingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fileDocument = async () => {
|
||||
if (!canFile.value) return
|
||||
const entry = filingDocument.value!
|
||||
const version = filingVersion
|
||||
const folder = filingFolder.value === "root" ? null : filingFolder.value
|
||||
filingSaving.value = true
|
||||
filingError.value = ""
|
||||
try {
|
||||
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value } })
|
||||
if (disposed || version !== filingVersion) return
|
||||
// Cancel pending polling responses before removing the successfully filed entry.
|
||||
requestVersion++
|
||||
clearTimeout(pollTimer)
|
||||
documents.value = documents.value.filter(document => document.id !== entry.id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
filingOpen.value = false
|
||||
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
|
||||
await load(true)
|
||||
} catch (cause: any) {
|
||||
if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht abgelegt werden."
|
||||
} finally {
|
||||
if (version === filingVersion) filingSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openSetup = async () => {
|
||||
setupOpen.value = true
|
||||
setupLoading.value = true
|
||||
setupError.value = ""
|
||||
connection.value = null
|
||||
const version = ++setupVersion
|
||||
try {
|
||||
const result = await $api<{ connection: ScannerConnection | null; path: string }>("/api/document-inbox/scanner")
|
||||
if (disposed || version !== setupVersion) return
|
||||
connection.value = result.connection
|
||||
scannerPath.value = result.path
|
||||
} catch (cause: any) {
|
||||
if (version === setupVersion) setupError.value = cause?.data?.error || "Der Scanner-Zugang konnte nicht geladen werden."
|
||||
} finally {
|
||||
if (version === setupVersion) setupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createCredentials = async () => {
|
||||
setupLoading.value = true
|
||||
setupError.value = ""
|
||||
const version = ++setupVersion
|
||||
try {
|
||||
const result = await $api<{ connection: ScannerConnection; path: string }>("/api/document-inbox/scanner/credentials", { method: "POST" })
|
||||
if (disposed || version !== setupVersion) return
|
||||
connection.value = { username: result.connection.username, active: result.connection.active }
|
||||
password.value = result.connection.password || ""
|
||||
scannerPath.value = result.path
|
||||
confirmReset.value = false
|
||||
} catch (cause: any) {
|
||||
if (version === setupVersion) setupError.value = cause?.data?.error || "Zugangsdaten konnten nicht erstellt werden."
|
||||
} finally {
|
||||
if (version === setupVersion) setupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const disableScanner = async () => {
|
||||
setupLoading.value = true
|
||||
const version = ++setupVersion
|
||||
try {
|
||||
await $api("/api/document-inbox/scanner", { method: "DELETE" })
|
||||
if (disposed || version !== setupVersion) return
|
||||
if (connection.value) connection.value.active = false
|
||||
password.value = ""
|
||||
confirmReset.value = false
|
||||
} catch (cause: any) {
|
||||
if (version === setupVersion) setupError.value = cause?.data?.error || "Der Scanner-Zugang konnte nicht deaktiviert werden."
|
||||
} finally {
|
||||
if (version === setupVersion) setupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.add({ title: "Kopiert", color: "success" })
|
||||
} catch {
|
||||
toast.add({ title: "Bitte den Wert markieren und manuell kopieren.", color: "warning" })
|
||||
}
|
||||
}
|
||||
const fileAction = async (entry: InboxDocument, download: boolean) => {
|
||||
try {
|
||||
if (download) await fileActions.downloadFile(entry.id)
|
||||
else await fileActions.openFile(entry.id)
|
||||
} catch {
|
||||
toast.add({ title: "Das Dokument konnte nicht geöffnet werden.", color: "error" })
|
||||
}
|
||||
}
|
||||
const formatSize = (size: number) => size < 1024 * 1024 ? `${Math.ceil(size / 1024)} KB` : `${(size / 1024 / 1024).toLocaleString("de-DE", { maximumFractionDigits: 1 })} MB`
|
||||
const changePage = (next: number) => { page.value = next; load() }
|
||||
|
||||
watch(search, () => {
|
||||
// Prevent an older response from briefly replacing results for a new search.
|
||||
requestVersion++
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
searchTimer = setTimeout(() => { page.value = 1; load() }, 300)
|
||||
})
|
||||
watch(setupOpen, open => {
|
||||
if (!open) { setupVersion++; password.value = ""; confirmReset.value = false }
|
||||
})
|
||||
watch(filingFolder, () => {
|
||||
const defaultType = selectedFolder.value?.standardFiletype
|
||||
if (defaultType) filingType.value = defaultType
|
||||
})
|
||||
watch(filingOpen, open => {
|
||||
if (!open) {
|
||||
filingVersion++
|
||||
filingDocument.value = null
|
||||
filingFolders.value = []
|
||||
filingTypes.value = []
|
||||
filingError.value = ""
|
||||
filingSaving.value = false
|
||||
filingLoading.value = false
|
||||
}
|
||||
})
|
||||
watch(() => auth.activeTenant, () => {
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
filingOpen.value = false
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
documents.value = []
|
||||
total.value = 0
|
||||
page.value = 1
|
||||
error.value = ""
|
||||
setupOpen.value = false
|
||||
connection.value = null
|
||||
password.value = ""
|
||||
load()
|
||||
})
|
||||
onMounted(() => { origin.value = window.location.origin; load() })
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Dokumentenablage">
|
||||
<template #right>
|
||||
<UButton v-if="isAdmin && auth.activeTenant" icon="i-heroicons-cog-6-tooth" color="neutral" variant="outline" @click="openSetup">Scanner einrichten</UButton>
|
||||
<UButton icon="i-heroicons-arrow-path" color="neutral" variant="ghost" aria-label="Dokumentenablage aktualisieren" :loading="loading" @click="load()" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<UDashboardPanelContent class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-highlighted">Eingegangene Dokumente</h1>
|
||||
<p class="mt-1 text-sm text-muted">Prüfe eingegangene Scans und lege sie mit Ordner und Dokumenttyp unter Dateien ab.</p>
|
||||
</div>
|
||||
<UInput v-model="search" aria-label="Dokumente nach Dateiname suchen" placeholder="Dateiname suchen …" icon="i-heroicons-magnifying-glass" :maxlength="200" class="w-full sm:w-72" />
|
||||
</div>
|
||||
<UAlert v-if="!auth.activeTenant" title="Bitte einen Mandanten auswählen." color="neutral" variant="subtle" />
|
||||
<UAlert v-else-if="error" :title="error" color="error" variant="subtle" />
|
||||
<div v-if="loading && !documents.length" class="space-y-3" aria-label="Dokumente werden geladen">
|
||||
<USkeleton v-for="index in 4" :key="index" class="h-16 w-full" />
|
||||
</div>
|
||||
<div v-else-if="!documents.length && !error" class="rounded-xl border border-dashed border-default px-6 py-16 text-center">
|
||||
<UIcon name="i-heroicons-inbox-arrow-down" class="mx-auto h-10 w-10 text-muted" />
|
||||
<h2 class="mt-3 font-semibold">{{ search ? 'Keine passenden Dokumente' : 'Keine offenen Dokumente' }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ search ? 'Versuche einen anderen Dateinamen.' : 'Sobald dein Scanner eine Datei überträgt, erscheint sie hier automatisch.' }}</p>
|
||||
</div>
|
||||
<div v-else-if="documents.length" class="overflow-x-auto rounded-xl border border-default">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-default bg-elevated/50 text-muted">
|
||||
<tr><th scope="col" class="px-4 py-3">Dokument</th><th scope="col" class="px-4 py-3">Eingegangen</th><th scope="col" class="px-4 py-3">Größe</th><th scope="col" class="px-4 py-3 text-right">Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="entry in documents" :key="entry.id" class="hover:bg-elevated/30">
|
||||
<td class="max-w-sm px-4 py-4"><button class="flex items-center gap-3 text-left font-medium text-highlighted hover:text-primary" @click="fileAction(entry, false)"><UIcon name="i-heroicons-document-text" class="h-5 w-5 shrink-0 text-muted" /><span class="break-all">{{ entry.name }}</span></button></td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ dayjs(entry.receivedAt).format('DD.MM.YYYY HH:mm') }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ formatSize(entry.size) }}</td>
|
||||
<td class="px-4 py-4"><div class="flex justify-end gap-1"><UButton icon="i-heroicons-folder-arrow-down" variant="soft" :aria-label="`${entry.name} ablegen`" @click="openFiling(entry)">Ablegen</UButton><UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" :aria-label="`${entry.name} öffnen`" @click="fileAction(entry, false)" /><UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" /></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="total > 0" class="flex items-center justify-between gap-3 text-sm text-muted">
|
||||
<span>{{ total }} {{ total === 1 ? 'Dokument' : 'Dokumente' }}</span>
|
||||
<div v-if="total > pageSize" class="flex items-center gap-3">
|
||||
<UButton color="neutral" variant="outline" :disabled="page <= 1 || loading" @click="changePage(page - 1)">Zurück</UButton>
|
||||
<span>Seite {{ page }} von {{ Math.ceil(total / pageSize) }}</span>
|
||||
<UButton color="neutral" variant="outline" :disabled="page * pageSize >= total || loading" @click="changePage(page + 1)">Weiter</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="filingOpen" title="In Dateien ablegen" description="Wähle den Zielordner und klassifiziere das Dokument." :dismissible="!filingSaving" :close="!filingSaving">
|
||||
<template #body>
|
||||
<form class="space-y-5" @submit.prevent="fileDocument">
|
||||
<div v-if="filingDocument" class="flex items-center justify-between gap-3 rounded-lg bg-elevated/50 p-3">
|
||||
<span class="break-all text-sm font-medium">{{ filingDocument.name }}</span>
|
||||
<UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" aria-label="Dokument öffnen" @click="fileAction(filingDocument, false)" />
|
||||
</div>
|
||||
<UAlert v-if="filingError" :title="filingError" color="error" variant="subtle" />
|
||||
<USkeleton v-if="filingLoading" class="h-32 w-full" />
|
||||
<template v-else>
|
||||
<UFormField label="Zielordner" required>
|
||||
<USelectMenu v-model="filingFolder" :items="folderOptions" value-key="id" label-key="name" :search-input="{ placeholder: 'Ordner suchen …' }" :disabled="filingSaving" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Dokumenttyp" required :help="fixedType ? 'Dieser Dokumenttyp wird vom Zielordner vorgegeben.' : 'Die Dokumenttypen entsprechen den Dateitypen in FEDEO.'">
|
||||
<USelectMenu v-model="filingType" :items="filingTypes" value-key="id" label-key="name" placeholder="Dokumenttyp wählen …" :search-input="{ placeholder: 'Dokumenttyp suchen …' }" :disabled="filingSaving || fixedType" class="w-full" />
|
||||
</UFormField>
|
||||
<UAlert v-if="!filingTypes.length && !filingError" title="Noch keine Dokumenttypen vorhanden" description="Lege zuerst unter Stammdaten → Dateitypen einen Typ an." color="warning" variant="subtle" />
|
||||
<UAlert v-else-if="fixedType && !filingTypes.some(type => type.id === filingType)" title="Der vorgegebene Dokumenttyp ist nicht verfügbar." description="Bitte die Ordnereinstellungen korrigieren oder einen anderen Zielordner wählen." color="warning" variant="subtle" />
|
||||
</template>
|
||||
<p class="text-sm text-muted">Nach dem Ablegen findest du das Dokument unter Dateien. Es verschwindet aus diesem Eingang.</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" :disabled="filingSaving" @click="filingOpen = false">Abbrechen</UButton>
|
||||
<UButton type="submit" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">In Dateien ablegen</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten.">
|
||||
<template #body>
|
||||
<div class="space-y-5">
|
||||
<UAlert v-if="setupError" :title="setupError" color="error" variant="subtle" />
|
||||
<p class="text-sm text-muted">Am Scanner „Netzwerkordner/FTP“ und als Kommunikationsmodus „WebDAV (HTTPS)“ wählen. In Web Config heißt das Ziel gegebenenfalls „SharePoint (WebDAV)“.</p>
|
||||
<UFormField label="Zieladresse">
|
||||
<div class="flex gap-2"><UInput :model-value="scannerUrl" readonly class="flex-1" /><UButton icon="i-heroicons-clipboard" color="neutral" variant="outline" aria-label="Zieladresse kopieren" @click="copy(scannerUrl)" /></div>
|
||||
</UFormField>
|
||||
<template v-if="connection">
|
||||
<UBadge :color="connection.active ? 'success' : 'neutral'" variant="subtle">{{ connection.active ? 'Scanner-Zugang aktiv' : 'Scanner-Zugang deaktiviert' }}</UBadge>
|
||||
<UFormField label="Benutzername"><div class="flex gap-2"><UInput :model-value="connection.username" readonly class="flex-1" /><UButton icon="i-heroicons-clipboard" color="neutral" variant="outline" aria-label="Benutzername kopieren" @click="copy(connection.username)" /></div></UFormField>
|
||||
<UFormField v-if="password" label="Passwort" help="Wird nur jetzt angezeigt. Bitte vor dem Schließen im Scanner hinterlegen."><div class="flex gap-2"><UInput :model-value="password" readonly class="flex-1 font-mono" autocomplete="off" /><UButton icon="i-heroicons-clipboard" color="neutral" variant="outline" aria-label="Passwort kopieren" @click="copy(password)" /></div></UFormField>
|
||||
</template>
|
||||
<p class="text-sm text-muted">PDF und Duplex einstellen und das Ziel als „FEDEO“ speichern. Jede Datei bleibt ein Dokument, auch bei mehreren Seiten. Dateinamen mit Datum, Uhrzeit oder laufender Nummer verwenden. Maximal 100 MB pro Datei.</p>
|
||||
<div v-if="confirmReset" class="space-y-3 rounded-lg border border-warning/30 bg-warning/10 p-4">
|
||||
<p class="text-sm">Das bisherige Passwort wird ungültig. Alle Scanner mit diesem Zugang benötigen anschließend das neue Passwort.</p>
|
||||
<div class="flex gap-2"><UButton :loading="setupLoading" @click="createCredentials">Neues Passwort erstellen</UButton><UButton color="neutral" variant="ghost" :disabled="setupLoading" @click="confirmReset = false">Abbrechen</UButton></div>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<UButton v-if="!connection" :loading="setupLoading" :disabled="Boolean(setupError)" @click="createCredentials">Scanner-Zugang erstellen</UButton>
|
||||
<UButton v-else color="neutral" variant="outline" :disabled="setupLoading" @click="confirmReset = true">{{ connection.active ? 'Neues Passwort erstellen' : 'Mit neuem Passwort aktivieren' }}</UButton>
|
||||
<UButton v-if="connection?.active" color="error" variant="ghost" :loading="setupLoading" @click="disableScanner">Zugang deaktivieren</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -280,7 +280,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
body: { email, password, rememberMe }
|
||||
})
|
||||
|
||||
console.log("Token: " + token)
|
||||
|
||||
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
|
||||
this.setSession(token, refreshToken, rememberMe)
|
||||
@@ -289,7 +288,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
// 2. User Daten laden
|
||||
await this.fetchMe(token)
|
||||
|
||||
console.log(this.user)
|
||||
|
||||
// 3. WICHTIG: Jetzt explizit weiterleiten!
|
||||
// Prüfen, ob der User geladen wurde
|
||||
@@ -380,7 +378,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
})
|
||||
|
||||
// ... (Deine Logik für tenants, sorting etc. bleibt gleich) ...
|
||||
console.log(me)
|
||||
this.user = me.user
|
||||
this.permissions = me.permissions
|
||||
this.tenants = me.tenants
|
||||
@@ -394,6 +391,9 @@ export const useAuthStore = defineStore("auth", {
|
||||
|
||||
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
|
||||
|
||||
this.activeTenant = null
|
||||
this.activeTenantData = null
|
||||
|
||||
if(me.activeTenant > 0) {
|
||||
const normalizedActiveTenant = String(me.activeTenant)
|
||||
this.activeTenant = normalizedActiveTenant
|
||||
@@ -417,7 +417,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
|
||||
this.scheduleSessionTimers(tokenToUse)
|
||||
|
||||
console.log(this)
|
||||
|
||||
} catch (err: any) {
|
||||
// WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert!
|
||||
@@ -477,7 +476,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
refreshToken: this.getStoredRefreshToken()
|
||||
}
|
||||
})
|
||||
console.log(res)
|
||||
|
||||
const {token, refreshToken} = res
|
||||
this.setSession(token, refreshToken, this.sessionPersistent)
|
||||
|
||||
@@ -85,13 +85,15 @@ test('stale or inactive agents cannot finish setup; fresh reports save the chose
|
||||
await ui.run('finishSetup()')
|
||||
assert.equal(ui.run('online.value'), false)
|
||||
assert.equal(ui.requests.length, 0)
|
||||
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
|
||||
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true, ocr: true, backgroundCleanup: true } })
|
||||
await ui.run('checkConnection()')
|
||||
ui.run('profile.value="ix1600"; step.value=3')
|
||||
await ui.run('finishSetup()')
|
||||
assert.equal(ui.run('saved.value'), true)
|
||||
assert.equal(ui.requests[0][2].preferredScannerName, scanner)
|
||||
assert.equal(ui.requests[0][2].scanDefaults.source, 'ADF Duplex')
|
||||
assert.equal(ui.requests[0][2].scanDefaults.ocr, true)
|
||||
assert.equal(ui.requests[0][2].scanDefaults.backgroundCleanup, true)
|
||||
assert.equal(ui.run('token.value'), '')
|
||||
assert.equal(ui.timers.size, 0)
|
||||
ui.run('openTestScan()')
|
||||
@@ -108,6 +110,16 @@ test('online agent without a detected scanner remains incomplete', async t => {
|
||||
assert.equal(ui.requests.length, 0)
|
||||
})
|
||||
|
||||
test('setup cannot finish with an agent missing the processing required by its profile', async t => {
|
||||
const ui = await setup(t, { ...baseAgent })
|
||||
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
|
||||
await ui.run('checkConnection()')
|
||||
ui.run('profile.value="ix1600"; step.value=3')
|
||||
await ui.run('finishSetup()')
|
||||
assert.equal(ui.run('processingMissing.value'), true)
|
||||
assert.equal(ui.requests.length, 0)
|
||||
})
|
||||
|
||||
test('connection errors stop automatic requests and are visible for retry', async t => {
|
||||
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } })
|
||||
ui.run('step.value=2')
|
||||
|
||||
132
frontend/tests/dashboard-session.test.cjs
Normal file
132
frontend/tests/dashboard-session.test.cjs
Normal file
@@ -0,0 +1,132 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse } = require('@vue/compiler-sfc')
|
||||
const { compile } = require('@vue/compiler-dom')
|
||||
|
||||
function mountHome(auth) {
|
||||
const requests = []
|
||||
const { descriptor } = parse(fs.readFileSync(__dirname + '/../pages/index.client.vue', 'utf8'))
|
||||
const context = vm.createContext({ useAuthStore: () => auth, setPageLayout() {} })
|
||||
vm.runInContext(descriptor.scriptSetup.content.replace(/^import .*$/gm, ''), context)
|
||||
const remove = node => {
|
||||
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||
node.parent = null
|
||||
}
|
||||
const renderer = vue.createRenderer({
|
||||
createElement: type => ({ type, children: [], parent: null }),
|
||||
createText: text => ({ text, children: [], parent: null }),
|
||||
createComment: text => ({ text, children: [], parent: null }),
|
||||
setElementText: (node, text) => { node.text = text },
|
||||
setText: (node, text) => { node.text = text },
|
||||
parentNode: node => node.parent,
|
||||
nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1] || null,
|
||||
insert(node, parent, anchor) {
|
||||
remove(node)
|
||||
node.parent = parent
|
||||
const index = anchor ? parent.children.indexOf(anchor) : -1
|
||||
if (index < 0) parent.children.push(node)
|
||||
else parent.children.splice(index, 0, node)
|
||||
},
|
||||
remove, patchProp() {},
|
||||
})
|
||||
const component = {
|
||||
setup: () => vm.runInContext('({ auth })', context),
|
||||
render: new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(vue),
|
||||
}
|
||||
const app = renderer.createApp(component)
|
||||
app.component('DashboardHome', {
|
||||
setup() {
|
||||
requests.push(auth.activeTenant)
|
||||
return () => vue.h('div', 'Dashboard ' + auth.activeTenant)
|
||||
},
|
||||
})
|
||||
for (const name of ['UDashboardNavbar', 'UDashboardPanelContent', 'UCard', 'UButton', 'TenantDropdown']) {
|
||||
app.component(name, { setup: (_, { slots }) => () => vue.h('div', slots.default?.()) })
|
||||
}
|
||||
const root = { children: [] }
|
||||
app.mount(root)
|
||||
const content = node => [node.text || '', ...node.children.map(content)].join(' ')
|
||||
return { requests, close: () => app.unmount(), text: () => content(root) }
|
||||
}
|
||||
|
||||
test('dashboard data only mounts with a tenant and remounts when the tenant changes', async t => {
|
||||
const auth = vue.reactive({ activeTenant: null, tenants: [{ id: '1' }, { id: '2' }], user: {} })
|
||||
const ui = mountHome(auth)
|
||||
t.after(ui.close)
|
||||
assert.deepEqual(ui.requests, [])
|
||||
assert.match(ui.text(), /Mandant auswählen/)
|
||||
auth.activeTenant = '1'
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(ui.requests, ['1'])
|
||||
auth.activeTenant = '2'
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(ui.requests, ['1', '2'])
|
||||
auth.activeTenant = null
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(ui.requests, ['1', '2'])
|
||||
assert.match(ui.text(), /Mandant auswählen/)
|
||||
})
|
||||
|
||||
test('an account without assigned tenants receives an explanation without loading widgets', t => {
|
||||
const ui = mountHome(vue.reactive({ activeTenant: null, tenants: [], user: { is_admin: true } }))
|
||||
t.after(ui.close)
|
||||
assert.deepEqual(ui.requests, [])
|
||||
assert.match(ui.text(), /noch kein Mandant zugeordnet/)
|
||||
assert.match(ui.text(), /Mandanten verwalten/)
|
||||
})
|
||||
|
||||
function authFixture() {
|
||||
const logs = [], calls = [], sessions = []
|
||||
const token = 'test-access-token'
|
||||
const refreshToken = 'test-refresh-token'
|
||||
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
|
||||
const context = vm.createContext({
|
||||
exports: {}, process: { client: false },
|
||||
defineStore: (_, definition) => definition,
|
||||
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
|
||||
useTempStore: () => ({ setStoredTempConfig() {} }),
|
||||
navigateTo() {},
|
||||
useNuxtApp: () => ({ $api: async (url, options) => {
|
||||
calls.push({ url, options })
|
||||
if (url === '/api/me') return me
|
||||
if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken }
|
||||
throw Error('Unexpected API request: ' + url)
|
||||
} }),
|
||||
})
|
||||
const source = fs.readFileSync(__dirname + '/../stores/auth.ts', 'utf8').replace(/^import .*$/gm, '')
|
||||
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
|
||||
const definition = context.exports.useAuthStore
|
||||
const store = definition.state()
|
||||
for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store)
|
||||
store.getStoredToken = () => token
|
||||
store.getStoredRefreshToken = () => refreshToken
|
||||
store.scheduleSessionTimers = () => {}
|
||||
store.setSession = (...args) => sessions.push(args)
|
||||
return { store, logs, calls, sessions, token, refreshToken, setMe: value => { me = value } }
|
||||
}
|
||||
|
||||
test('refreshing a session without an active tenant clears the previous tenant state', async () => {
|
||||
const f = authFixture()
|
||||
f.store.activeTenant = '12'
|
||||
f.store.activeTenantData = { id: '12', name: 'Old tenant' }
|
||||
await f.store.fetchMe()
|
||||
assert.equal(f.store.activeTenant, null)
|
||||
assert.equal(f.store.activeTenantData, null)
|
||||
assert.deepEqual(f.calls.map(call => call.url), ['/api/me'])
|
||||
})
|
||||
|
||||
test('login and tenant switching store credentials without writing them to the console', async () => {
|
||||
const f = authFixture()
|
||||
await f.store.login('test@example.com', 'test-password')
|
||||
await f.store.switchTenant('1')
|
||||
assert.equal(f.sessions.length, 2)
|
||||
assert.equal(f.sessions[0][0], f.token)
|
||||
assert.equal(f.sessions[1][1], f.refreshToken)
|
||||
const output = JSON.stringify(f.logs)
|
||||
assert.ok(!output.includes(f.token))
|
||||
assert.ok(!output.includes(f.refreshToken))
|
||||
})
|
||||
97
frontend/tests/document-editor-units.test.cjs
Normal file
97
frontend/tests/document-editor-units.test.cjs
Normal file
@@ -0,0 +1,97 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const dayjs = require('dayjs')
|
||||
const { parse } = require('@vue/compiler-sfc')
|
||||
|
||||
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8'))
|
||||
const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
|
||||
const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'setupPage', 'setRowData'])
|
||||
const source = script.statements
|
||||
.filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script))))
|
||||
.map(statement => statement.getText(script)).join('\n')
|
||||
|
||||
function fixture(unit) {
|
||||
const ref = value => ({ value })
|
||||
const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 }
|
||||
const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 }
|
||||
const context = vm.createContext({
|
||||
dayjs, uuidv4: () => 'row-id', console: { log() {} },
|
||||
products: ref([product]), services: ref([service]),
|
||||
productcategories: ref([]), servicecategories: ref([]),
|
||||
itemInfo: ref({ type: 'invoices', taxType: 'Standard', rows: [], agriculture: {} }),
|
||||
route: { params: {}, query: {} }, loaded: ref(false), templateName: ref(''),
|
||||
taxTypeItems: [{ key: 'Standard' }],
|
||||
dataStore: { documentTypesForCreation: { invoices: { labelSingle: 'Rechnung' } } },
|
||||
setupData: async () => {}, setDocumentTypeConfig() {}, setContactPersonData: async () => {},
|
||||
checkCompatibilityWithInputPrice() {}, updateCustomSurcharge() {},
|
||||
useEntities: relation => ({
|
||||
select: async () => relation === 'products' ? [product] : [service],
|
||||
selectSingle: async () => {
|
||||
if (relation === 'documenttemplates') return { name: 'Vorlage', documentType: 'invoices', templateData: { rows: [{ id: 'saved-row', mode: 'normal', product: 20, unit }] } }
|
||||
return { id: 40, type: 'invoices', taxType: 'Standard', rows: [{ id: 'saved-row', mode: 'normal', product: 20, unit }] }
|
||||
},
|
||||
}),
|
||||
})
|
||||
vm.runInContext(source, context)
|
||||
return { context, run: code => vm.runInContext(code, context) }
|
||||
}
|
||||
|
||||
const formats = [1, { id: 1, name: 'Stück' }, '1', { id: '1', name: 'Stück' }]
|
||||
|
||||
test('article and service selection resolve relation objects and legacy IDs to a selectable unit', async () => {
|
||||
for (const unit of formats) {
|
||||
const { run } = fixture(unit)
|
||||
for (const entity of ['product', 'service']) {
|
||||
run(`itemInfo.value.rows = [{ ${entity}: ${entity === 'product' ? 20 : 30} }]`)
|
||||
await run('setRowData(itemInfo.value.rows[0])')
|
||||
assert.equal(run('itemInfo.value.rows[0].unit'), 1, `${entity}: ${JSON.stringify(unit)}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('article and service modal results retain their selected unit', async () => {
|
||||
for (const unit of formats) {
|
||||
const { run } = fixture(unit)
|
||||
run('itemInfo.value.rows = [{}]')
|
||||
await run('setRowData(itemInfo.value.rows[0], null, products.value[0])')
|
||||
assert.equal(run('itemInfo.value.rows[0].unit'), 1)
|
||||
run('itemInfo.value.rows = [{}]')
|
||||
await run('setRowData(itemInfo.value.rows[0], services.value[0], null)')
|
||||
assert.equal(run('itemInfo.value.rows[0].unit'), 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('saved documents and both template entry points restore units without overwriting the stored choice', async () => {
|
||||
for (const route of [{ params: { id: 40 }, query: {} }, { params: {}, query: { templateId: 50 } }, { params: {}, query: { templateFromDocument: 40 } }]) {
|
||||
for (const unit of formats) {
|
||||
const { context, run } = fixture(unit)
|
||||
context.route = route
|
||||
context.products.value[0].unit = { id: 2, name: 'Meter' }
|
||||
await run('setupPage()')
|
||||
assert.equal(run('loaded.value'), true)
|
||||
assert.equal(run('itemInfo.value.rows[0].unit'), 1, JSON.stringify(route))
|
||||
assert.equal(run('itemInfo.value.rows[0].id'), 'saved-row')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('imported JSON rows preserve quantity, prices and the stored unit', () => {
|
||||
const { run, context } = fixture(1)
|
||||
context.rows = [JSON.stringify({ id: 'imported', mode: 'normal', unit: { id: '1' }, quantity: 2.5, price: 12.34 })]
|
||||
const row = run('normalizeCreatedDocumentRows(rows)[0]')
|
||||
assert.equal(row.unit, 1)
|
||||
assert.equal(row.id, 'imported')
|
||||
assert.equal(row.quantity, 2.5)
|
||||
assert.equal(row.price, 12.34)
|
||||
})
|
||||
|
||||
test('missing and malformed units remain unselected', () => {
|
||||
const { run, context } = fixture(1)
|
||||
for (const unit of [null, undefined, '', {}, { id: null }, 'invalid', 0, -1, true]) {
|
||||
context.row = { unit }
|
||||
assert.equal(run('normalizeCreatedDocumentRow(row).unit'), null)
|
||||
}
|
||||
})
|
||||
192
frontend/tests/document-inbox.test.cjs
Normal file
192
frontend/tests/document-inbox.test.cjs
Normal file
@@ -0,0 +1,192 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
const ts = require('../../backend/node_modules/typescript')
|
||||
|
||||
const deferred = () => {
|
||||
let resolve
|
||||
const promise = new Promise(done => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const settle = () => new Promise(setImmediate)
|
||||
|
||||
function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
const auth = vue.reactive({ activeTenant: '1', user: { is_admin: true } })
|
||||
const timers = new Map()
|
||||
let timerId = 0
|
||||
let mount
|
||||
let unmount
|
||||
const context = vm.createContext({
|
||||
require, exports: {}, URL,
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||
useAuthStore: () => auth,
|
||||
useRuntimeConfig: () => ({ public: { apiBase } }),
|
||||
useFiles: () => ({}), useToast: () => ({ add() {} }),
|
||||
useNuxtApp: () => ({ $api: api }),
|
||||
window: { location: { origin: 'https://fedeo.example' } },
|
||||
onMounted: handler => { mount = handler },
|
||||
onBeforeUnmount: handler => { unmount = handler },
|
||||
setTimeout: handler => { timers.set(++timerId, handler); return timerId },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
})
|
||||
const source = fs.readFileSync(`${__dirname}/../pages/organisation/dokumentenablage.vue`, 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, context)
|
||||
return { auth, timers, mount, close: () => unmount(), run: code => vm.runInContext(code, context) }
|
||||
}
|
||||
|
||||
test('scanner setup preserves absolute and relative backend prefixes', async t => {
|
||||
for (const base of ['https://fedeo.example/backend/', '/backend']) {
|
||||
const ui = setup(async () => ({ documents: [], total: 0 }), base)
|
||||
t.after(ui.close)
|
||||
ui.mount()
|
||||
await settle()
|
||||
assert.equal(ui.run('scannerUrl.value'), 'https://fedeo.example/backend/scanner-inbox/')
|
||||
}
|
||||
})
|
||||
|
||||
test('a slow response from the previous tenant cannot expose its documents', async t => {
|
||||
const previous = deferred()
|
||||
let calls = 0
|
||||
const ui = setup(() => ++calls === 1 ? previous.promise : Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 }))
|
||||
t.after(ui.close)
|
||||
ui.mount()
|
||||
ui.auth.activeTenant = '2'
|
||||
await settle()
|
||||
previous.resolve({ documents: [{ id: 'tenant-1-secret' }], total: 1 })
|
||||
await settle()
|
||||
assert.equal(ui.run('documents.value[0].id'), 'tenant-2')
|
||||
})
|
||||
|
||||
test('credential responses are discarded on tenant switches and passwords clear on closing', async t => {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => url.endsWith('/credentials') ? pending.promise : Promise.resolve({ documents: [], total: 0, connection: null, path: '/scanner-inbox/' }))
|
||||
t.after(ui.close)
|
||||
ui.mount()
|
||||
await ui.run('openSetup()')
|
||||
const creation = ui.run('createCredentials()')
|
||||
ui.auth.activeTenant = '2'
|
||||
await settle()
|
||||
pending.resolve({ connection: { username: 'previous-tenant', password: 'never-display', active: true }, path: '/scanner-inbox/' })
|
||||
await creation
|
||||
assert.equal(ui.run('password.value'), '')
|
||||
assert.equal(ui.run('connection.value'), null)
|
||||
assert.equal(ui.run('setupOpen.value'), false)
|
||||
ui.run('setupOpen.value = true')
|
||||
await settle()
|
||||
ui.run('password.value = "shown-once"; setupOpen.value = false')
|
||||
await settle()
|
||||
assert.equal(ui.run('password.value'), '')
|
||||
})
|
||||
|
||||
test('unmounting cancels polling and ignores in-flight responses', async () => {
|
||||
const pending = deferred()
|
||||
const ui = setup(() => pending.promise)
|
||||
ui.mount()
|
||||
ui.close()
|
||||
pending.resolve({ documents: [{ id: 'late' }], total: 1 })
|
||||
await settle()
|
||||
assert.equal(ui.run('documents.value.length'), 0)
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
const filingOptions = {
|
||||
folders: [
|
||||
{ id: 'parent', name: 'Buchhaltung', parent: null, standardFiletype: null, standardFiletypeIsOptional: true },
|
||||
{ id: 'folder', name: 'Rechnungen', parent: 'parent', standardFiletype: 'invoice', standardFiletypeIsOptional: false },
|
||||
],
|
||||
types: [{ id: 'invoice', name: 'Rechnung' }, { id: 'other', name: 'Sonstiges' }],
|
||||
}
|
||||
|
||||
test('filing shows folder paths, requires a type and honours mandatory folder defaults', async t => {
|
||||
const ui = setup(async () => filingOptions)
|
||||
t.after(ui.close)
|
||||
await ui.run('openFiling({ id: "scan", name: "scan.pdf" })')
|
||||
assert.equal(ui.run('canFile.value'), false)
|
||||
assert.equal(ui.run('folderOptions.value.find(folder => folder.id === "folder").name'), 'Buchhaltung / Rechnungen')
|
||||
ui.run('filingFolder.value = "folder"')
|
||||
await settle()
|
||||
assert.equal(ui.run('filingType.value'), 'invoice')
|
||||
assert.equal(ui.run('fixedType.value'), true)
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
ui.run('filingType.value = "other"')
|
||||
assert.equal(ui.run('canFile.value'), false)
|
||||
ui.run('filingFolder.value = "root"')
|
||||
await settle()
|
||||
assert.equal(ui.run('fixedType.value'), false)
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
})
|
||||
|
||||
test('successful filing sends folder and type, removes the entry and returns from an empty last page', async t => {
|
||||
const pendingSave = deferred()
|
||||
const requests = []
|
||||
const ui = setup((url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return Promise.resolve(filingOptions)
|
||||
if (url.endsWith('/file')) return pendingSave.promise
|
||||
return Promise.resolve({ documents: [{ id: 'remaining' }], total: 50 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 51; page.value = 2')
|
||||
await ui.run('openFiling(documents.value[0])')
|
||||
ui.run('filingType.value = "invoice"')
|
||||
const saving = ui.run('fileDocument()')
|
||||
await ui.run('fileDocument()')
|
||||
assert.equal(requests.filter(item => item.url.endsWith('/file')).length, 1)
|
||||
assert.equal(ui.run('documents.value.length'), 1, 'keeps the original until saving succeeds')
|
||||
pendingSave.resolve({ id: 'scan' })
|
||||
await saving
|
||||
const post = requests.find(item => item.url.endsWith('/file'))
|
||||
assert.equal(post.url, '/api/document-inbox/scan/file')
|
||||
assert.equal(post.options.body.folder, null)
|
||||
assert.equal(post.options.body.type, 'invoice')
|
||||
assert.equal(ui.run('filingOpen.value'), false)
|
||||
assert.equal(ui.run('page.value'), 1)
|
||||
assert.equal(requests.at(-1).options.query.offset, 0)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'remaining')
|
||||
})
|
||||
|
||||
test('filing failure preserves the document and selected values for retry', async t => {
|
||||
const ui = setup(async url => {
|
||||
if (url.endsWith('/file')) throw { data: { message: 'Ordner nicht verfügbar' } }
|
||||
return filingOptions
|
||||
})
|
||||
t.after(ui.close)
|
||||
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1')
|
||||
await ui.run('openFiling(documents.value[0])')
|
||||
ui.run('filingFolder.value = "folder"')
|
||||
await settle()
|
||||
await ui.run('fileDocument()')
|
||||
assert.equal(ui.run('filingError.value'), 'Ordner nicht verfügbar')
|
||||
assert.equal(ui.run('filingOpen.value'), true)
|
||||
assert.equal(ui.run('filingType.value'), 'invoice')
|
||||
assert.equal(ui.run('documents.value[0].id'), 'scan')
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
})
|
||||
|
||||
test('tenant switching discards pending folder options and filing responses', async t => {
|
||||
for (const stage of ['options', 'save']) {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => {
|
||||
if (url.endsWith('/filing-options')) return stage === 'options' ? pending.promise : Promise.resolve(filingOptions)
|
||||
if (url.endsWith('/file')) return pending.promise
|
||||
return Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
let operation = ui.run('openFiling({ id: "scan", name: "scan.pdf" })')
|
||||
if (stage === 'save') {
|
||||
await operation
|
||||
ui.run('filingType.value = "invoice"')
|
||||
operation = ui.run('fileDocument()')
|
||||
}
|
||||
ui.auth.activeTenant = '2'
|
||||
await settle()
|
||||
pending.resolve(stage === 'options' ? filingOptions : { id: 'scan' })
|
||||
await operation
|
||||
assert.equal(ui.run('filingOpen.value'), false)
|
||||
assert.equal(ui.run('filingFolders.value.length'), 0)
|
||||
assert.equal(ui.run('filingTypes.value.length'), 0)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'tenant-2')
|
||||
}
|
||||
})
|
||||
@@ -4,7 +4,7 @@ const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
|
||||
async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) {
|
||||
async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true, ocr: true, backgroundCleanup: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) {
|
||||
const notices = []
|
||||
const requests = []
|
||||
let unmount
|
||||
@@ -38,6 +38,9 @@ test('duplex scanning continues past the former three-minute polling limit', asy
|
||||
const body = ui.requests.find(request => request.url === '/api/scan-jobs').options.body
|
||||
assert.equal(body.settings.source, 'ADF Duplex')
|
||||
assert.equal(body.settings.paperSize, 'A4')
|
||||
assert.equal(body.settings.ocr, true)
|
||||
assert.equal(body.settings.ocrLanguage, 'deu+eng')
|
||||
assert.equal(body.settings.backgroundCleanup, true)
|
||||
assert.equal(body.target.folder, 'folder-1')
|
||||
})
|
||||
|
||||
@@ -72,7 +75,7 @@ test('iX1600 preset resets image settings and normalizes the output filename to
|
||||
})
|
||||
|
||||
test('setup test scan uses the requested agent even when another agent is first', async () => {
|
||||
const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true }, scannerNames: ['scanner-' + id] }))
|
||||
const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true, ocr: true, backgroundCleanup: true }, scannerNames: ['scanner-' + id] }))
|
||||
const ui = await setup(() => ({ job: { status: 'completed' } }), { agentId: 'configured' }, agents)
|
||||
await ui.run('startScan()')
|
||||
assert.equal(ui.requests.find(request => request.url === '/api/scan-jobs').options.body.agentId, 'configured')
|
||||
@@ -83,3 +86,10 @@ test('an unavailable setup agent never silently scans with another device', asyn
|
||||
await ui.run('startScan()')
|
||||
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
|
||||
})
|
||||
|
||||
test('an old agent cannot silently produce an image-only PDF when OCR was requested', async () => {
|
||||
const ui = await setup(() => ({}), {}, [{ id: 'old', active: true, capabilities: { scan: true }, scannerNames: ['scanner'] }])
|
||||
await ui.run('startScan()')
|
||||
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
|
||||
assert.equal(ui.notices[0].title, 'Agent aktualisieren')
|
||||
})
|
||||
|
||||
201
frontend/tests/selfhost-updates.test.cjs
Normal file
201
frontend/tests/selfhost-updates.test.cjs
Normal file
@@ -0,0 +1,201 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
|
||||
const check = { status: 'checked', command: 'check', requestId: 'checked-request', plan: {
|
||||
version: '3.2.0', revision: 'a'.repeat(40), updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
|
||||
} }
|
||||
const overview = (job = check, engine = { status: 'checked' }) => ({ acceptingRequests: true, engine, request: job, installed: {}, backend: { version: '3.1.0' } })
|
||||
async function setup(t, api, saved = null) {
|
||||
const mounted = [], unmounted = [], calls = [], storage = new Map(saved ? [['fedeo:update:admin', saved]] : [])
|
||||
let nextId = 0
|
||||
const context = vm.createContext({
|
||||
ref: vue.ref, computed: vue.computed, onMounted: fn => mounted.push(fn), onBeforeUnmount: fn => unmounted.push(fn),
|
||||
useAuthStore: () => ({ user: { id: 'admin' } }),
|
||||
useNuxtApp: () => ({ $api: async (path, options) => { calls.push({ path, options }); return api(path, options) } }),
|
||||
localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) },
|
||||
crypto: { randomUUID: () => `request-id-${++nextId}` }, setTimeout: () => 1, clearTimeout() {},
|
||||
})
|
||||
const source = fs.readFileSync(__dirname + '/../composables/useSelfhostUpdates.ts', 'utf8').replace(/export /g, '')
|
||||
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }) + '\nglobalThis.ui = useSelfhostUpdates()', context)
|
||||
mounted.forEach(fn => fn()); await new Promise(setImmediate)
|
||||
t.after(() => unmounted.forEach(fn => fn()))
|
||||
return { ui: context.ui, calls, storage }
|
||||
}
|
||||
test('checked version enables one apply; duplicate click is blocked and polling survives restart', async t => {
|
||||
let state = overview(), offline = false, finish
|
||||
const f = await setup(t, async (path, options) => {
|
||||
if (offline) throw new Error('network')
|
||||
if (options.method === 'POST') return new Promise(resolve => { finish = resolve })
|
||||
return path.endsWith('/status') ? state : state.request
|
||||
})
|
||||
assert.equal(f.ui.canApply.value, true)
|
||||
const start = f.ui.apply(); await f.ui.apply()
|
||||
assert.equal(f.calls.filter(c => c.options.method === 'POST').length, 1)
|
||||
const body = f.calls.at(-1).options.body
|
||||
assert.equal(body.version, '3.2.0'); assert.equal(body.checkRequestId, 'checked-request')
|
||||
state = overview({ status: 'running', command: 'apply', requestId: body.requestId }, { status: 'running', recoveryRequired: true })
|
||||
finish(state.request); await start
|
||||
await f.ui.refresh(); assert.equal(f.ui.recovery.value, false)
|
||||
offline = true; await f.ui.refresh()
|
||||
assert.match(f.ui.message.value, /Host führt das Update weiter/)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
offline = false; state = overview({ ...state.request, status: 'succeeded' }, { status: 'succeeded' })
|
||||
await f.ui.refresh(); assert.equal(f.ui.job.value.status, 'succeeded')
|
||||
})
|
||||
test('lost start response is recovered after reload without replaying an update', async t => {
|
||||
const first = await setup(t, async (path, options) => {
|
||||
if (options.method === 'POST') throw new Error('response lost')
|
||||
return path.endsWith('/status') ? overview() : check
|
||||
})
|
||||
await first.ui.apply()
|
||||
const id = first.ui.pending.value.body.requestId
|
||||
const saved = first.storage.get('fedeo:update:admin')
|
||||
const running = { status: 'running', command: 'apply', requestId: id }
|
||||
const second = await setup(t, async path => path.endsWith('/status') ? overview(running) : running, saved)
|
||||
assert.equal(second.ui.pending.value, null)
|
||||
assert.equal(second.ui.job.value.requestId, id)
|
||||
assert.equal(second.calls.some(c => c.options.method === 'POST'), false)
|
||||
})
|
||||
test('unconfirmed submission retains its original ID for explicit retry', async t => {
|
||||
const saved = JSON.stringify({ pending: { command: 'apply', body: { requestId: 'original-request', version: '3.2.0', checkRequestId: 'checked-request' } } })
|
||||
const f = await setup(t, async (path, options) => {
|
||||
if (options.method === 'POST') return { status: 'queued', command: 'apply', requestId: options.body.requestId }
|
||||
if (path.endsWith('/status')) return overview()
|
||||
throw { statusCode: 404 }
|
||||
}, saved)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
await f.ui.sendPending()
|
||||
assert.equal(f.calls.at(-1).options.body.requestId, 'original-request')
|
||||
})
|
||||
test('recovery, up-to-date, unmanaged compose and other active jobs prevent applying', async t => {
|
||||
for (const state of [overview(check, { status: 'failed', recoveryRequired: true }),
|
||||
overview({ ...check, status: 'up_to_date' }),
|
||||
overview({ ...check, plan: { ...check.plan, requiresComposeAdoption: true } }),
|
||||
overview({ status: 'running', command: 'apply', requestId: 'someone-else' })]) {
|
||||
const f = await setup(t, async () => state)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
await f.ui.apply(); assert.equal(f.calls.some(c => c.options.method === 'POST'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('a completed storage migration stays blocked until the host handover is complete', async t => {
|
||||
let state = { ...overview(check, { status: 'failed', recoveryRequired: true }),
|
||||
storage: { provider: 'seaweedfs', migrationPending: true } }
|
||||
const f = await setup(t, async path => path.endsWith('/status') ? state : state.request)
|
||||
assert.equal(f.ui.overview.value.storage.migrationPending, true)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
state = { ...overview({ ...check, plan: { ...check.plan, storageProvider: 'seaweedfs', storageImageRetained: true } }),
|
||||
storage: { provider: 'seaweedfs', migrationPending: false } }
|
||||
await f.ui.refresh()
|
||||
assert.equal(f.ui.canApply.value, true)
|
||||
assert.equal(f.ui.job.value.plan.storageProvider, 'seaweedfs')
|
||||
})
|
||||
|
||||
test('an older polling response cannot erase a new submission whose response was lost', async t => {
|
||||
let hold = false, release
|
||||
const f = await setup(t, async (path, options) => {
|
||||
if (options.method === 'POST') throw new Error('response lost')
|
||||
if (path.endsWith('/status')) return overview()
|
||||
if (hold) return new Promise(resolve => { release = resolve })
|
||||
return check
|
||||
})
|
||||
hold = true
|
||||
const polling = f.ui.refresh()
|
||||
await new Promise(setImmediate)
|
||||
await f.ui.apply()
|
||||
const id = f.ui.pending.value.body.requestId
|
||||
release(check); await polling
|
||||
assert.equal(f.ui.pending.value.body.requestId, id)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
})
|
||||
|
||||
test('failed jobs automatically show logs and refresh them only while open', async t => {
|
||||
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request', error: { code: 'UPDATE_FAILED', message: 'Auftrag fehlgeschlagen.' } }
|
||||
let content = 'pull access denied'
|
||||
const f = await setup(t, async path => {
|
||||
if (path.endsWith('/logs')) return { requestId: failed.requestId, logs: [{ source: 'commands', content, truncated: false }] }
|
||||
return path.endsWith('/status') ? { ...overview(failed), capabilities: { requestLogs: true } } : failed
|
||||
})
|
||||
assert.equal(f.ui.logsOpen.value, true)
|
||||
assert.equal(f.ui.logEntries.value[0].content, content)
|
||||
content = 'final failure detail'
|
||||
await f.ui.refresh()
|
||||
assert.equal(f.ui.logEntries.value[0].content, content)
|
||||
await f.ui.toggleLogs()
|
||||
const count = f.calls.filter(c => c.path.endsWith('/logs')).length
|
||||
await f.ui.refresh()
|
||||
assert.equal(f.ui.logsOpen.value, false)
|
||||
assert.equal(f.calls.filter(c => c.path.endsWith('/logs')).length, count)
|
||||
})
|
||||
|
||||
test('older host services explain the required upgrade without requesting unsupported endpoints', async t => {
|
||||
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request' }
|
||||
const f = await setup(t, async path => path.endsWith('/status') ? overview(failed) : failed)
|
||||
assert.equal(f.ui.logsOpen.value, true)
|
||||
assert.match(f.ui.logsMessage.value, /Host-Update-Dateien aktualisieren/)
|
||||
assert.equal(f.calls.some(c => c.path.endsWith('/logs')), false)
|
||||
assert.equal(f.ui.connected.value, true)
|
||||
})
|
||||
|
||||
test('log failures preserve job state and revoked admin access clears the displayed logs', async t => {
|
||||
let failure
|
||||
const f = await setup(t, async path => {
|
||||
if (path.endsWith('/logs')) {
|
||||
if (failure) throw failure
|
||||
return { requestId: check.requestId, logs: [{ source: 'commands', content: 'diagnostic', truncated: false }] }
|
||||
}
|
||||
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
|
||||
})
|
||||
await f.ui.toggleLogs()
|
||||
failure = { statusCode: 503 }
|
||||
await f.ui.refreshLogs()
|
||||
assert.match(f.ui.logsMessage.value, /konnte nicht geladen/)
|
||||
assert.equal(f.ui.connected.value, true)
|
||||
assert.equal(f.ui.job.value.status, 'checked')
|
||||
failure = { statusCode: 403 }
|
||||
await f.ui.refreshLogs()
|
||||
assert.equal(f.ui.accessDenied.value, true)
|
||||
assert.equal(f.ui.logEntries.value.length, 0)
|
||||
})
|
||||
|
||||
test('a delayed log response cannot appear under a newly submitted job', async t => {
|
||||
let release
|
||||
const f = await setup(t, async (path, options) => {
|
||||
if (options.method === 'POST') return { status: 'queued', requestId: options.body.requestId, command: 'check' }
|
||||
if (path.endsWith('/logs')) return new Promise(resolve => { release = resolve })
|
||||
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
|
||||
})
|
||||
const loading = f.ui.toggleLogs()
|
||||
await new Promise(setImmediate)
|
||||
await f.ui.check()
|
||||
release({ requestId: check.requestId, logs: [{ source: 'commands', content: 'previous job', truncated: false }] })
|
||||
await loading
|
||||
assert.notEqual(f.ui.job.value.requestId, check.requestId)
|
||||
assert.equal(f.ui.logEntries.value.length, 0)
|
||||
})
|
||||
|
||||
test('the update card renders log output as escaped text and explains truncation', async t => {
|
||||
const f = await setup(t, async path => path.endsWith('/status') ? overview() : check)
|
||||
f.ui.logsOpen.value = true
|
||||
const { parse, compileScript } = require('@vue/compiler-sfc')
|
||||
const descriptor = parse(fs.readFileSync(__dirname + '/../components/SelfhostUpdateCard.vue', 'utf8')).descriptor
|
||||
const compiled = compileScript(descriptor, { id: 'update-card-test', inlineTemplate: true }).content
|
||||
const exports = {}
|
||||
const context = vm.createContext({ exports, require, ref: vue.ref, computed: vue.computed,
|
||||
useRuntimeConfig: () => ({ public: {} }), useSelfhostUpdates: () => ({ ...f.ui,
|
||||
logEntries: vue.ref([{ source: 'commands', content: '<script>alert("log")</script>', truncated: true }]) }) })
|
||||
vm.runInContext(ts.transpile(compiled, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
|
||||
const app = vue.createSSRApp(exports.default)
|
||||
for (const name of ['UCard', 'UIcon', 'UBadge', 'UAlert', 'UProgress', 'UButton', 'UModal', 'UCheckbox']) {
|
||||
app.component(name, { setup: (_props, { slots }) => () => vue.h('div', slots.default?.()) })
|
||||
}
|
||||
const html = await require('@vue/server-renderer').renderToString(app)
|
||||
assert.match(html, /Werkzeugausgaben/)
|
||||
assert.match(html, /Gekürzte Anzeige/)
|
||||
assert.match(html, /<script>/)
|
||||
assert.doesNotMatch(html, /<script>/)
|
||||
})
|
||||
@@ -26,6 +26,7 @@ export function renderAgentInstaller(bundle, options) {
|
||||
'FEDEO_POLL_SECONDS=5', 'FEDEO_SCAN_FORMAT=pdf', 'FEDEO_SCAN_RESOLUTION=300',
|
||||
'FEDEO_SCAN_MODE=Color', 'FEDEO_SCAN_TIMEOUT_SECONDS=900',
|
||||
'FEDEO_SCAN_POSTPROCESS=false', 'FEDEO_SCAN_POSTPROCESS_PROFILE=document', '',
|
||||
'FEDEO_SCAN_BACKGROUND_CLEANUP=true', 'FEDEO_SCAN_OCR=true', 'FEDEO_SCAN_OCR_LANGUAGE=deu+eng', '',
|
||||
].join('\n')
|
||||
const replacements = {
|
||||
AGENT_ID: options.agentId, AUTOSTART: String(Boolean(options.autostart)),
|
||||
@@ -40,6 +41,7 @@ export function renderAgentInstaller(bundle, options) {
|
||||
export const ix1600ScanDefaults = {
|
||||
format: 'pdf', resolution: 300, mode: 'Color', source: 'ADF Duplex',
|
||||
paperSize: 'A4', postprocess: false, postprocessProfile: 'document',
|
||||
backgroundCleanup: true, ocr: true, ocrLanguage: 'deu+eng',
|
||||
}
|
||||
|
||||
export function agentSetupReady(agent, since = 0, now = Date.now()) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"test": "node --test scripts/release/tests/*.test.mjs && npm run test:update",
|
||||
"test:update": "python3 -B -m unittest discover -s scripts/update/tests -v",
|
||||
"test:storage": "npm test --prefix scripts/storage && python3 -B -m unittest discover -s scripts/storage/tests -p 'test_*.py' -v",
|
||||
"commits:check": "node scripts/release/cli.mjs check",
|
||||
"release:preview": "node scripts/release/cli.mjs preview",
|
||||
"release:ci": "node scripts/release/ci.mjs"
|
||||
|
||||
@@ -163,6 +163,16 @@ download_file() {
|
||||
mv "$temp_file" "$target"
|
||||
}
|
||||
|
||||
guard_storage_migration() {
|
||||
if [[ -e "$FEDEO_DIR/storage" || -L "$FEDEO_DIR/storage" ||
|
||||
-e "$FEDEO_DIR/.updates/storage-provider.json" ||
|
||||
-e "$FEDEO_DIR/.updates/storage-migration/state.json" ]]; then
|
||||
echo "Eine Speicherumstellung wurde vorbereitet oder durchgeführt. Der Standardinstaller darf diese Instanz nicht überschreiben." >&2
|
||||
echo "Den geprüften Migrationsablauf bzw. den Host-Updater verwenden (SELFHOST-UPDATES.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_selfhost_files() {
|
||||
echo
|
||||
echo "Zielverzeichnis: $FEDEO_DIR"
|
||||
@@ -177,6 +187,8 @@ download_selfhost_files() {
|
||||
$SUDO chown "$(id -u):$(id -g)" "$FEDEO_DIR" "$FEDEO_DIR/scripts"
|
||||
fi
|
||||
|
||||
guard_storage_migration
|
||||
|
||||
if [[ -d "$FEDEO_DIR/.git" ]]; then
|
||||
echo "Hinweis: In $FEDEO_DIR liegt noch ein alter Git-Checkout. Der Installer aktualisiert nur die Selfhost-Dateien und lässt Daten, .env und Checkout unangetastet."
|
||||
fi
|
||||
@@ -210,6 +222,7 @@ Dieser Installer bringt die typische Serverstruktur mit:
|
||||
|
||||
EOF
|
||||
|
||||
guard_storage_migration
|
||||
install_base_packages
|
||||
ensure_docker
|
||||
download_selfhost_files
|
||||
|
||||
@@ -467,6 +467,14 @@ uninstall_stack() {
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ -e "$ROOT_DIR/storage" || -L "$ROOT_DIR/storage" ||
|
||||
-e "$ROOT_DIR/.updates/storage-provider.json" ||
|
||||
-e "$ROOT_DIR/.updates/storage-migration/state.json" ]]; then
|
||||
echo "Eine Speicherumstellung wurde vorbereitet oder durchgeführt. Das Standardsetup darf diese Instanz nicht überschreiben oder entfernen." >&2
|
||||
echo "Den geprüften Migrationsablauf bzw. den Host-Updater verwenden (SELFHOST-UPDATES.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$UNINSTALL" == "true" ]]; then
|
||||
uninstall_stack
|
||||
return
|
||||
|
||||
646
scripts/selfhost-storage-migrate.py
Normal file
646
scripts/selfhost-storage-migrate.py
Normal file
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit, offline-capable pilot migration of an installer stack; Python 3.10+."""
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location("fedeo_update", HERE / "selfhost-update.py")
|
||||
u = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(u)
|
||||
Error = u.UpdateError
|
||||
GIB = 1024 ** 3
|
||||
|
||||
|
||||
class TransientStorageError(Error):
|
||||
pass
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise Error(message)
|
||||
|
||||
|
||||
def sha_file(path):
|
||||
h = hashlib.sha256()
|
||||
with Path(path).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def literal(value):
|
||||
"""Resolved Compose JSON still undergoes interpolation when used as a file."""
|
||||
if isinstance(value, str):
|
||||
return value.replace("$", "$$")
|
||||
if isinstance(value, list):
|
||||
return [literal(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: literal(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def resolved(value):
|
||||
"""Compose config serializes literal dollar signs escaped for another Compose read."""
|
||||
if isinstance(value, str):
|
||||
return value.replace("$$", "$")
|
||||
if isinstance(value, list):
|
||||
return [resolved(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: resolved(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def validate_minio_listing(root, bucket, inventory, *, exact=False):
|
||||
"""MinIO can hide a/b from LIST when a exists, while GET a/b still works.
|
||||
|
||||
Only inspect paths in the known single-drive layout; never decode or modify
|
||||
xl.meta. A cold equality check guards the completeness of the S3 inventory.
|
||||
"""
|
||||
source = root / "minio"
|
||||
format_info = u.read_json(source / ".minio.sys/format.json", {})
|
||||
require(format_info.get("format") == "xl-single" and format_info.get("xl", {}).get("version") == "3",
|
||||
"MinIO-Datenformat ist für den zusätzlichen Vollständigkeitsabgleich nicht freigegeben.")
|
||||
bucket_root = source / bucket
|
||||
physical = {str(p.parent.relative_to(bucket_root)) for p in bucket_root.rglob("xl.meta") if p.is_file()}
|
||||
for key in physical:
|
||||
parts = key.split("/")
|
||||
require(all("/".join(parts[:n]) not in physical for n in range(1, len(parts))),
|
||||
"MinIO enthält einen Datei-/Verzeichniskonflikt; die S3-Auflistung kann Objekte verbergen.")
|
||||
if exact:
|
||||
require(physical == {o["Key"] for o in inventory["objects"]},
|
||||
"Kalte MinIO-Metadaten und S3-Inventar weichen ab; keine vollständige Übernahme nachweisbar.")
|
||||
|
||||
|
||||
def validate_config(config, root):
|
||||
services = config.get("services", {})
|
||||
required = {"db", "minio", "createbuckets", "backend", "frontend", "traefik", "node-exporter"}
|
||||
require(required <= services.keys() and not (services.keys() - required - u.LEGACY_MATRIX_SERVICES),
|
||||
"Nur der Installer-Stack wird unterstützt; zusätzliche/fehlende Dienste zuerst klären.")
|
||||
for service in services.values():
|
||||
require(not any(service.get(k) for k in ("build", "profiles", "secrets", "configs", "volumes_from")),
|
||||
"Builds, Profile und externe Konfigurationen benötigen einen eigenen Migrationsplan.")
|
||||
store = services["minio"]
|
||||
require(not store.get("ports") and set(store.get("networks", {})) == {"internal"},
|
||||
"MinIO muss ausschließlich im internen Docker-Netz erreichbar sein.")
|
||||
require(not services["db"].get("ports"), "Veröffentlichte Datenbankports benötigen einen gesonderten Schreiber-Nachweis.")
|
||||
require(set(store.get("environment", {})) == {"MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD"},
|
||||
"Zusätzliche MinIO-Konfiguration benötigt eine gesonderte Prüfung.")
|
||||
for name, target, directory in (("minio", "/data", "minio"), ("db", "/var/lib/postgresql/data", "postgres")):
|
||||
mounts = services[name].get("volumes", [])
|
||||
require(len(mounts) == 1 and mounts[0].get("type") == "bind" and mounts[0].get("target") == target
|
||||
and mounts[0].get("source") == str(root / directory), "Standard-Datenpfade fehlen: " + name)
|
||||
require((root / directory).is_dir() and (root / directory).resolve() == root / directory,
|
||||
"Datenpfad fehlt oder ist ein Link: " + directory)
|
||||
env = services["backend"].get("environment", {})
|
||||
require(env.get("FEDEO_FILE_BACKEND", "s3") == "s3" and not env.get("INFISICAL_CLIENT_ID"),
|
||||
"Externe Speicher/Secret-Provider benötigen einen eigenen Adapter.")
|
||||
require(env.get("S3_ENDPOINT", "").rstrip("/") == "http://minio:9000", "Abweichender S3-Endpunkt.")
|
||||
require(all(env.get(k) for k in ("S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY")), "S3-Konfiguration unvollständig.")
|
||||
require(re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", env["S3_BUCKET"]), "Ungültiger Bucketname.")
|
||||
require(env["S3_ACCESS_KEY"] == store["environment"]["MINIO_ROOT_USER"] and
|
||||
env["S3_SECRET_KEY"] == store["environment"]["MINIO_ROOT_PASSWORD"],
|
||||
"Getrennte MinIO-Benutzer vorhanden; deren Rechte zunächst vollständig inventarisieren.")
|
||||
db = urllib.parse.urlsplit(env.get("DATABASE_URL", ""))
|
||||
db_env = services["db"].get("environment", {})
|
||||
require(db.hostname == "db" and db.port in (None, 5432) and
|
||||
urllib.parse.unquote(db.path.lstrip("/")) == db_env.get("POSTGRES_DB") and
|
||||
urllib.parse.unquote(db.username or "") == db_env.get("POSTGRES_USER") and
|
||||
urllib.parse.unquote(db.password or "") == db_env.get("POSTGRES_PASSWORD"),
|
||||
"DATABASE_URL stimmt nicht mit der lokalen Sicherung überein.")
|
||||
return env
|
||||
|
||||
|
||||
def index_containers(config, containers):
|
||||
"""Keep previously retired Matrix containers without reactivating their services."""
|
||||
indexed, retained = {}, {}
|
||||
for container in containers:
|
||||
labels = container["Config"].get("Labels", {})
|
||||
name = labels.get("com.docker.compose.service")
|
||||
require(name and name not in indexed and labels.get("com.docker.compose.oneoff", "False").lower() != "true",
|
||||
"Unbekannte oder doppelte Projektcontainer vorhanden.")
|
||||
if name not in config["services"]:
|
||||
require(name in u.LEGACY_MATRIX_SERVICES, "Unbekannter Projektcontainer: " + name)
|
||||
state, host = container["State"], container["HostConfig"]
|
||||
require(not any(state.get(k) for k in ("Running", "Restarting", "Paused")) and
|
||||
host.get("RestartPolicy", {}).get("Name") == "no" and not host.get("AutoRemove"),
|
||||
"Alter Matrix-Container ist nicht dauerhaft stillgelegt: " + name)
|
||||
volumes = []
|
||||
for mount in container.get("Mounts", []):
|
||||
if mount["Type"] == "bind":
|
||||
volumes.append({"type": "bind", "source": mount["Source"], "target": mount["Destination"]})
|
||||
else:
|
||||
# These image-declared volumes belong to already retired services.
|
||||
# Preserve their containers/volumes; never remove or restart them.
|
||||
require(mount["Type"] == "volume" and (name, mount["Destination"]) in {
|
||||
("matrix-redis", "/data"), ("matrix-turn", "/var/lib/coturn")},
|
||||
"Abweichender Datenmount eines alten Matrix-Containers: " + name)
|
||||
retained[name] = {"volumes": volumes}
|
||||
indexed[name] = container
|
||||
return indexed, retained
|
||||
|
||||
|
||||
class Migration:
|
||||
def __init__(self, root, tools_image=None):
|
||||
os.umask(0o077)
|
||||
self.engine = u.Updater(root)
|
||||
self.root = self.engine.root
|
||||
self.home = self.engine.home / "storage-migration"
|
||||
require(not self.home.is_symlink(), "Migrationsverzeichnis darf kein Link sein.")
|
||||
self.home.mkdir(mode=0o700, exist_ok=True)
|
||||
self.runner = u.Runner(self.home / "commands.log")
|
||||
self.state = u.read_json(self.home / "state.json", {})
|
||||
self.tools_image = tools_image or self.state.get("toolsImage")
|
||||
require(not self.state or tools_image in (None, self.state.get("toolsImage")), "Image eines bestehenden Auftrags darf nicht ersetzt werden.")
|
||||
self.weed = u.read_json(HERE / "storage/seaweedfs.json")
|
||||
self.helper_name = "fedeo-storage-helper-" + hashlib.sha256(str(self.root).encode()).hexdigest()[:12]
|
||||
self.restore_name = self.helper_name + "-restore"
|
||||
|
||||
def docker(self, *args, **kwargs):
|
||||
return self.runner.run(["docker", *args], **kwargs)
|
||||
|
||||
def compose(self, file, *args, **kwargs):
|
||||
return self.docker("compose", "--project-directory", str(self.root), "--env-file", str(self.root / ".env"),
|
||||
"-p", self.state["project"], "-f", str(file), *args, **kwargs)
|
||||
|
||||
def save(self, phase=None, **fields):
|
||||
self.state.update(fields, updatedAt=u.now())
|
||||
if phase:
|
||||
self.state["phase"] = phase
|
||||
u.write_json(self.home / "state.json", self.state)
|
||||
|
||||
def guard(self):
|
||||
# Compatible with the already installed 3.3 updater: it refuses recoveryRequired.
|
||||
# Kept after cutover until a provider-aware application updater is installed.
|
||||
u.write_json(self.engine.home / "status.json", {
|
||||
"status": "failed", "phase": "storage_migration", "job": str(self.home),
|
||||
"startedAt": self.state["startedAt"], "updatedAt": u.now(), "recoveryRequired": True,
|
||||
"maintenance": not self.state.get("writesReleased", False), "mutationStarted": True,
|
||||
"error": "Speichermigration: selfhost-storage-migrate.py status prüfen. Herkömmliche Updates sind gesperrt."})
|
||||
|
||||
def fingerprint(self):
|
||||
return {name: sha_file(self.root / name) for name in ("docker-compose.yml", ".env")}
|
||||
|
||||
def inspect(self, container):
|
||||
return json.loads(self.docker("inspect", container))[0]
|
||||
|
||||
def wait_ready(self, container, timeout=180):
|
||||
for _ in range(timeout):
|
||||
state = self.inspect(container)["State"]
|
||||
require(state.get("Running"), "Ein benötigter Container wurde beendet.")
|
||||
if state.get("Health", {}).get("Status") in (None, "healthy"):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise Error("Container wurde innerhalb der Wartezeit nicht bereit.")
|
||||
|
||||
def cleanup_helper(self):
|
||||
ids = self.docker("ps", "-aq", "--filter", "name=^/" + self.helper_name + "$").split()
|
||||
for container in ids:
|
||||
require(self.inspect(container)["Config"].get("Labels", {}).get("io.fedeo.storage-job") == str(self.home),
|
||||
"Fremder Container belegt den Hilfsnamen.")
|
||||
self.docker("rm", "-f", container)
|
||||
|
||||
def helper(self, *args, **kwargs):
|
||||
deadline = time.monotonic() + 180
|
||||
while True:
|
||||
try:
|
||||
return self.helper_once(*args, **kwargs)
|
||||
except TransientStorageError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(2)
|
||||
|
||||
def helper_once(self, command, *, source=False, admin=False, endpoint="http://objectstore:9000", directory="copy", backend_sdk=False):
|
||||
self.cleanup_helper()
|
||||
config = u.read_json(self.home / "credentials.json")
|
||||
job = {"bucket": config["bucket"], "directory": "/work", "result": "/work/result.json"}
|
||||
if command == "contract":
|
||||
job["foreignBucket"] = "fedeo-denied-" + secrets.token_hex(8)
|
||||
if source:
|
||||
job["source"] = config["source"]
|
||||
job["target"] = {**config["admin" if admin else "target"], "endpoint": endpoint}
|
||||
u.write_json(self.home / "job.json", job)
|
||||
work = self.home / directory
|
||||
work.mkdir(mode=0o700, exist_ok=True)
|
||||
(work / "error.json").unlink(missing_ok=True)
|
||||
runtime = [self.tools_image, command]
|
||||
if backend_sdk:
|
||||
runtime = ["--entrypoint", "node", "-e", "FEDEO_STORAGE_SDK_ROOT=" + self.state["backendWorkingDir"],
|
||||
"-v", str(HERE / "storage") + ":/fedeo-check:ro", self.state["containers"]["backend"]["image"],
|
||||
"/fedeo-check/cli.mjs", command]
|
||||
try:
|
||||
self.docker("run", "--rm", "--pull", "never", "--name", self.helper_name,
|
||||
"--label", "io.fedeo.storage-job=" + str(self.home), "--network", self.state["network"],
|
||||
"--user", str(os.getuid()) + ":" + str(os.getgid()),
|
||||
"--read-only", "--cap-drop=ALL", "--security-opt", "no-new-privileges", "--tmpfs", "/tmp",
|
||||
"-v", str(self.home / "job.json") + ":/config/job.json:ro", "-v", str(work) + ":/work",
|
||||
*runtime, timeout=24 * 3600, output=self.home / "progress.jsonl")
|
||||
return u.read_json(work / "result.json")
|
||||
except Error:
|
||||
detail = u.read_json(work / "error.json", {})
|
||||
if detail.get("status") in (500, 502, 503, 504) or detail.get("code") in ("ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH"):
|
||||
raise TransientStorageError("S3 noch nicht bereit; Wiederholung blieb erfolglos.") from None
|
||||
raise
|
||||
finally:
|
||||
self.cleanup_helper()
|
||||
|
||||
def plan(self):
|
||||
require(not self.state, "Ein Plan existiert bereits; status prüfen oder vorhandenen Auftrag fortsetzen.")
|
||||
require(not self.engine.status().get("recoveryRequired"), "Zuerst den offenen Update-Auftrag klären.")
|
||||
require(self.tools_image, "--tools-image mit lokalem Hilfsimage angeben.")
|
||||
require(all((self.root / n).is_file() and not (self.root / n).is_symlink() for n in (".env", "docker-compose.yml")),
|
||||
"Reguläre .env und docker-compose.yml erforderlich.")
|
||||
require(not any((self.root / n).exists() for n in ("docker-compose.override.yml", "docker-compose.override.yaml", "compose.yml", "compose.yaml")),
|
||||
"Compose-Overrides zuerst in einem gesonderten Plan berücksichtigen.")
|
||||
endpoint = self.docker("context", "inspect", "--format", "{{.Endpoints.docker.Host}}").strip()
|
||||
require(os.environ.get("DOCKER_HOST", endpoint).startswith("unix://"),
|
||||
"Nur ein lokaler Docker-Daemon wird unterstützt.")
|
||||
info = json.loads(self.docker("info", "--format", "{{json .}}"))
|
||||
require(info["OSType"] == "linux" and info["Architecture"] in ("amd64", "x86_64"), "Pilot benötigt Linux amd64.")
|
||||
config = resolved(json.loads(self.docker("compose", "--project-directory", str(self.root), "--env-file", str(self.root / ".env"),
|
||||
"-f", str(self.root / "docker-compose.yml"), "config", "--format", "json")))
|
||||
env = validate_config(config, self.root)
|
||||
project = config["name"]
|
||||
ids = self.docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split()
|
||||
containers = [self.inspect(i) for i in ids]
|
||||
by_service, retained = index_containers(config, containers)
|
||||
# Validate retained bind mounts with the same strict path rules as configured
|
||||
# Matrix services. Do not put retired services back into either Compose file.
|
||||
self.engine.current = copy.deepcopy(config)
|
||||
self.engine.current["services"].update(retained)
|
||||
self.engine.retired_services = sorted(set(self.engine.current["services"]) & u.LEGACY_MATRIX_SERVICES)
|
||||
self.engine.matrix_backup_paths = []
|
||||
self.engine.validate_legacy_matrix()
|
||||
require(all(by_service.get(n, {}).get("State", {}).get("Running") for n in ("db", "minio", "backend", "frontend", "traefik")),
|
||||
"Die bestehende Instanz muss vor der Planung laufen.")
|
||||
for name in ("minio", "backend", "db"):
|
||||
actual = dict(v.split("=", 1) for v in by_service[name]["Config"]["Env"] if "=" in v)
|
||||
require(all(actual.get(k) == str(v) for k, v in config["services"][name].get("environment", {}).items()),
|
||||
"Laufender Container und Compose-Umgebung weichen ab: " + name)
|
||||
network = config["networks"]["internal"]["name"]
|
||||
peers = json.loads(self.docker("network", "inspect", network))[0].get("Containers", {})
|
||||
require(set(peers) <= {c["Id"] for c in containers}, "Weitere Container im internen Netzwerk; externe Schreiber zuerst klären.")
|
||||
for name, target in (("minio", "/data"), ("db", "/var/lib/postgresql/data")):
|
||||
mounts = [m for m in by_service[name]["Mounts"] if m["Destination"] == target]
|
||||
require(len(mounts) == 1 and mounts[0]["Source"] == str(self.root / ("minio" if name == "minio" else "postgres")),
|
||||
"Laufender Datenmount weicht ab: " + name)
|
||||
actual_store = by_service["minio"]
|
||||
require(not actual_store["HostConfig"].get("PortBindings") and set(actual_store["NetworkSettings"]["Networks"]) == {network},
|
||||
"Laufendes MinIO hat zusätzliche Netzwerkzugänge.")
|
||||
require(len(actual_store["Mounts"]) == 1, "Laufendes MinIO hat zusätzliche Mounts.")
|
||||
require(not any(p.is_symlink() for p in (self.root / "minio").rglob("*")), "Links in MinIO-Daten benötigen eigene Backup-Prüfung.")
|
||||
tools = json.loads(self.docker("image", "inspect", self.tools_image))[0]
|
||||
weed = json.loads(self.docker("image", "inspect", self.weed["image"]))[0]
|
||||
self.tools_image = tools["Id"]
|
||||
# Containerd may discard an untagged image index after a mutable build tag
|
||||
# moves. Keep an independent immutable job reference throughout the pilot.
|
||||
tools_tag = "fedeo-storage-tools:job-" + secrets.token_hex(12)
|
||||
self.docker("image", "tag", self.tools_image, tools_tag)
|
||||
self.state = {"schemaVersion": 1, "phase": "planning", "startedAt": u.now(), "project": project,
|
||||
"network": network, "toolsImage": self.tools_image, "weedImage": weed["Id"],
|
||||
"toolsRetainTag": tools_tag,
|
||||
"version": env.get("FEDEO_VERSION"), "fingerprint": self.fingerprint(), "writesReleased": False,
|
||||
"backendWorkingDir": by_service["backend"]["Config"].get("WorkingDir") or "/",
|
||||
"matrixPaths": self.engine.matrix_backup_paths, "retainedServices": sorted(retained),
|
||||
"containers": {name: {"id": c["Id"], "image": c["Image"],
|
||||
"running": c["State"]["Running"], "restart": c["HostConfig"]["RestartPolicy"],
|
||||
"health": c["State"].get("Health", {}).get("Status"),
|
||||
"networks": c["NetworkSettings"]["Networks"]} for name, c in by_service.items()}}
|
||||
# Planning errors must not leave a usable partial state file.
|
||||
u.write_json(self.home / "original.json", config)
|
||||
for name in ("docker-compose.yml", ".env"):
|
||||
u.atomic(self.home / ("original" + name.replace("/", "-")), (self.root / name).read_bytes())
|
||||
u.write_json(self.home / "previous-update.json", u.read_json(self.engine.home / "status.json", {"status": "idle"}))
|
||||
admin = {"accessKeyId": "fedeo-migration-" + secrets.token_hex(8), "secretAccessKey": secrets.token_urlsafe(36)}
|
||||
app = {"accessKeyId": env["S3_ACCESS_KEY"], "secretAccessKey": env["S3_SECRET_KEY"]}
|
||||
connection = {"region": env["S3_REGION"], "credentials": app}
|
||||
u.write_json(self.home / "credentials.json", {"bucket": env["S3_BUCKET"],
|
||||
"source": {**connection, "endpoint": env["S3_ENDPOINT"]}, "target": connection,
|
||||
"admin": {**connection, "credentials": admin}})
|
||||
inventory = self.helper("inventory", source=True, directory="preflight")
|
||||
validate_minio_listing(self.root, env["S3_BUCKET"], inventory)
|
||||
physical = int(self.runner.run(["du", "-sb", str(self.root / "minio")]).split()[0])
|
||||
db_size = int(self.docker("exec", by_service["db"]["Id"], "sh", "-ec",
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT pg_database_size(current_database())"').strip())
|
||||
image_ids = sorted({c["Image"] for c in containers} | {tools["Id"], weed["Id"]})
|
||||
image_bytes = sum(i["Size"] for i in json.loads(self.docker("image", "inspect", *image_ids)))
|
||||
matrix_bytes = sum(int(self.runner.run(["du", "-sb", str(self.root / p)]).split()[0]) for p in self.state["matrixPaths"])
|
||||
# Source backup + target + restored target + offline images + DB + spool + generous metadata reserve.
|
||||
needed = physical + inventory["bytes"] * 3 + inventory["count"] * 256 * 1024 + db_size * 2 + image_bytes * 2 + matrix_bytes + 5 * GIB
|
||||
require(shutil.disk_usage(self.root).free >= needed, f"Zu wenig freier Platz; mindestens {needed} Bytes erforderlich.")
|
||||
self.state.update(imageIds=image_ids, requiredFreeBytes=needed, freeBytes=shutil.disk_usage(self.root).free,
|
||||
count=inventory["count"], bytes=inventory["bytes"])
|
||||
require(not (self.root / "storage").exists(), "storage/ existiert bereits; kein leeres Migrationsziel.")
|
||||
s3config = {"identities": [
|
||||
{"name": "migration-admin", "credentials": [{"accessKey": admin["accessKeyId"], "secretKey": admin["secretAccessKey"]}],
|
||||
"actions": ["Admin", "Read", "Write", "List", "Tagging"]},
|
||||
{"name": "fedeo", "credentials": [{"accessKey": app["accessKeyId"], "secretKey": app["secretAccessKey"]}],
|
||||
"actions": [action + ":" + env["S3_BUCKET"] for action in ("Read", "Write", "List")]}
|
||||
]}
|
||||
u.write_json(self.home / "s3.json", s3config)
|
||||
self.make_compose(config)
|
||||
self.save("planned")
|
||||
|
||||
def make_compose(self, original):
|
||||
target = copy.deepcopy(original)
|
||||
store = copy.deepcopy(self.weed)
|
||||
store["image"] = self.state["weedImage"]
|
||||
store.update(container_name="fedeo-objectstore", restart="no", networks={"internal": {}},
|
||||
user=str(os.getuid()) + ":" + str(os.getgid()),
|
||||
environment={"FEDEO_S3_CONFIG_SHA256": sha_file(self.home / "s3.json")},
|
||||
volumes=[{"type": "bind", "source": str(self.root / "storage/seaweedfs"), "target": "/data"},
|
||||
{"type": "bind", "source": str(self.root / "storage/config"), "target": "/etc/fedeo", "read_only": True}])
|
||||
stage = {"name": self.state["project"], "services": {"objectstore": store},
|
||||
"networks": {"internal": {"external": True, "name": self.state["network"]}}}
|
||||
u.write_json(self.home / "stage.json", literal(stage))
|
||||
del target["services"]["minio"]
|
||||
del target["services"]["createbuckets"]
|
||||
target["services"]["objectstore"] = copy.deepcopy(store)
|
||||
target["services"]["objectstore"]["networks"]["internal"] = {"aliases": ["minio"]}
|
||||
target["services"]["objectstore"]["restart"] = "unless-stopped"
|
||||
dependencies = target["services"]["backend"].setdefault("depends_on", {})
|
||||
dependencies.pop("minio", None)
|
||||
dependencies.pop("createbuckets", None)
|
||||
dependencies["objectstore"] = {"condition": "service_healthy"}
|
||||
# Keep exactly the running application images; never couple this to an application/DB update.
|
||||
for name, c in self.state["containers"].items():
|
||||
if name in target["services"]:
|
||||
target["services"][name]["image"] = c["image"]
|
||||
target["services"]["backend"]["environment"]["FEDEO_RUN_MIGRATIONS"] = "false"
|
||||
# SDK 3.962 streaming trailers leave aws-chunked in SeaweedFS object metadata.
|
||||
# SigV4 remains active; the migration tool supplies an explicit SHA-256 checksum.
|
||||
target["services"]["backend"]["environment"]["AWS_REQUEST_CHECKSUM_CALCULATION"] = "WHEN_REQUIRED"
|
||||
u.write_json(self.home / "target.json", literal(target))
|
||||
# Fail before maintenance if Compose cannot parse the generated configurations.
|
||||
self.compose(self.home / "stage.json", "config", "--quiet")
|
||||
self.compose(self.home / "target.json", "config", "--quiet")
|
||||
|
||||
def verify_original(self):
|
||||
require(self.fingerprint() == self.state["fingerprint"], "Compose/.env wurde nach der Planung verändert.")
|
||||
for name, c in self.state["containers"].items():
|
||||
actual = self.inspect(c["id"])
|
||||
require(actual["Image"] == c["image"], "Containeridentität verändert: " + name)
|
||||
if name in self.state.get("retainedServices", []):
|
||||
index_containers({"services": {}}, [actual])
|
||||
|
||||
def freeze(self):
|
||||
self.save("freezing")
|
||||
self.guard()
|
||||
for name, c in self.state["containers"].items():
|
||||
if name != "db":
|
||||
self.docker("update", "--restart=no", c["id"])
|
||||
ids = [c["id"] for name, c in self.state["containers"].items() if name != "db"]
|
||||
if ids:
|
||||
self.docker("stop", "-t", "60", *ids)
|
||||
self.assert_frozen()
|
||||
|
||||
def assert_frozen(self):
|
||||
require(self.inspect(self.state["containers"]["minio"]["id"])["HostConfig"]["RestartPolicy"]["Name"] == "no",
|
||||
"MinIO könnte automatisch wieder starten.")
|
||||
for name, c in self.state["containers"].items():
|
||||
if name in ("db", "minio"):
|
||||
continue
|
||||
actual = self.inspect(c["id"])
|
||||
require(not actual["State"]["Running"] and actual["HostConfig"]["RestartPolicy"]["Name"] == "no",
|
||||
"Schreibpause wurde aufgehoben: " + name)
|
||||
known = {c["id"] for c in self.state["containers"].values()}
|
||||
peers = json.loads(self.docker("network", "inspect", self.state["network"]))[0].get("Containers", {})
|
||||
for cid in set(peers) - known:
|
||||
c = self.inspect(cid)
|
||||
labels = c["Config"].get("Labels", {})
|
||||
target = (labels.get("com.docker.compose.project") == self.state["project"] and
|
||||
labels.get("com.docker.compose.service") == "objectstore" and c["Image"] == self.state["weedImage"])
|
||||
own_helper = labels.get("io.fedeo.storage-job") == str(self.home)
|
||||
require(target or own_helper, "Ein zusätzlicher Container hat sich mit dem Speichernetz verbunden.")
|
||||
|
||||
def check_backup(self):
|
||||
backup = self.home / "backup"
|
||||
complete = u.read_json(backup / "complete.json", {})
|
||||
require(complete.get("sha256"), "Abgeschlossene Sicherung fehlt.")
|
||||
for name, expected in complete["sha256"].items():
|
||||
require(Path(name).name == name and sha_file(backup / name) == expected,
|
||||
"Backup-Prüfsumme stimmt nicht: " + name)
|
||||
|
||||
def backup(self):
|
||||
self.save("backing_up")
|
||||
backup = self.home / "backup"
|
||||
backup.mkdir(mode=0o700, exist_ok=True)
|
||||
require(not self.inspect(self.state["containers"]["minio"]["id"])["State"]["Running"], "MinIO für kalte Sicherung stoppen.")
|
||||
db = self.state["containers"]["db"]["id"]
|
||||
self.docker("exec", db, "sh", "-ec", 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc', output=backup / "database.dump")
|
||||
self.docker("exec", "-i", db, "pg_restore", "--list", input_file=backup / "database.dump", output=backup / "database.list")
|
||||
self.docker("exec", db, "sh", "-ec", 'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=backup / "globals.sql")
|
||||
paths = ["minio", *self.state["matrixPaths"], "storage/config", ".env", "docker-compose.yml"]
|
||||
if (self.root / "traefik/letsencrypt").is_dir():
|
||||
paths.append("traefik/letsencrypt")
|
||||
self.runner.run(["tar", "-cpf", str(backup / "files.tar"), "-C", str(self.root), *paths], timeout=7200)
|
||||
self.docker("image", "save", "-o", str(backup / "images.tar"), *self.state["imageIds"], timeout=7200)
|
||||
u.write_json(backup / "complete.json", {"at": u.now(), "sha256": {p.name: sha_file(p) for p in backup.iterdir() if p.is_file() and p.name != "complete.json"}})
|
||||
self.save("backed_up")
|
||||
|
||||
def migrate(self, writers_exclusive):
|
||||
require(writers_exclusive, "--writers-exclusive bestätigt: keine externen S3-/DB-Schreiber und kein paralleles manuelles Compose.")
|
||||
require(self.state.get("phase") in ("planned", "freezing", "backing_up", "backed_up", "copying", "verifying", "verified"),
|
||||
"Migration kann in dieser Phase nicht gestartet werden.")
|
||||
self.cleanup_helper()
|
||||
self.verify_original()
|
||||
require(shutil.disk_usage(self.root).free >= (self.state["requiredFreeBytes"] if self.state["phase"] == "planned" else GIB),
|
||||
"Nicht genug freier Speicher für Migration/Fortsetzung.")
|
||||
if self.state["phase"] in ("planned", "freezing", "backing_up"):
|
||||
(self.root / "storage/config").mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
(self.root / "storage/seaweedfs/master").mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
u.atomic(self.root / "storage/config/s3.json", (self.home / "s3.json").read_bytes())
|
||||
u.atomic(self.root / "storage/config/start-seaweedfs.sh", (HERE / "storage/start-seaweedfs.sh").read_bytes())
|
||||
self.freeze()
|
||||
self.docker("start", self.state["containers"]["minio"]["id"])
|
||||
self.wait_ready(self.state["containers"]["minio"]["id"])
|
||||
current = self.helper("inventory", source=True, directory="frozen-inventory")
|
||||
extra = max(0, current["bytes"] - self.state["bytes"]) * 4 + max(0, current["count"] - self.state["count"]) * 256 * 1024
|
||||
require(shutil.disk_usage(self.root).free >= self.state["requiredFreeBytes"] + extra,
|
||||
"Bestand seit Planung gewachsen; Speicherreserve reicht nicht mehr.")
|
||||
self.save(count=current["count"], bytes=current["bytes"])
|
||||
self.docker("stop", "-t", "60", self.state["containers"]["minio"]["id"])
|
||||
validate_minio_listing(self.root, u.read_json(self.home / "credentials.json")["bucket"], current, exact=True)
|
||||
self.backup()
|
||||
self.assert_frozen()
|
||||
self.guard()
|
||||
self.check_backup()
|
||||
self.docker("start", self.state["containers"]["minio"]["id"])
|
||||
self.wait_ready(self.state["containers"]["minio"]["id"])
|
||||
self.compose(self.home / "stage.json", "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "180")
|
||||
self.helper("init", admin=True, directory="init")
|
||||
self.helper("contract", directory="contract")
|
||||
self.helper("contract", directory="backend-contract", backend_sdk=True)
|
||||
self.save("copying")
|
||||
self.helper("copy", source=True)
|
||||
self.assert_frozen()
|
||||
self.save("verifying")
|
||||
self.helper("verify", source=True)
|
||||
self.assert_frozen()
|
||||
self.save("verified")
|
||||
|
||||
def restore_test(self):
|
||||
# Start a separate container on newly extracted directories. Never count a
|
||||
# successful archive listing or restart of the original directory as restore.
|
||||
self.save("restore_testing")
|
||||
restore = self.home / "restore-test"
|
||||
if restore.exists():
|
||||
# A previous interrupted restore is retained; use a fresh directory.
|
||||
restore = self.home / ("restore-test-" + secrets.token_hex(4))
|
||||
restore.mkdir(mode=0o700)
|
||||
self.runner.run(["tar", "-xpf", str(self.home / "backup/seaweedfs.tar"), "-C", str(restore)], timeout=7200)
|
||||
known = self.docker("ps", "-aq", "--filter", "name=^/" + self.restore_name + "$").split()
|
||||
for cid in known:
|
||||
require(self.inspect(cid)["Config"].get("Labels", {}).get("io.fedeo.storage-job") == str(self.home), "Fremder Restore-Container.")
|
||||
self.docker("rm", "-f", cid)
|
||||
target = u.read_json(self.home / "stage.json")["services"]["objectstore"]
|
||||
try:
|
||||
self.docker("run", "-d", "--pull", "never", "--name", self.restore_name,
|
||||
"--label", "io.fedeo.storage-job=" + str(self.home), "--network", self.state["network"],
|
||||
"--user", str(os.getuid()) + ":" + str(os.getgid()),
|
||||
"-v", str(restore / "storage/seaweedfs") + ":/data",
|
||||
"-v", str(restore / "storage/config") + ":/etc/fedeo:ro",
|
||||
"-e", "FEDEO_S3_CONFIG_SHA256=" + sha_file(self.home / "s3.json"),
|
||||
"--entrypoint", target["entrypoint"][0], self.state["weedImage"],
|
||||
*target["entrypoint"][1:], *target["command"])
|
||||
self.helper("restore-verify", endpoint="http://" + self.restore_name + ":9000")
|
||||
self.helper("contract", endpoint="http://" + self.restore_name + ":9000", directory="restore-contract")
|
||||
self.save("cutover", restoreVerifiedAt=u.now(), seaweedBackupSha256=sha_file(self.home / "backup/seaweedfs.tar"))
|
||||
finally:
|
||||
self.docker("rm", "-f", self.restore_name)
|
||||
|
||||
def activate(self):
|
||||
require(self.state.get("phase") in ("verified", "cutover", "restore_testing"), "Erst Kopie und Prüfung abschließen.")
|
||||
require(not self.state.get("writesReleased"), "Schreibfreigabe ist bereits dauerhaft erfolgt.")
|
||||
self.cleanup_helper()
|
||||
self.assert_frozen()
|
||||
# A killed contract test can leave only its journalled probe keys behind.
|
||||
# Bring the target up without the legacy alias, remove those exact keys,
|
||||
# then take the cold backup. Never delete unknown target objects.
|
||||
self.compose(self.home / "stage.json", "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "180")
|
||||
for directory in ("contract", "backend-contract", "acceptance", "backend-acceptance"):
|
||||
if (self.home / directory / "probe.json").is_file():
|
||||
self.helper("cleanup-probe", directory=directory)
|
||||
if self.state["phase"] == "verified":
|
||||
self.verify_original()
|
||||
self.helper("verify", source=True)
|
||||
self.save("cutover")
|
||||
self.guard()
|
||||
self.check_backup()
|
||||
old = self.state["containers"]["minio"]["id"]
|
||||
self.docker("stop", "-t", "60", old)
|
||||
validate_minio_listing(self.root, u.read_json(self.home / "credentials.json")["bucket"],
|
||||
u.read_json(self.home / "copy/inventory.json"), exact=True)
|
||||
if self.state["network"] in self.inspect(old)["NetworkSettings"]["Networks"]:
|
||||
self.docker("network", "disconnect", self.state["network"], old)
|
||||
self.compose(self.home / "stage.json", "stop", "-t", "60", "objectstore")
|
||||
# A cold backup is required before serving any business write on the new provider.
|
||||
self.runner.run(["tar", "-cpf", str(self.home / "backup/seaweedfs.tar"), "-C", str(self.root), "storage"], timeout=7200)
|
||||
self.restore_test()
|
||||
u.atomic(self.root / "docker-compose.yml", (self.home / "target.json").read_bytes())
|
||||
self.compose(self.root / "docker-compose.yml", "up", "-d", "--no-deps", "--pull", "never", "--wait", "--wait-timeout", "180", "objectstore")
|
||||
self.helper("restore-verify", endpoint="http://minio:9000")
|
||||
self.helper("contract", endpoint="http://minio:9000", directory="acceptance")
|
||||
self.helper("contract", endpoint="http://minio:9000", directory="backend-acceptance", backend_sdk=True)
|
||||
# Persist before starting any backend/background process; no automatic rollback beyond here.
|
||||
self.save("released", writesReleased=True, releasedAt=u.now(), provider="seaweedfs")
|
||||
self.guard()
|
||||
self.start_application()
|
||||
|
||||
def start_application(self):
|
||||
require(self.state.get("writesReleased"), "Schreibfreigabe fehlt.")
|
||||
require(sha_file(self.root / "docker-compose.yml") == sha_file(self.home / "target.json"), "Aktive Compose-Datei verändert.")
|
||||
names = [n for n, c in self.state["containers"].items() if c["running"] and n not in ("minio", "createbuckets", "traefik")]
|
||||
# Preserve Matrix without letting an already failing optional healthcheck
|
||||
# cause a new FEDEO outage after cutover. Core services must always pass.
|
||||
degraded = [n for n in names if n in u.LEGACY_MATRIX_SERVICES
|
||||
and self.state["containers"][n].get("health") == "unhealthy"]
|
||||
self.compose(self.root / "docker-compose.yml", "up", "-d", "--pull", "never", *names)
|
||||
self.compose(self.root / "docker-compose.yml", "up", "-d", "--no-deps", "--pull", "never",
|
||||
"--wait", "--wait-timeout", "240", *[n for n in names if n not in degraded])
|
||||
for name in degraded:
|
||||
cid = self.compose(self.root / "docker-compose.yml", "ps", "--all", "--quiet", name).strip()
|
||||
require(cid and "\n" not in cid and self.inspect(cid)["State"]["Running"],
|
||||
"Alter Matrix-Dienst wurde nicht wieder gestartet: " + name)
|
||||
self.compose(self.root / "docker-compose.yml", "up", "-d", "--no-deps", "--pull", "never", "traefik")
|
||||
self.save("complete", preexistingUnhealthyServices=degraded)
|
||||
|
||||
def rollback(self):
|
||||
require(self.state and not self.state.get("writesReleased"), "Nach Schreibfreigabe ist eine geprüfte Rückmigration erforderlich.")
|
||||
require(sha_file(self.root / ".env") == self.state["fingerprint"][".env"], ".env verändert; Rückkehr manuell prüfen.")
|
||||
require(sha_file(self.root / "docker-compose.yml") in {self.state["fingerprint"]["docker-compose.yml"], sha_file(self.home / "target.json")},
|
||||
"Compose-Datei außerhalb der Migration verändert; Rückkehr manuell prüfen.")
|
||||
self.cleanup_helper()
|
||||
for cid in self.docker("ps", "-aq", "--filter", "name=^/" + self.restore_name + "$").split():
|
||||
require(self.inspect(cid)["Config"].get("Labels", {}).get("io.fedeo.storage-job") == str(self.home), "Fremder Restore-Container.")
|
||||
self.docker("rm", "-f", cid)
|
||||
require(self.state["phase"] != "planning", "Unvollständiger Plan.")
|
||||
self.save("rolling_back")
|
||||
self.guard()
|
||||
# May not exist when the failure occurred before the first target start.
|
||||
ids = self.docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + self.state["project"],
|
||||
"--filter", "label=com.docker.compose.service=objectstore").split()
|
||||
for cid in ids:
|
||||
self.docker("update", "--restart=no", cid)
|
||||
self.docker("stop", "-t", "60", cid)
|
||||
if self.state["network"] in self.inspect(cid)["NetworkSettings"]["Networks"]:
|
||||
self.docker("network", "disconnect", self.state["network"], cid)
|
||||
u.atomic(self.root / "docker-compose.yml", (self.home / "originaldocker-compose.yml").read_bytes())
|
||||
for name, c in self.state["containers"].items():
|
||||
actual = self.inspect(c["id"])
|
||||
if name == "minio" and self.state["network"] not in actual["NetworkSettings"]["Networks"]:
|
||||
aliases = c["networks"][self.state["network"]].get("Aliases") or ["minio"]
|
||||
args = [arg for alias in aliases for arg in ("--alias", alias)]
|
||||
self.docker("network", "connect", *args, self.state["network"], c["id"])
|
||||
policy = c["restart"]["Name"]
|
||||
if policy == "on-failure" and c["restart"].get("MaximumRetryCount"):
|
||||
policy += ":" + str(c["restart"]["MaximumRetryCount"])
|
||||
self.docker("update", "--restart=" + policy, c["id"])
|
||||
for name in ["db", "minio", *[n for n in self.state["containers"] if n not in ("db", "minio", "traefik")], "traefik"]:
|
||||
c = self.state["containers"].get(name)
|
||||
if c and c["running"]:
|
||||
self.docker("start", c["id"])
|
||||
self.wait_ready(c["id"])
|
||||
self.save("rolled_back")
|
||||
u.write_json(self.engine.home / "status.json", u.read_json(self.home / "previous-update.json"))
|
||||
|
||||
def public_status(self):
|
||||
return {k: self.state[k] for k in ("phase", "version", "count", "bytes", "requiredFreeBytes", "freeBytes",
|
||||
"writesReleased", "releasedAt", "updatedAt") if k in self.state}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["plan", "migrate", "activate", "resume-start", "rollback", "status"])
|
||||
parser.add_argument("--directory", default="/opt/fedeo")
|
||||
parser.add_argument("--tools-image")
|
||||
parser.add_argument("--writers-exclusive", action="store_true")
|
||||
args = parser.parse_args()
|
||||
migration = Migration(args.directory, args.tools_image)
|
||||
try:
|
||||
if args.command == "status":
|
||||
print(json.dumps(migration.public_status(), indent=2)); return
|
||||
with migration.engine.lock():
|
||||
if args.command == "plan": migration.plan()
|
||||
elif args.command == "migrate": migration.migrate(args.writers_exclusive)
|
||||
elif args.command == "activate": migration.activate()
|
||||
elif args.command == "resume-start": migration.start_application()
|
||||
elif args.command == "rollback": migration.rollback()
|
||||
print(json.dumps(migration.public_status(), indent=2))
|
||||
except (Error, OSError, ValueError, KeyboardInterrupt) as error:
|
||||
print("Migration angehalten: " + str(error) + "\nGeschütztes Protokoll: " + str(migration.home / "commands.log"), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -14,6 +14,7 @@ import socketserver
|
||||
import stat
|
||||
import threading
|
||||
import traceback
|
||||
import urllib.parse
|
||||
|
||||
spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py"))
|
||||
engine = importlib.util.module_from_spec(spec)
|
||||
@@ -33,7 +34,8 @@ ERRORS = {
|
||||
"COMPOSE_REVIEW_REQUIRED": "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
|
||||
"RECOVERY_REQUIRED": "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
|
||||
"PLAN_CHANGED": "Release oder Konfiguration haben sich geändert. Erneut prüfen.",
|
||||
"UPDATE_FAILED": "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
|
||||
"UPDATE_FAILED": "Auftrag fehlgeschlagen.",
|
||||
"LOGS_UNAVAILABLE": "Das Update-Protokoll kann gerade nicht gelesen werden.",
|
||||
"INTERRUPTED": "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
|
||||
}
|
||||
|
||||
@@ -52,8 +54,10 @@ def identifier(value):
|
||||
def public_state(state):
|
||||
"""An allowlist, never raw command logs, exceptions, host paths, config or diff contents."""
|
||||
result = {"status": state.get("status") if state.get("status") in STATUSES else "idle"}
|
||||
if state.get("phase") in PHASES:
|
||||
result["phase"] = state["phase"]
|
||||
# Host tools are installed before the application update; old backend enums must still parse us.
|
||||
phase = {"checking_storage": "validating", "storage_adopted": "resolved"}.get(state.get("phase"), state.get("phase"))
|
||||
if phase in PHASES:
|
||||
result["phase"] = phase
|
||||
for key in ("recoveryRequired", "maintenance", "mutationStarted"):
|
||||
if key in state:
|
||||
result[key] = bool(state[key])
|
||||
@@ -79,9 +83,80 @@ def public_plan(plan):
|
||||
if type(plan.get("estimatedBackupBytes")) is int:
|
||||
result["estimatedBackupBytes"] = plan["estimatedBackupBytes"]
|
||||
result["retiredServices"] = sorted(set(plan.get("retiredServices", [])) & engine.LEGACY_MATRIX_SERVICES)
|
||||
if plan.get("storageProvider") in ("minio", "seaweedfs"):
|
||||
result["storageProvider"] = plan["storageProvider"]
|
||||
result["storageImageRetained"] = plan.get("storageImageRetained") is True
|
||||
return result
|
||||
|
||||
|
||||
LOG_BYTES = 6000
|
||||
SECRET_KEY = r"[\w.-]*(?:password|passwd|secret|token|credential|authorization|(?:api|private|access|encryption|signing)[_-]?key|database[_-]?url|dsn|auth)[\w.-]*"
|
||||
QUOTED_VALUE = r"\"(?:\\.|[^\"\\])*\"|'[^']*'"
|
||||
SECRET_VALUE = re.compile(r"(?im)(?<![\w.-])([\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*)(" + QUOTED_VALUE + r"|[^\r\n,}\]]+)")
|
||||
CONFIG_SECRET = re.compile(r"(?im)^\s*(?:export\s+|-\s*)?[\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*(" + QUOTED_VALUE + r"|[^\r\n]+)")
|
||||
|
||||
|
||||
def read_private(path, *, tail=False):
|
||||
"""Read only regular files, never follow a log/config symlink or block on a FIFO."""
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
except FileNotFoundError:
|
||||
return "", False
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
size = os.fstat(stream.fileno()).st_size
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
raise OSError("Not a regular file")
|
||||
limit = 32768 if tail else 1024 * 1024
|
||||
if size > limit and not tail:
|
||||
raise OSError("Configuration too large to redact safely")
|
||||
truncated = size > limit
|
||||
if truncated:
|
||||
stream.seek(-limit, os.SEEK_END)
|
||||
stream.readline(limit) # Never expose a suffix of a secret on a cut line.
|
||||
return stream.read(limit).decode("utf-8", errors="replace"), truncated
|
||||
|
||||
|
||||
def log_redactor(directory, job):
|
||||
# Include original values after an update changed the active configuration.
|
||||
paths = [directory / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
|
||||
if job:
|
||||
paths += [job / "target.env", job / "target.compose.yml"]
|
||||
paths += [job / "backup/config" / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
|
||||
values = {value for key, value in os.environ.items() if re.fullmatch(SECRET_KEY, key, re.I) and value}
|
||||
for path in paths:
|
||||
content, _ = read_private(path)
|
||||
for match in CONFIG_SECRET.finditer(content):
|
||||
value = match[1].strip()
|
||||
if value[:1] in ("'", '"'):
|
||||
if value.startswith('"'):
|
||||
try:
|
||||
values.add(json.loads(value))
|
||||
except ValueError:
|
||||
pass
|
||||
value = value[1:-1]
|
||||
else:
|
||||
value = value.split(" #", 1)[0].strip()
|
||||
if value:
|
||||
values.add(value)
|
||||
for match in re.finditer(r"[a-z][a-z0-9+.-]*://[^\s\"']+", content, re.I):
|
||||
password = urllib.parse.urlsplit(match[0]).password
|
||||
if password:
|
||||
values.update((password, urllib.parse.unquote(password)))
|
||||
values |= {urllib.parse.quote(value, safe="") for value in values}
|
||||
|
||||
def redact(content):
|
||||
content = re.sub(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))", "", content)
|
||||
for value in sorted(values, key=len, reverse=True):
|
||||
content = content.replace(value, "[REDACTED]")
|
||||
content = re.sub(r"(?is)-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|\Z)", "[REDACTED]", content)
|
||||
content = re.sub(r"(?i)([a-z][a-z0-9+.-]*://)[^\s/]*@", r"\1[REDACTED]@", content)
|
||||
content = re.sub(r"(?i)\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]+", r"\1 [REDACTED]", content)
|
||||
content = SECRET_VALUE.sub(lambda m: m[1] + "[REDACTED]", content)
|
||||
content = re.sub(r"(?i)(--" + SECRET_KEY + r"\s+)(" + QUOTED_VALUE + r"|\S+)", r"\1[REDACTED]", content)
|
||||
return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", content)
|
||||
return redact
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, directory, *, updater_factory=engine.Updater):
|
||||
self.directory = Path(directory).resolve()
|
||||
@@ -109,6 +184,9 @@ class Controller:
|
||||
if record.get("status") in ("queued", "running"):
|
||||
record.update(status="interrupted", errorCode="INTERRUPTED", updatedAt=engine.now())
|
||||
engine.write_json(path, record)
|
||||
if (REQUEST_ID.fullmatch(str(record.get("requestId", "")))
|
||||
and (self.active is None or record.get("startedAt", "") > self.read(self.active).get("startedAt", ""))):
|
||||
self.active = record["requestId"]
|
||||
updater.status() # Detect an abandoned engine job without touching an active CLI job.
|
||||
except BaseException:
|
||||
self.daemon_lock.close()
|
||||
@@ -137,12 +215,55 @@ class Controller:
|
||||
result["progress"] = public_state(state)
|
||||
return result
|
||||
|
||||
def request_logs(self, request_id):
|
||||
record = self.read(request_id)
|
||||
try:
|
||||
home = self.home.parent
|
||||
state = engine.read_json(home / "status.json", {})
|
||||
candidates = [Path(record["job"])] if record.get("job") else []
|
||||
if state.get("requestId") == request_id and state.get("job"):
|
||||
candidates.append(Path(state["job"]))
|
||||
# Older service versions did not persist the engine-job association.
|
||||
if not candidates:
|
||||
candidates = [path.parent for path in home.glob("*/status.json")]
|
||||
job = None
|
||||
for candidate in candidates:
|
||||
if candidate.parent != home or candidate.is_symlink() or not candidate.is_dir():
|
||||
continue
|
||||
content, _ = read_private(candidate / "status.json")
|
||||
if content and json.loads(content).get("requestId") == request_id:
|
||||
job = candidate
|
||||
break
|
||||
redact = log_redactor(self.directory, job)
|
||||
files = [("service", self.home / (request_id + ".log"))]
|
||||
if job:
|
||||
files += [("events", job / "events.jsonl"), ("commands", job / "commands.log")]
|
||||
logs = []
|
||||
for source, path in files:
|
||||
content, truncated = read_private(path, tail=True)
|
||||
content = redact(content)
|
||||
encoded = content.encode("utf-8")
|
||||
if len(encoded) > LOG_BYTES:
|
||||
content = encoded[-LOG_BYTES:].decode("utf-8", errors="ignore")
|
||||
truncated = True
|
||||
if content or truncated:
|
||||
logs.append({"source": source, "content": content, "truncated": truncated})
|
||||
return {"requestId": request_id, "logs": logs}
|
||||
except (OSError, ValueError, TypeError):
|
||||
raise ApiError("LOGS_UNAVAILABLE", 503) from None
|
||||
|
||||
def status(self):
|
||||
updater = self.factory(self.directory)
|
||||
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing}
|
||||
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing,
|
||||
"capabilities": {"requestLogs": True}}
|
||||
installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {})
|
||||
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
|
||||
"revision": installed.get("commit")}).items() if k != "status"}
|
||||
storage = engine.read_json(updater.home / "storage-provider.json", {})
|
||||
migration = engine.read_json(updater.home / "storage-migration/state.json", {})
|
||||
if storage.get("provider") == "seaweedfs" or migration.get("writesReleased"):
|
||||
result["storage"] = {"provider": "seaweedfs",
|
||||
"migrationPending": engine.read_json(updater.home / "status.json", {}).get("phase") == "storage_migration"}
|
||||
if self.active:
|
||||
result["request"] = self.request_status(self.active)
|
||||
return result
|
||||
@@ -205,6 +326,7 @@ class Controller:
|
||||
|
||||
def run(self, record, fingerprint):
|
||||
request_id = record["requestId"]
|
||||
updater = None
|
||||
try:
|
||||
record.update(status="running", updatedAt=engine.now())
|
||||
engine.write_json(self.path(request_id), record)
|
||||
@@ -221,6 +343,8 @@ class Controller:
|
||||
os.chmod(log.name, 0o600)
|
||||
traceback.print_exc(file=log)
|
||||
finally:
|
||||
if updater and updater.state and updater.state.get("requestId") == request_id:
|
||||
record["job"] = str(updater.job)
|
||||
record["updatedAt"] = engine.now()
|
||||
engine.write_json(self.path(request_id), record)
|
||||
|
||||
@@ -264,7 +388,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.command == "GET" and self.path == "/v1/status":
|
||||
self.send_json(200, controller.status())
|
||||
elif self.command == "GET" and self.path.startswith("/v1/requests/"):
|
||||
self.send_json(200, controller.request_status(self.path.removeprefix("/v1/requests/")))
|
||||
suffix = self.path.removeprefix("/v1/requests/")
|
||||
self.send_json(200, controller.request_logs(suffix.removesuffix("/logs"))
|
||||
if suffix.endswith("/logs") else controller.request_status(suffix))
|
||||
elif self.command == "POST" and self.path in ("/v1/check", "/v1/apply"):
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FEDEO host-side update engine. Python 3.10+, Docker Compose 2.24+; no pip packages.
|
||||
|
||||
The CLI and a future host service share Updater. No daemon or HTTP API is installed.
|
||||
The CLI and the optional local host service share Updater and the exclusive lock.
|
||||
All commands operate on an existing local selfhost installation, never a git checkout.
|
||||
"""
|
||||
import argparse
|
||||
import contextlib
|
||||
import copy
|
||||
import datetime
|
||||
import difflib
|
||||
import fcntl
|
||||
@@ -31,6 +32,103 @@ FILES = ("release-manifest.json", "docker-compose.selfhost.yml", ".env.example",
|
||||
GIB = 1024 ** 3
|
||||
LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
|
||||
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"}
|
||||
CORE_SERVICES = {"backend", "frontend", "db", "traefik", "node-exporter"}
|
||||
IMAGE_ID = re.compile(r"sha256:[a-f0-9]{64}\Z")
|
||||
|
||||
|
||||
def compose_strings(value, *, escape=False):
|
||||
"""Round-trip resolved Compose output, including literal dollars in secrets."""
|
||||
if isinstance(value, str):
|
||||
return value.replace("$", "$$") if escape else value.replace("$$", "$")
|
||||
if isinstance(value, list):
|
||||
return [compose_strings(v, escape=escape) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: compose_strings(v, escape=escape) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def file_sha(path):
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
# Runs with the actual application's SDK. Never prints keys, URLs or credentials.
|
||||
S3_READ_CHECK = r"""
|
||||
const {S3Client, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand} = require('@aws-sdk/client-s3');
|
||||
const client = new S3Client({endpoint:process.env.S3_ENDPOINT, region:process.env.S3_REGION,
|
||||
forcePathStyle:true, maxAttempts:8, credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}});
|
||||
const timer = setTimeout(()=>process.exit(1), 180000);
|
||||
(async()=>{
|
||||
const Bucket=process.env.S3_BUCKET;
|
||||
await client.send(new HeadBucketCommand({Bucket}));
|
||||
const listed=await client.send(new ListObjectsV2Command({Bucket,MaxKeys:1}));
|
||||
const first=listed.Contents?.[0];
|
||||
if(first) {
|
||||
const object=await client.send(new GetObjectCommand({Bucket,Key:first.Key,...(first.Size ? {Range:'bytes=0-0'} : {})}));
|
||||
await object.Body.transformToByteArray();
|
||||
}
|
||||
})().catch(()=>{console.error('S3-Zugriffsprüfung fehlgeschlagen');process.exitCode=1})
|
||||
.finally(()=>{clearTimeout(timer);client.destroy()});
|
||||
"""
|
||||
|
||||
S3_UPGRADE_CHECK = r"""
|
||||
const s3 = require('@aws-sdk/client-s3');
|
||||
const {getSignedUrl} = require('@aws-sdk/s3-request-presigner');
|
||||
const {Readable} = require('node:stream');
|
||||
const assert = require('node:assert/strict');
|
||||
const client = new s3.S3Client({endpoint:process.env.S3_ENDPOINT, region:process.env.S3_REGION,
|
||||
forcePathStyle:true, maxAttempts:8,
|
||||
credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}});
|
||||
const Bucket=process.env.S3_BUCKET, Key=process.env.FEDEO_UPDATE_PROBE_KEY;
|
||||
const timer=setTimeout(()=>process.exit(1), 180000);
|
||||
(async()=>{
|
||||
assert.match(Key, /^\.fedeo-update-check\/[a-f0-9]{32}\.pdf$/);
|
||||
try {
|
||||
await client.send(new s3.HeadBucketCommand({Bucket}));
|
||||
const body=Buffer.from('FEDEO S3 update check');
|
||||
await client.send(new s3.PutObjectCommand({Bucket,Key,Body:Readable.from([body]),ContentLength:body.length,
|
||||
ContentType:'application/octet-stream',Metadata:{fedeo:'update-check'}}));
|
||||
const head=await client.send(new s3.HeadObjectCommand({Bucket,Key}));
|
||||
assert.equal(head.ContentType,'application/octet-stream');
|
||||
assert.equal(head.Metadata.fedeo,'update-check');
|
||||
assert.ok(!head.ContentEncoding);
|
||||
const result=await client.send(new s3.GetObjectCommand({Bucket,Key}));
|
||||
assert.deepEqual(Buffer.from(await result.Body.transformToByteArray()),body);
|
||||
const signed=await getSignedUrl(client,new s3.GetObjectCommand({Bucket,Key}),{expiresIn:86400});
|
||||
const response=await fetch(signed);
|
||||
assert.equal(response.status,200);
|
||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()),body);
|
||||
const anonymous=new URL(signed); anonymous.search='';
|
||||
const denied=await fetch(anonymous);
|
||||
assert.equal(denied.status,403); await denied.body?.cancel();
|
||||
} finally { await client.send(new s3.DeleteObjectCommand({Bucket,Key})); }
|
||||
try { await client.send(new s3.HeadObjectCommand({Bucket,Key})); throw new Error('probe remains'); }
|
||||
catch(error) { if(error.$metadata?.httpStatusCode!==404) throw error; }
|
||||
})().catch(()=>{console.error('S3-Vertrag des Zielbackends fehlgeschlagen');process.exitCode=1})
|
||||
.finally(()=>{clearTimeout(timer);client.destroy()});
|
||||
"""
|
||||
|
||||
S3_CLEANUP_CHECK = r"""
|
||||
const s3=require('@aws-sdk/client-s3'), assert=require('node:assert/strict');
|
||||
const client=new s3.S3Client({endpoint:process.env.S3_ENDPOINT,region:process.env.S3_REGION,forcePathStyle:true,
|
||||
credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}});
|
||||
const Bucket=process.env.S3_BUCKET, Key=process.env.FEDEO_UPDATE_PROBE_KEY;
|
||||
const timer=setTimeout(()=>process.exit(1),60000);
|
||||
(async()=>{
|
||||
assert.match(Key,/^\.fedeo-update-check\/[a-f0-9]{32}\.pdf$/);
|
||||
let head;
|
||||
try { head=await client.send(new s3.HeadObjectCommand({Bucket,Key})); }
|
||||
catch(error) { if(error.$metadata?.httpStatusCode===404) return; throw error; }
|
||||
assert.equal(head.Metadata?.fedeo,'update-check');
|
||||
await client.send(new s3.DeleteObjectCommand({Bucket,Key}));
|
||||
try { await client.send(new s3.HeadObjectCommand({Bucket,Key})); throw new Error('probe remains'); }
|
||||
catch(error) { if(error.$metadata?.httpStatusCode!==404) throw error; }
|
||||
})().catch(()=>{console.error('Protokollierten S3-Prüfschlüssel manuell prüfen');process.exitCode=1})
|
||||
.finally(()=>{clearTimeout(timer);client.destroy()});
|
||||
"""
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
@@ -283,7 +381,7 @@ class Updater:
|
||||
return self.runner.run(prefix + list(args), **kwargs)
|
||||
|
||||
def config(self, candidate):
|
||||
return json.loads(self.compose(candidate, "config", "--format", "json"))
|
||||
return compose_strings(json.loads(self.compose(candidate, "config", "--format", "json")))
|
||||
|
||||
def inspect(self, name):
|
||||
return json.loads(self.runner.run(["docker", "inspect", name]))[0]
|
||||
@@ -292,6 +390,166 @@ class Updater:
|
||||
if shutil.disk_usage(directory).free < required:
|
||||
raise UpdateError(f"Zu wenig freier Speicher auf {directory}: mindestens {required // GIB + 1} GiB erforderlich.")
|
||||
|
||||
def storage_record(self):
|
||||
path = self.home / "storage-provider.json"
|
||||
if path.is_symlink():
|
||||
raise UpdateError("Speicherbeschreibung darf kein symbolischer Link sein.")
|
||||
record = read_json(path, {})
|
||||
if record and (record.get("schemaVersion") != 1 or record.get("provider") != "seaweedfs"
|
||||
or not IMAGE_ID.fullmatch(str(record.get("image", "")))):
|
||||
raise UpdateError("Unbekannte Speicherbeschreibung; Speichermigration auf dem Host prüfen.")
|
||||
return record
|
||||
|
||||
def validate_storage(self, config, record, *, running=True):
|
||||
"""The adopted storage service is immutable across application releases."""
|
||||
store = config.get("services", {}).get("objectstore", {})
|
||||
if store != record.get("service") or store.get("image") != record.get("image"):
|
||||
raise UpdateError("SeaweedFS-Konfiguration oder Image verändert; separate Speichermigration erforderlich.")
|
||||
for directory in ("storage", "storage/seaweedfs", "storage/config"):
|
||||
path = self.root / directory
|
||||
if not path.is_dir() or path.resolve() != path:
|
||||
raise UpdateError("SeaweedFS benötigt reguläre lokale storage/-Verzeichnisse.")
|
||||
if any(p.is_symlink() for p in (self.root / "storage").rglob("*")):
|
||||
raise UpdateError("Symbolische Links in storage/ können nicht vollständig gesichert werden.")
|
||||
expected = {"/data": str(self.root / "storage/seaweedfs"), "/etc/fedeo": str(self.root / "storage/config")}
|
||||
volumes = store.get("volumes", [])
|
||||
if (len(volumes) != 2 or any(v.get("type") != "bind" for v in volumes)
|
||||
or {v.get("target"): v.get("source") for v in volumes} != expected
|
||||
or not next(v for v in volumes if v["target"] == "/etc/fedeo").get("read_only")):
|
||||
raise UpdateError("SeaweedFS-Datenmounts entsprechen nicht der übernommenen lokalen Ablage.")
|
||||
for name in ("s3.json", "start-seaweedfs.sh"):
|
||||
path = self.root / "storage/config" / name
|
||||
if not path.is_file() or file_sha(path) != record.get("configSha256", {}).get(name):
|
||||
raise UpdateError("Geschützte SeaweedFS-Konfiguration verändert oder unvollständig.")
|
||||
if store.get("environment", {}).get("FEDEO_S3_CONFIG_SHA256") != record["configSha256"]["s3.json"]:
|
||||
raise UpdateError("SeaweedFS-Konfigurationsprüfsumme stimmt nicht.")
|
||||
network = config.get("networks", {}).get("internal", {}).get("name")
|
||||
if (network != record.get("network") or store.get("ports") or set(store.get("networks", {})) != {"internal"}
|
||||
or "minio" not in store["networks"]["internal"].get("aliases", [])):
|
||||
raise UpdateError("SeaweedFS muss ausschließlich am bisherigen internen S3-Anschluss liegen.")
|
||||
old = self.inspect(record["retainedMinio"]["id"])
|
||||
if (old.get("Image") != record["retainedMinio"]["image"]
|
||||
or any(old.get("State", {}).get(k) for k in ("Running", "Restarting", "Paused"))
|
||||
or old.get("HostConfig", {}).get("RestartPolicy", {}).get("Name") != "no"
|
||||
or network in old.get("NetworkSettings", {}).get("Networks", {})):
|
||||
raise UpdateError("Der erhaltene MinIO-Container ist nicht unverändert stillgelegt.")
|
||||
if running:
|
||||
cid = self.compose(False, "ps", "--quiet", "objectstore").strip()
|
||||
actual = self.inspect(cid) if cid and "\n" not in cid else {}
|
||||
mounts = actual.get("Mounts", [])
|
||||
if (actual.get("Image") != record["image"] or not actual.get("State", {}).get("Running")
|
||||
or actual.get("State", {}).get("Health", {}).get("Status") != "healthy"
|
||||
or actual.get("HostConfig", {}).get("PortBindings")
|
||||
or set(actual.get("NetworkSettings", {}).get("Networks", {})) != {network}
|
||||
or "minio" not in actual["NetworkSettings"]["Networks"][network].get("Aliases", [])
|
||||
or len(mounts) != 2 or any(v.get("Type") != "bind" for v in mounts)
|
||||
or {v.get("Destination"): v.get("Source") for v in mounts} != expected
|
||||
or any(v.get("RW") for v in mounts if v.get("Destination") == "/etc/fedeo")
|
||||
or ("FEDEO_S3_CONFIG_SHA256=" + record["configSha256"]["s3.json"]) not in actual.get("Config", {}).get("Env", [])):
|
||||
raise UpdateError("Laufender SeaweedFS-Container weicht vom geprüften Speicher ab.")
|
||||
|
||||
def adopt_storage(self):
|
||||
"""Explicit host-only handover; cannot resolve an interrupted migration/update."""
|
||||
with self.lock():
|
||||
guard = read_json(self.home / "status.json", {})
|
||||
if guard.get("phase") != "storage_migration" or not guard.get("recoveryRequired"):
|
||||
if self.storage_record() and not guard.get("recoveryRequired"):
|
||||
return {"status": "already_adopted", "storageProvider": "seaweedfs"}
|
||||
raise UpdateError("Keine abgeschlossene Pilotmigration zur Übernahme vorhanden.")
|
||||
migration = self.home / "storage-migration"
|
||||
if migration.is_symlink() or guard.get("job") != str(migration):
|
||||
raise UpdateError("Speichermigrationsauftrag stimmt nicht.")
|
||||
state = read_json(migration / "state.json", {})
|
||||
if (state.get("schemaVersion") != 1 or state.get("phase") != "complete"
|
||||
or state.get("writesReleased") is not True or state.get("provider") != "seaweedfs"
|
||||
or not state.get("restoreVerifiedAt") or not IMAGE_ID.fullmatch(str(state.get("weedImage", "")))):
|
||||
raise UpdateError("Speichermigration und geprüften Restore zuerst vollständig abschließen; kein resolve verwenden.")
|
||||
if any((self.root / n).exists() for n in ("docker-compose.override.yml", "docker-compose.override.yaml", "compose.yml", "compose.yaml")):
|
||||
raise UpdateError("Pilotübernahme unterstützt keine zusätzlichen Compose-Dateien.")
|
||||
before = {n: file_sha(self.root / n) for n in (".env", "docker-compose.yml")}
|
||||
if any((self.root / n).is_symlink() for n in before):
|
||||
raise UpdateError("Konfigurationsdateien dürfen keine symbolischen Links sein.")
|
||||
if (before["docker-compose.yml"] != file_sha(migration / "target.json")
|
||||
or before[".env"] != state.get("fingerprint", {}).get(".env")):
|
||||
raise UpdateError("Installation nach der Speichermigration verändert; Übernahme manuell prüfen.")
|
||||
verified = read_json(migration / "copy/verified.json", {})
|
||||
if (not verified.get("verifiedAt") or verified.get("count") != state.get("count")
|
||||
or verified.get("bytes") != state.get("bytes")
|
||||
or len(verified.get("objects", [])) != state.get("count")):
|
||||
raise UpdateError("Vollständiger Kopiernachweis der Speichermigration fehlt.")
|
||||
for name in ("restore-contract", "acceptance", "backend-acceptance"):
|
||||
if not {"PUT", "HEAD", "GET", "DELETE", "SigV4", "presigned-24h", "anonymous-denied", "wrong-key-denied"} <= set(
|
||||
read_json(migration / name / "result.json", {}).get("operations", [])):
|
||||
raise UpdateError("S3-Abnahme der Speichermigration fehlt: " + name)
|
||||
if (not (migration / "backup/complete.json").is_file()
|
||||
or file_sha(migration / "backup/seaweedfs.tar") != state.get("seaweedBackupSha256")):
|
||||
raise UpdateError("Geprüfte Speichersicherung fehlt oder wurde verändert.")
|
||||
self.job, self.override = migration, None
|
||||
self.runner = self.runner_factory(migration / "adoption.log")
|
||||
endpoint = self.runner.run(["docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"] ).strip()
|
||||
if not os.environ.get("DOCKER_HOST", endpoint).startswith("unix://"):
|
||||
raise UpdateError("Speicherübernahme muss direkt auf dem lokalen Docker-Host laufen.")
|
||||
config = self.config(False)
|
||||
self.project = config["name"]
|
||||
required = CORE_SERVICES | {"objectstore"}
|
||||
if not required <= set(config["services"]) or set(config["services"]) - required - LEGACY_MATRIX_SERVICES:
|
||||
raise UpdateError("Speicherübernahme benötigt den Standard-Stack; nur bekannte alte Matrix-Dienste sind zusätzlich erlaubt.")
|
||||
original = read_json(migration / "original.json")
|
||||
record = {"schemaVersion": 1, "provider": "seaweedfs", "adoptedAt": now(),
|
||||
"image": state["weedImage"], "network": state["network"],
|
||||
"service": config["services"]["objectstore"],
|
||||
"databaseSourceImage": original["services"]["db"]["image"],
|
||||
"databaseImage": state["containers"]["db"]["image"],
|
||||
"retainedMinio": {k: state["containers"]["minio"][k] for k in ("id", "image")},
|
||||
"configSha256": {n: file_sha(self.root / "storage/config" / n) for n in ("s3.json", "start-seaweedfs.sh")}}
|
||||
self.validate_storage(config, record)
|
||||
for name in CORE_SERVICES:
|
||||
cid = self.compose(False, "ps", "--quiet", name).strip()
|
||||
actual = self.inspect(cid) if cid and "\n" not in cid else {}
|
||||
if (actual.get("Image") != state["containers"][name]["image"]
|
||||
or not actual.get("State", {}).get("Running")
|
||||
or actual.get("State", {}).get("Health", {}).get("Status") not in (None, "healthy")):
|
||||
raise UpdateError("Anwendung nach Speichermigration nicht gesund/unverändert: " + name)
|
||||
self.compose(False, "exec", "-T", "backend", "node", "-e", S3_READ_CHECK, timeout=210)
|
||||
if any(file_sha(self.root / n) != digest for n, digest in before.items()):
|
||||
raise UpdateError("Konfiguration während Speicherübernahme verändert.")
|
||||
# The old guard remains until both records are durable; retry after a crash is safe.
|
||||
write_json(self.home / "storage-provider.json", record)
|
||||
installed = read_json(self.home / "installed.json", {})
|
||||
installed.update(composeSha256=before["docker-compose.yml"], storageProvider="seaweedfs")
|
||||
write_json(self.home / "installed.json", installed)
|
||||
write_json(migration / "updater-adoption.json", {"adoptedAt": record["adoptedAt"], "provider": "seaweedfs"})
|
||||
write_json(self.home / "status.json", {"status": "recovered", "phase": "storage_adopted",
|
||||
"updatedAt": now(), "recoveryRequired": False, "maintenance": False, "mutationStarted": False})
|
||||
return {"status": "adopted", "storageProvider": "seaweedfs", "version": state["version"]}
|
||||
|
||||
def adapt_storage_candidate(self):
|
||||
self.storage = self.storage_record()
|
||||
self.storage_service = "objectstore" if self.storage else "minio"
|
||||
self.storage_paths = ["storage"] if self.storage else ["minio"]
|
||||
if not self.storage:
|
||||
if "objectstore" in self.current["services"]:
|
||||
raise UpdateError("SeaweedFS zuerst mit adopt-storage aus der abgeschlossenen Pilotmigration übernehmen.")
|
||||
return
|
||||
self.validate_storage(self.current, self.storage)
|
||||
services = self.candidate["services"]
|
||||
if set(services) != CORE_SERVICES | {"minio", "createbuckets"}:
|
||||
raise UpdateError("Release verwendet einen unbekannten Speicheraufbau; Updater-Unterstützung erforderlich.")
|
||||
if services["db"]["image"] != self.storage["databaseSourceImage"]:
|
||||
raise UpdateError("Ein Wechsel der PostgreSQL-Version benötigt eine separate Datenbankmigration.")
|
||||
services["db"]["image"] = self.storage["databaseImage"]
|
||||
del services["minio"], services["createbuckets"]
|
||||
services["objectstore"] = copy.deepcopy(self.storage["service"])
|
||||
dependencies = services["backend"].setdefault("depends_on", {})
|
||||
dependencies.pop("minio", None)
|
||||
dependencies.pop("createbuckets", None)
|
||||
dependencies["objectstore"] = {"condition": "service_healthy", "required": True}
|
||||
services["backend"].setdefault("environment", {}).update(
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION="WHEN_REQUIRED", FEDEO_RUN_MIGRATIONS="false", FEDEO_STORAGE_PROVIDER="seaweedfs")
|
||||
write_json(self.job / "target.compose.yml", compose_strings(self.candidate, escape=True))
|
||||
self.candidate = self.config(True)
|
||||
self.validate_storage(self.candidate, self.storage)
|
||||
|
||||
def prepare(self, version, *, allow_current=False):
|
||||
for name in (".env", "docker-compose.yml"):
|
||||
path = self.root / name
|
||||
@@ -345,14 +603,51 @@ class Updater:
|
||||
raise UpdateError(f"Release-Compose enthält kein eindeutiges {name}-Image.")
|
||||
atomic(self.job / "target.compose.yml", compose)
|
||||
atomic(self.job / "target.env", set_version((self.root / ".env").read_bytes(), self.target))
|
||||
diff = "".join(difflib.unified_diff(original.decode().splitlines(True), compose.splitlines(True),
|
||||
self.candidate = self.config(True)
|
||||
self.adapt_storage_candidate()
|
||||
diff = "".join(difflib.unified_diff(original.decode().splitlines(True),
|
||||
(self.job / "target.compose.yml").read_text().splitlines(True),
|
||||
fromfile="installed/docker-compose.yml", tofile="release/docker-compose.yml"))
|
||||
atomic(self.job / "compose.diff", diff)
|
||||
self.candidate = self.config(True)
|
||||
self.validate_configs()
|
||||
ids = self.compose(False, "ps", "--all", "--quiet").split()
|
||||
# Compose ps can omit services removed from the current file. Retained
|
||||
# Matrix/MinIO containers still belong to the project and its backup plan.
|
||||
ids = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter",
|
||||
"label=com.docker.compose.project=" + self.project]).split()
|
||||
self.containers = [self.inspect(container) for container in ids]
|
||||
write_json(self.job / "original-containers.json", self.containers)
|
||||
retained = {}
|
||||
seen = set()
|
||||
for container in self.containers:
|
||||
labels = container.get("Config", {}).get("Labels", {})
|
||||
name = labels.get("com.docker.compose.service")
|
||||
if labels.get("com.docker.compose.oneoff", "False").lower() == "true":
|
||||
if container.get("State", {}).get("Running"):
|
||||
raise UpdateError("Ein Einmalcontainer läuft noch; dessen Arbeit zuerst abschließen.")
|
||||
continue
|
||||
if not name or name in seen:
|
||||
raise UpdateError("Unbekannte oder doppelte Projektcontainer vorhanden.")
|
||||
seen.add(name)
|
||||
if name not in self.current["services"]:
|
||||
if self.storage and name in ("minio", "createbuckets"):
|
||||
continue # MinIO's exact identity and retirement are checked by validate_storage.
|
||||
if name not in LEGACY_MATRIX_SERVICES:
|
||||
raise UpdateError("Unbekannter Projektcontainer: " + name)
|
||||
state, host = container.get("State", {}), container.get("HostConfig", {})
|
||||
if (any(state.get(k) for k in ("Running", "Restarting", "Paused"))
|
||||
or host.get("RestartPolicy", {}).get("Name") != "no" or host.get("AutoRemove")):
|
||||
raise UpdateError("Alter Matrix-Container ist nicht dauerhaft stillgelegt: " + name)
|
||||
volumes = []
|
||||
for mount in container.get("Mounts", []):
|
||||
if mount["Type"] == "bind":
|
||||
volumes.append({"type": "bind", "source": mount["Source"], "target": mount["Destination"]})
|
||||
elif mount["Type"] != "volume" or (name, mount["Destination"]) not in {
|
||||
("matrix-redis", "/data"), ("matrix-turn", "/var/lib/coturn")}:
|
||||
raise UpdateError("Abweichender Datenmount eines alten Matrix-Containers: " + name)
|
||||
retained[name] = {"volumes": volumes}
|
||||
self.current["services"].update(retained)
|
||||
self.retired_services = sorted(set(self.retired_services) | set(retained))
|
||||
self.validate_legacy_matrix()
|
||||
self.retired_containers = [c for c in self.containers
|
||||
if c["Config"]["Labels"].get("com.docker.compose.service") in self.retired_services]
|
||||
if any(c.get("HostConfig", {}).get("AutoRemove") for c in self.retired_containers):
|
||||
@@ -369,16 +664,22 @@ class Updater:
|
||||
raise UpdateError("Ein Einmalcontainer läuft noch; dessen Arbeit zuerst abschließen.")
|
||||
self.running = [c["Config"]["Labels"]["com.docker.compose.service"] for c in self.containers
|
||||
if c["State"]["Running"] and c["Config"]["Labels"].get("com.docker.compose.oneoff", "False").lower() != "true"]
|
||||
if not {"backend", "frontend", "db", "minio"}.issubset(self.running):
|
||||
raise UpdateError("Backend, Frontend, Datenbank und MinIO müssen vor dem Update laufen.")
|
||||
if not {"backend", "frontend", "db", self.storage_service}.issubset(self.running):
|
||||
raise UpdateError("Backend, Frontend, Datenbank und Dateispeicher müssen vor dem Update laufen.")
|
||||
if not set(self.running).issubset(self.current["services"]):
|
||||
raise UpdateError("Nicht konfigurierte Container dieses Compose-Projekts laufen noch.")
|
||||
self.before = {str(p): sha(p.read_bytes()) for p in
|
||||
[self.root / ".env", self.root / "docker-compose.yml", *([self.override] if self.override else [])]}
|
||||
if self.storage:
|
||||
for path in [self.home / "storage-provider.json", *[self.root / "storage/config" / n for n in ("s3.json", "start-seaweedfs.sh")]]:
|
||||
self.before[str(path)] = file_sha(path)
|
||||
database_size = int(self.compose(False, "exec", "-T", "db", "sh", "-ec",
|
||||
'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT pg_database_size(current_database())"').strip())
|
||||
minio_size = int(self.runner.run(["du", "-sb", str(self.root / "minio")]).split()[0])
|
||||
self.backup_size = database_size * 2 + minio_size + GIB
|
||||
storage_size = sum(int(self.runner.run(["du", "-sb", str(self.root / path)]).split()[0]) for path in self.storage_paths)
|
||||
self.backup_size = database_size * 2 + storage_size + GIB
|
||||
if self.storage:
|
||||
image = json.loads(self.runner.run(["docker", "image", "inspect", self.storage["image"]]))[0]
|
||||
self.backup_size += image["Size"] * 2
|
||||
if self.matrix_backup_paths:
|
||||
self.backup_size += int(self.runner.run(["du", "-sb", str(self.root / "matrix")]).split()[0])
|
||||
self.check_space(self.backup_size, self.home)
|
||||
@@ -387,6 +688,8 @@ class Updater:
|
||||
"releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed,
|
||||
"estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]),
|
||||
"retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths,
|
||||
"storageProvider": "seaweedfs" if self.storage else "minio",
|
||||
"storageImageRetained": bool(self.storage), "storageBackupPaths": self.storage_paths,
|
||||
"updateAvailable": update_available,
|
||||
# Kept in private job records; the service never exposes configuration fingerprints.
|
||||
"approvalFingerprint": sha(json.dumps({"manifest": self.manifest, "configuration": self.before,
|
||||
@@ -396,7 +699,7 @@ class Updater:
|
||||
|
||||
def validate_configs(self):
|
||||
old, new = self.current["services"], self.candidate["services"]
|
||||
required = {"backend", "frontend", "db", "minio", "createbuckets", "traefik", "node-exporter"}
|
||||
required = CORE_SERVICES | ({"objectstore"} if self.storage else {"minio", "createbuckets"})
|
||||
differences = []
|
||||
for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)):
|
||||
missing, additional = required - set(services), set(services) - required
|
||||
@@ -410,7 +713,7 @@ class Updater:
|
||||
if details:
|
||||
differences.append(label + " (" + "; ".join(details) + ")")
|
||||
if differences:
|
||||
raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO. "
|
||||
raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalem Dateispeicher. "
|
||||
+ ". ".join(differences) + ". Zusätzliche Dienste benötigen Unterstützung für Erhalt, Backup und Wiederherstellung.")
|
||||
self.retired_services = sorted(set(old) & LEGACY_MATRIX_SERVICES)
|
||||
self.matrix_backup_paths = []
|
||||
@@ -418,13 +721,23 @@ class Updater:
|
||||
for name in required:
|
||||
if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"):
|
||||
raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.")
|
||||
if old[name].get("volumes", []) != new[name].get("volumes", []):
|
||||
old_mounts, new_mounts = old[name].get("volumes", []), new[name].get("volumes", [])
|
||||
# One-time bootstrap of the constrained updater API; never relax data-mount checks.
|
||||
control_mount = {"type": "bind", "source": "/run/fedeo-updater", "target": "/run/fedeo-updater",
|
||||
"read_only": True, "bind": {}}
|
||||
control_variants = (control_mount, {**control_mount, "bind": {"create_host_path": True}})
|
||||
if name == "backend" and not any(v.get("target") == "/run/fedeo-updater" for v in old_mounts):
|
||||
new_mounts = [v for v in new_mounts if v not in control_variants]
|
||||
if old_mounts != new_mounts:
|
||||
raise UpdateError(f"Datenpfade/Mounts von {name} würden verändert; zuerst manuell migrieren.")
|
||||
if old[name].get("container_name") != new[name].get("container_name"):
|
||||
raise UpdateError(f"Container-Identität von {name} würde verändert.")
|
||||
if self.current.get("networks") != self.candidate.get("networks"):
|
||||
raise UpdateError("Docker-Netzwerke würden verändert.")
|
||||
for name, target, directory in (("db", "/var/lib/postgresql/data", "postgres"), ("minio", "/data", "minio")):
|
||||
data_services = [("db", "/var/lib/postgresql/data", "postgres")]
|
||||
if not self.storage:
|
||||
data_services.append(("minio", "/data", "minio"))
|
||||
for name, target, directory in data_services:
|
||||
volumes = new[name].get("volumes", [])
|
||||
if len(volumes) != 1 or volumes[0].get("type") != "bind" or volumes[0].get("target") != target or volumes[0].get("source") != str(self.root / directory):
|
||||
raise UpdateError(f"{name} benötigt das lokale Standard-Datenverzeichnis.")
|
||||
@@ -441,7 +754,7 @@ class Updater:
|
||||
healthcheck = new[name].get("healthcheck", {})
|
||||
if new[name]["image"] != self.images[name] or healthcheck.get("disable") or not healthcheck.get("test") or healthcheck["test"] == ["NONE"]:
|
||||
raise UpdateError(f"{name}: Release-Digest oder Healthcheck wurde überschrieben.")
|
||||
for name in ("minio", "createbuckets"):
|
||||
for name in (() if self.storage else ("minio", "createbuckets")):
|
||||
if not re.search(r"@sha256:[a-f0-9]{64}$", new[name]["image"]):
|
||||
raise UpdateError("MinIO und mc müssen auf feste Digests gepinnt sein.")
|
||||
env = new["backend"].get("environment", {})
|
||||
@@ -515,13 +828,21 @@ class Updater:
|
||||
atomic(directory / "config" / name, (self.root / name).read_bytes())
|
||||
if self.override:
|
||||
atomic(directory / "config" / self.override.name, self.override.read_bytes())
|
||||
if self.storage:
|
||||
self.validate_storage(self.current, self.storage, running=False)
|
||||
cid = self.compose(False, "ps", "--all", "--quiet", "objectstore").strip()
|
||||
store_state = self.inspect(cid).get("State", {}) if cid else {"Running": True}
|
||||
if any(store_state.get(k) for k in ("Running", "Restarting", "Paused")):
|
||||
raise UpdateError("SeaweedFS muss vor der kalten Sicherung gestoppt sein.")
|
||||
atomic(directory / "config/.updates/storage-provider.json", (self.home / "storage-provider.json").read_bytes())
|
||||
self.runner.run(["docker", "image", "save", "-o", str(directory / "storage-image.tar"), self.storage["image"]], timeout=3600)
|
||||
write_json(directory / "containers.json", self.containers)
|
||||
self.compose(False, "exec", "-T", "db", "sh", "-ec",
|
||||
'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom', output=directory / "database.dump")
|
||||
self.compose(False, "exec", "-T", "db", "pg_restore", "--list", input_file=directory / "database.dump")
|
||||
self.compose(False, "exec", "-T", "db", "sh", "-ec",
|
||||
'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=directory / "globals.sql")
|
||||
paths = ["minio", *self.matrix_backup_paths]
|
||||
paths = [*self.storage_paths, *self.matrix_backup_paths]
|
||||
if (self.root / "traefik/letsencrypt").is_dir():
|
||||
paths.append("traefik/letsencrypt")
|
||||
self.runner.run(["tar", "-cpf", str(directory / "files.tar"), "-C", str(self.root), *paths], timeout=7200)
|
||||
@@ -536,7 +857,13 @@ class Updater:
|
||||
os.fsync(stream.fileno())
|
||||
checksums[str(path.relative_to(directory))] = digest.hexdigest()
|
||||
self.verify_matrix_stopped()
|
||||
if self.storage:
|
||||
cid = self.compose(False, "ps", "--all", "--quiet", "objectstore").strip()
|
||||
if not cid or any(self.inspect(cid).get("State", {}).get(k) for k in ("Running", "Restarting", "Paused")):
|
||||
raise UpdateError("SeaweedFS wurde während der kalten Sicherung gestartet.")
|
||||
write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar",
|
||||
"storageProvider": "seaweedfs" if self.storage else "minio", "storageDataPaths": self.storage_paths,
|
||||
"storageImage": self.storage["image"] if self.storage else None,
|
||||
"retiredServices": self.retired_services, "retainedContainerIds": self.state["retiredContainerIds"],
|
||||
"matrixDataPaths": self.matrix_backup_paths, "sha256": checksums})
|
||||
self.save("backed_up", backup=str(directory))
|
||||
@@ -559,10 +886,22 @@ class Updater:
|
||||
if not self.managed and not accept_compose:
|
||||
raise UpdateError("Compose-Datei ist noch nicht verwaltet oder wurde bearbeitet. compose.diff prüfen, Anpassungen in die Override-Datei übertragen und --accept-compose-replacement verwenden.")
|
||||
self.save("pulling")
|
||||
self.compose(True, "pull", timeout=3600)
|
||||
# Exact digests are immutable: reuse local copies even when their registry is offline.
|
||||
# Mutable infrastructure tags must still be refreshed before entering maintenance.
|
||||
for policy in ("missing", "always"):
|
||||
services = [name for name, service in self.candidate["services"].items()
|
||||
if not IMAGE_ID.fullmatch(service["image"])
|
||||
and bool(re.search(r"@sha256:[a-f0-9]{64}$", service["image"])) == (policy == "missing")]
|
||||
if services:
|
||||
self.compose(True, "pull", "--policy", policy, *services, timeout=3600)
|
||||
for service in self.candidate["services"].values():
|
||||
if IMAGE_ID.fullmatch(service["image"]):
|
||||
self.runner.run(["docker", "image", "inspect", service["image"]])
|
||||
self.check_space(self.backup_size, self.home)
|
||||
if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()):
|
||||
raise UpdateError("Konfiguration wurde während der Vorbereitung verändert; erneut prüfen.")
|
||||
if self.storage:
|
||||
self.validate_storage(self.current, self.storage)
|
||||
self.save("maintenance", recoveryRequired=True, maintenance=True, runningServices=self.running)
|
||||
# Stop every writer and public entry point. The database stays up for pg_dump.
|
||||
self.compose(False, "stop", "--timeout", "60", *[s for s in self.running if s != "db"])
|
||||
@@ -583,15 +922,25 @@ class Updater:
|
||||
atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes())
|
||||
atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").read_bytes())
|
||||
os.chmod(self.root / "scripts/selfhost-setup.sh", 0o700)
|
||||
self.compose(True, "up", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter")
|
||||
self.compose(True, "run", "--rm", "--no-deps", "createbuckets")
|
||||
self.compose(True, "up", "--pull", "never", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", self.storage_service, "node-exporter")
|
||||
if self.storage:
|
||||
self.save("checking_storage", storageCheckContainer="fedeo-storage-check-" + self.job.name,
|
||||
storageProbeKey=".fedeo-update-check/" + uuid.uuid4().hex + ".pdf")
|
||||
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["storageCheckContainer"],
|
||||
"-e", "FEDEO_UPDATE_PROBE_KEY=" + self.state["storageProbeKey"],
|
||||
"--entrypoint", "node", "backend", "-e", S3_UPGRADE_CHECK, timeout=210)
|
||||
self.save(storageProbeKey=None)
|
||||
else:
|
||||
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "createbuckets")
|
||||
self.save("migrating", migrationContainer="fedeo-update-" + self.job.name)
|
||||
self.compose(True, "run", "--rm", "--no-deps", "--name", self.state["migrationContainer"],
|
||||
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["migrationContainer"],
|
||||
"-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate")
|
||||
self.save("starting")
|
||||
self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900)
|
||||
self.compose(True, "up", "--pull", "never", "-d", "--wait", "--wait-timeout", "600", timeout=900)
|
||||
self.save("verifying")
|
||||
self.verify_matrix_stopped(retired=True)
|
||||
if self.storage:
|
||||
self.validate_storage(self.candidate, self.storage)
|
||||
for name, image in self.images.items():
|
||||
container_id = self.compose(True, "ps", "--quiet", name).strip()
|
||||
container = self.inspect(container_id)
|
||||
@@ -605,6 +954,8 @@ class Updater:
|
||||
def execute(self, command, version=None, accept_compose=False, *, expected_fingerprint=None, request_id=None):
|
||||
with self.lock():
|
||||
previous = read_json(self.home / "status.json", {})
|
||||
if previous.get("phase") == "storage_migration":
|
||||
raise UpdateError("Speichermigration prüfen und nach vollständigem Abschluss adopt-storage ausführen; kein resolve verwenden.")
|
||||
if previous.get("recoveryRequired"):
|
||||
raise UpdateError("Ein vorheriges Update benötigt Wiederherstellung. status und docs/selfhost-updates.md prüfen; danach resolve ausführen.")
|
||||
self.job = self.home / (datetime.datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:8])
|
||||
@@ -644,6 +995,8 @@ class Updater:
|
||||
raise UpdateError("Die manuell wiederhergestellte Version mit --version angeben.")
|
||||
with self.lock():
|
||||
state = read_json(self.home / "status.json", {})
|
||||
if state.get("phase") == "storage_migration":
|
||||
raise UpdateError("Speichermigration kann nicht mit resolve freigegeben werden; Status prüfen und nach Abschluss adopt-storage verwenden.")
|
||||
if not state.get("recoveryRequired"):
|
||||
raise UpdateError("Kein offener Wiederherstellungsfall.")
|
||||
self.job = Path(state["job"])
|
||||
@@ -651,11 +1004,16 @@ class Updater:
|
||||
self.runner = self.runner_factory(self.job / "commands.log")
|
||||
self.override = next((self.root / name for name in ("docker-compose.override.yml", "docker-compose.override.yaml") if (self.root / name).exists()), None)
|
||||
current = self.config(False)
|
||||
self.storage = self.storage_record()
|
||||
self.storage_service = "objectstore" if self.storage else "minio"
|
||||
if self.storage:
|
||||
self.validate_storage(current, self.storage)
|
||||
self.public = current["services"]["frontend"]["environment"]["NUXT_PUBLIC_API_BASE"].removesuffix("/backend")
|
||||
if state.get("migrationContainer"):
|
||||
remaining = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", "name=^/" + state["migrationContainer"] + "$"]).strip()
|
||||
for key in ("migrationContainer", "storageCheckContainer"):
|
||||
if state.get(key):
|
||||
remaining = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", "name=^/" + state[key] + "$"]).strip()
|
||||
if remaining:
|
||||
raise UpdateError("Den verbliebenen Migrationscontainer zuerst prüfen und manuell entfernen.")
|
||||
raise UpdateError("Den verbliebenen Prüf-/Migrationscontainer zuerst prüfen und manuell entfernen.")
|
||||
# Older releases have /version and /health, but no frontend health endpoint.
|
||||
version_info = self.http.json(self.public + "/backend/version")
|
||||
if version_info.get("product") != "FEDEO" or version_info.get("version") != version:
|
||||
@@ -663,10 +1021,16 @@ class Updater:
|
||||
if self.http.json(self.public + "/backend/health").get("status") != "ok":
|
||||
raise UpdateError("Das wiederhergestellte Backend ist nicht erreichbar.")
|
||||
self.http.get(self.public + "/")
|
||||
if state.get("storageProbeKey"):
|
||||
if not self.storage or not re.fullmatch(r"\.fedeo-update-check/[a-f0-9]{32}\.pdf", state["storageProbeKey"]):
|
||||
raise UpdateError("Ungültiger protokollierter S3-Prüfschlüssel; manuell prüfen.")
|
||||
self.compose(False, "exec", "-T", "-e", "FEDEO_UPDATE_PROBE_KEY=" + state["storageProbeKey"],
|
||||
"backend", "node", "-e", S3_CLEANUP_CHECK, timeout=90)
|
||||
self.save(storageProbeKey=None)
|
||||
original_images = {c["Config"]["Labels"]["com.docker.compose.service"]: c["Image"]
|
||||
for c in read_json(self.job / "original-containers.json", [])}
|
||||
manifest = read_json(self.job / "release-manifest.json")
|
||||
for name in ("backend", "frontend", "db", "minio"):
|
||||
for name in ("backend", "frontend", "db", self.storage_service):
|
||||
container_id = self.compose(False, "ps", "--quiet", name).strip()
|
||||
container = self.inspect(container_id) if container_id else {}
|
||||
container_state = container.get("State", {})
|
||||
@@ -689,9 +1053,7 @@ class Updater:
|
||||
for original in read_json(self.job / "original-containers.json", []):
|
||||
if original.get("Id") not in state.get("retiredContainerIds", []):
|
||||
continue
|
||||
name = original["Config"]["Labels"]["com.docker.compose.service"]
|
||||
container_id = self.compose(False, "ps", "--all", "--quiet", name).strip()
|
||||
restored = self.inspect(container_id) if container_id else {}
|
||||
restored = self.inspect(original["Id"])
|
||||
if (restored.get("Image") != original["Image"]
|
||||
or restored.get("State", {}).get("Running") != original["State"]["Running"]
|
||||
or (original["State"]["Running"] and restored.get("State", {}).get("Health", {}).get("Status") not in (None, "healthy"))
|
||||
@@ -711,7 +1073,7 @@ def main(argv=None):
|
||||
print("Python ab Version 3.10 wird benötigt.", file=sys.stderr)
|
||||
return 1
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=("check", "apply", "status", "resolve"))
|
||||
parser.add_argument("command", choices=("check", "apply", "status", "resolve", "adopt-storage"))
|
||||
parser.add_argument("--directory", default=os.environ.get("FEDEO_DIR", "/opt/fedeo"))
|
||||
parser.add_argument("--version", help="Zielversion, z. B. 3.1.0; check wählt sonst den neuesten stabilen Release")
|
||||
parser.add_argument("--accept-compose-replacement", action="store_true", help="Zuvor geprüften Austausch einer nicht verwalteten Compose-Datei akzeptieren")
|
||||
@@ -726,6 +1088,8 @@ def main(argv=None):
|
||||
result = updater.status()
|
||||
elif args.command == "resolve":
|
||||
result = updater.resolve(args.version)
|
||||
elif args.command == "adopt-storage":
|
||||
result = updater.adopt_storage()
|
||||
else:
|
||||
result = updater.execute(args.command, args.version, args.accept_compose_replacement)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
2
scripts/storage/.dockerignore
Normal file
2
scripts/storage/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
tests
|
||||
6
scripts/storage/Dockerfile
Normal file
6
scripts/storage/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32
|
||||
WORKDIR /opt/fedeo-storage
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
|
||||
COPY s3.mjs cli.mjs ./
|
||||
ENTRYPOINT ["node", "/opt/fedeo-storage/cli.mjs"]
|
||||
18
scripts/storage/Dockerfile.seaweedfs
Normal file
18
scripts/storage/Dockerfile.seaweedfs
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM golang:1.26-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c AS builder
|
||||
RUN apk add --no-cache patch
|
||||
WORKDIR /src
|
||||
ADD --checksum=sha256:8b3823d4ca2df8250d7e6f35774228cc4ff01ffe2f8c10656df074a8db2f48a5 https://codeload.github.com/seaweedfs/seaweedfs/tar.gz/c50733600 /tmp/source.tar.gz
|
||||
RUN tar -xzf /tmp/source.tar.gz --strip-components=1 && rm /tmp/source.tar.gz
|
||||
COPY preserve-s3-content-type.patch /tmp/preserve-s3-content-type.patch
|
||||
RUN patch -p1 < /tmp/preserve-s3-content-type.patch
|
||||
ENV CGO_ENABLED=0
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
go test -p 4 ./weed/filer -run '^TestFilerStoreWrapperMimeNormalization$' -count=1 -v \
|
||||
&& go build -p 4 -trimpath -ldflags '-s -w -X github.com/seaweedfs/seaweedfs/weed/util/version.COMMIT=c5073360007d28385a33426a42ac3e4ec504c5a3-fedeo.1' -o /out/weed ./weed
|
||||
|
||||
FROM chrislusf/seaweedfs:4.47@sha256:ce9e796f1fe6f06968f4c04bdaf8f678dad9c8acdfef3d244133d71bfa6bf882
|
||||
COPY --from=builder /out/weed /usr/bin/weed
|
||||
COPY --from=builder /src/LICENSE /usr/share/doc/fedeo-seaweedfs/LICENSE
|
||||
COPY preserve-s3-content-type.patch /usr/share/doc/fedeo-seaweedfs/preserve-s3-content-type.patch
|
||||
LABEL io.fedeo.storage.patch="preserve-s3-content-type-v1" \
|
||||
org.opencontainers.image.revision="c5073360007d28385a33426a42ac3e4ec504c5a3-fedeo.1"
|
||||
64
scripts/storage/README.md
Normal file
64
scripts/storage/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Speicherwerkzeug für den betreuten Pilotbetrieb
|
||||
|
||||
AWS SDK `3.962.0` entspricht dem bei Entwicklung vorliegenden Backend-Lockfile.
|
||||
Der konkrete Ablauf auf einer Instanz steht in
|
||||
[`docs/minio-umstellung-pilot.md`](../../docs/minio-umstellung-pilot.md).
|
||||
|
||||
Lokaler Test (Linux amd64, Docker, Node 22):
|
||||
|
||||
```sh
|
||||
npm ci --prefix scripts/storage
|
||||
npm test --prefix scripts/storage
|
||||
docker pull chrislusf/seaweedfs:4.47@sha256:ce9e796f1fe6f06968f4c04bdaf8f678dad9c8acdfef3d244133d71bfa6bf882
|
||||
docker build -f scripts/storage/Dockerfile.seaweedfs -t fedeo-seaweedfs:4.47-fedeo.1 scripts/storage
|
||||
docker build -f scripts/storage/tests/Dockerfile.minio -t fedeo-minio-fixture:2025-09-07 scripts/storage/tests
|
||||
docker build -t fedeo-storage-tools:pilot scripts/storage
|
||||
node scripts/storage/tests/integration.mjs
|
||||
python3 -B scripts/storage/tests/host_integration.py
|
||||
docker pull registry:2
|
||||
python3 -B scripts/update/tests/host_storage_integration.py
|
||||
```
|
||||
|
||||
Der S3-Test verwendet ausschließlich neu angelegte Testcontainer mit künstlichen
|
||||
Daten. Er prüft über 1.000 Objekte, Pagination, eine 1-GiB-Datei, leere Dateien,
|
||||
Sonderzeichen, Metadaten, SigV4, 24-Stunden-Links, Streaming, verbotene Zugriffe,
|
||||
beschädigte Zielobjekte, erneute Kopie und echten Restore in ein frisches
|
||||
Verzeichnis. Die gesicherten Testdaten bleiben unter `/tmp/fedeo-storage-*`
|
||||
zur Prüfung erhalten; die Testcontainer werden entfernt.
|
||||
|
||||
Der Host-Test verwendet einen echten PostgreSQL-/MinIO-/SeaweedFS-Stack und
|
||||
künstliche Anwendungscontainer. Er ersetzt keine fachliche Abnahme von FEDEO
|
||||
(PDF, WebDAV, Exporte, Mandantenrechte) auf der konkreten Instanz.
|
||||
|
||||
Der zusätzliche Updater-Test führt Migration, geprüfte Übernahme, zwei Updates
|
||||
und Restore aus der regulären Updatesicherung aus. Er startet eine eigene lokale
|
||||
Testregistry. Release-HTTP und öffentliche Versionsantworten sind Testdaten;
|
||||
Docker Compose, Image-Registry, PostgreSQL, S3-Verträge und Restore sind echt.
|
||||
Das neue Pilotpaket enthält auch die zusammengehörigen Host-Updater-Dateien.
|
||||
Nach erfolgreicher Migration übernimmt `pilot.py adopt-updater` den Speicher;
|
||||
bereits umgestellte Instanzen werden dafür nicht erneut migriert.
|
||||
|
||||
Die erste Übernahme ist auf 200.000 Objekte und 1 GiB pro Objekt begrenzt.
|
||||
Das größte Objekt wird auf Platte gespult; RAM-Verbrauch wächst nicht mit dessen
|
||||
Größe. Multipart-Übernahmen und Sonderkonfigurationen werden abgelehnt.
|
||||
|
||||
SeaweedFS 4.47 bewahrt bei SDK-Streaming mit automatischen Prüfsummen teilweise
|
||||
`Content-Encoding: aws-chunked` auf. Der Kopierer setzt einen vorher berechneten
|
||||
SHA-256-Checksum-Header. Die Pilot-Anwendung erhält
|
||||
`AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED`; der Vertragstest prüft diese
|
||||
Einstellung mit leeren und nicht leeren Streams. SigV4 bleibt aktiv.
|
||||
|
||||
Das Pilotimage `4.47-fedeo.1` korrigiert SeaweedFS' Normalisierung expliziter
|
||||
S3-MIME-Typen. `application/octet-stream` muss auch für `.pdf` erhalten bleiben.
|
||||
Der Dockerfile pinnt Herstellerquelle, Buildimage und Laufzeitbasis; der beigefügte
|
||||
Patch umfasst die Korrektur und Go-Tests für alle drei Schreibpfade. Der S3-Test
|
||||
prüft HEAD/GET, Kopie und Restore einschließlich dieses Falls. Das ungeänderte
|
||||
Herstellerimage wird vom neuen Vertragstest gezielt abgewiesen. Bei Folgeupdates
|
||||
die Korrektur beibehalten oder deren Übernahme durch den Hersteller nachweisen.
|
||||
|
||||
SeaweedFS-Herkunft: [Release 4.47](https://github.com/seaweedfs/seaweedfs/releases/tag/4.47),
|
||||
[betroffene Normalisierung](https://github.com/seaweedfs/seaweedfs/blob/c5073360007d28385a33426a42ac3e4ec504c5a3/weed/filer/filerstore_wrapper.go).
|
||||
Das isolierte MinIO-Testimage verwendet das offizielle, per SHA-256 verifizierte
|
||||
[Release-Binary](https://github.com/minio/minio/releases/tag/RELEASE.2025-09-07T16-13-09Z),
|
||||
da die früheren Containerreferenzen nicht mehr abrufbar sind. Es wird nicht im
|
||||
Pilotpaket mitgeliefert und nicht über Kundendaten gestartet.
|
||||
44
scripts/storage/build-pilot.sh
Normal file
44
scripts/storage/build-pilot.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
storage_repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
storage_output="${1:-$storage_repo/.release/storage-pilot}"
|
||||
test ! -e "$storage_output"
|
||||
mkdir -p "$storage_output/scripts/storage" "$storage_output/docs"
|
||||
storage_weed="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["image"])' "$storage_repo/scripts/storage/seaweedfs.json")"
|
||||
if ! docker image inspect "$storage_weed" >/dev/null 2>&1; then
|
||||
docker build -f "$storage_repo/scripts/storage/Dockerfile.seaweedfs" -t "$storage_weed" "$storage_repo/scripts/storage"
|
||||
fi
|
||||
docker build -t fedeo-storage-tools:pilot "$storage_repo/scripts/storage"
|
||||
storage_tools_id="$(docker image inspect fedeo-storage-tools:pilot --format '{{.Id}}')"
|
||||
storage_weed_id="$(docker image inspect "$storage_weed" --format '{{.Id}}')"
|
||||
storage_tools="fedeo-storage-tools:pilot-${storage_tools_id#sha256:}"
|
||||
storage_weed_local="fedeo-seaweedfs:pilot-${storage_weed_id#sha256:}"
|
||||
docker image tag "$storage_tools_id" "$storage_tools"
|
||||
docker image tag "$storage_weed_id" "$storage_weed_local"
|
||||
cp "$storage_repo/scripts/"{selfhost-storage-migrate.py,selfhost-update.py,selfhost-update-service.py,selfhost-update-service-install.sh,fedeo-updater.service} "$storage_output/scripts/"
|
||||
cp "$storage_repo/scripts/storage/"{s3.mjs,cli.mjs,seaweedfs.json,start-seaweedfs.sh,package.json,package-lock.json,Dockerfile.seaweedfs,preserve-s3-content-type.patch} "$storage_output/scripts/storage/"
|
||||
cp "$storage_repo/scripts/storage/pilot.py" "$storage_output/pilot.py"
|
||||
cp "$storage_repo/docs/"{minio-umstellung-pilot.md,selfhost-updates.md} "$storage_output/docs/"
|
||||
docker image save -o "$storage_output/images.tar" "$storage_tools" "$storage_weed_local"
|
||||
python3 - "$storage_output" "$storage_tools" "$storage_weed_local" "$storage_weed" <<'PY'
|
||||
import hashlib,json,pathlib,sys
|
||||
root=pathlib.Path(sys.argv[1]).resolve()
|
||||
spec=root/'scripts/storage/seaweedfs.json'
|
||||
config=json.loads(spec.read_text()); config['image']=sys.argv[3]
|
||||
spec.write_text(json.dumps(config,indent=2)+'\n')
|
||||
checksums={}
|
||||
for p in sorted(root.rglob('*')):
|
||||
if p.is_file():
|
||||
h=hashlib.sha256()
|
||||
with p.open('rb') as f:
|
||||
for chunk in iter(lambda:f.read(1024*1024), b''): h.update(chunk)
|
||||
checksums[str(p.relative_to(root))]=h.hexdigest()
|
||||
upstream=next(line.split()[1] for line in (root/'scripts/storage/Dockerfile.seaweedfs').read_text().splitlines()
|
||||
if line.startswith('FROM chrislusf/seaweedfs:'))
|
||||
(root/'bundle.json').write_text(json.dumps({'schemaVersion':1,'toolsImage':sys.argv[2],
|
||||
'seaweedImage':sys.argv[3],'upstreamSeaweedImage':upstream,'seaweedBuild':'4.47-fedeo.1',
|
||||
'sha256':checksums},indent=2)+'\n')
|
||||
PY
|
||||
tar -czf "$storage_output.tar.gz" -C "$(dirname "$storage_output")" "$(basename "$storage_output")"
|
||||
(cd "$(dirname "$storage_output")" && sha256sum "$(basename "$storage_output").tar.gz" > "$(basename "$storage_output").tar.gz.sha256")
|
||||
printf 'Pilotpaket: %s.tar.gz\n' "$storage_output"
|
||||
39
scripts/storage/cli.mjs
Normal file
39
scripts/storage/cli.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { client, call, inventory, transfer, contract, verifyManifest, atomicJson, cleanupProbe } from './s3.mjs';
|
||||
|
||||
// Secrets enter through a root-only mounted JSON file, never through argv or logs.
|
||||
const [command, configPath = '/config/job.json'] = process.argv.slice(2);
|
||||
if (command === 'contract') process.env.AWS_REQUEST_CHECKSUM_CALCULATION = 'WHEN_REQUIRED';
|
||||
let source, target;
|
||||
try {
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8'));
|
||||
source = config.source && client(config.source);
|
||||
target = config.target && client(config.target);
|
||||
const bucket = config.bucket;
|
||||
let result;
|
||||
if (command === 'inventory') result = await inventory(source, bucket);
|
||||
else if (command === 'copy' || command === 'verify') {
|
||||
result = await transfer(source, target, bucket, config.directory || '/work', { verifyOnly: command === 'verify' });
|
||||
result = { ...result, objects: undefined };
|
||||
} else if (command === 'init') {
|
||||
try { await call(target, 'HeadBucket', { Bucket: bucket }); }
|
||||
catch (error) {
|
||||
if (error.$metadata?.httpStatusCode !== 404) throw error;
|
||||
await call(target, 'CreateBucket', { Bucket: bucket });
|
||||
}
|
||||
result = { bucket, initialized: true };
|
||||
} else if (command === 'contract') result = await contract(target, config.target, bucket, { ...config, probePath: '/work/probe.json' });
|
||||
else if (command === 'cleanup-probe') { await cleanupProbe(target, bucket, '/work/probe.json'); result = { cleaned: true }; }
|
||||
else if (command === 'restore-verify') {
|
||||
result = await verifyManifest(target, bucket, JSON.parse(await readFile(config.manifest || '/work/verified.json', 'utf8')));
|
||||
} else throw new Error('Unbekannter Befehl.');
|
||||
await atomicJson(config.result || '/work/result.json', result);
|
||||
console.log(JSON.stringify(command === 'inventory' ? { count: result.count, bytes: result.bytes } : result));
|
||||
} catch (error) {
|
||||
// SDK error messages/URLs may contain identifiers; keep public output bounded.
|
||||
const detail = { error: error.name, code: error.code, status: error.$metadata?.httpStatusCode,
|
||||
message: error.$metadata ? 'S3-Prüfung fehlgeschlagen; Zugriff/Konfiguration prüfen.' : error.message };
|
||||
try { await atomicJson('/work/error.json', detail); } catch { /* Preserve the original error. */ }
|
||||
console.error(JSON.stringify(detail));
|
||||
process.exitCode = 1;
|
||||
} finally { source?.destroy(); target?.destroy(); }
|
||||
1740
scripts/storage/package-lock.json
generated
Normal file
1740
scripts/storage/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
scripts/storage/package.json
Normal file
12
scripts/storage/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@fedeo/storage-migration",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=22" },
|
||||
"scripts": { "test": "node --test tests/*.test.mjs" },
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.962.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.962.0"
|
||||
}
|
||||
}
|
||||
56
scripts/storage/pilot.py
Normal file
56
scripts/storage/pilot.py
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry point shipped inside a verified, self-contained storage pilot bundle."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def checksum(path):
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for block in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["load-images", "plan", "migrate", "activate", "resume-start", "rollback", "status", "adopt-updater"])
|
||||
parser.add_argument("--directory", default="/opt/fedeo")
|
||||
parser.add_argument("--writers-exclusive", action="store_true")
|
||||
args = parser.parse_args()
|
||||
manifest = json.loads((ROOT / "bundle.json").read_text())
|
||||
for name, expected in manifest["sha256"].items():
|
||||
# The large image archive is checked when loading, not on every status call.
|
||||
if name == "images.tar" and args.command != "load-images":
|
||||
continue
|
||||
path = ROOT / name
|
||||
if path.resolve().is_relative_to(ROOT) and checksum(path) == expected:
|
||||
continue
|
||||
raise SystemExit("Paket-Prüfsumme stimmt nicht: " + name)
|
||||
if args.command == "load-images":
|
||||
subprocess.run(["docker", "image", "load", "-i", str(ROOT / "images.tar")], check=True)
|
||||
for image in (manifest["toolsImage"], manifest["seaweedImage"]):
|
||||
subprocess.run(["docker", "image", "inspect", image], check=True, stdout=subprocess.DEVNULL)
|
||||
print("Offline-Images geprüft und geladen. Nächster Schritt: plan.")
|
||||
return
|
||||
if args.command == "adopt-updater":
|
||||
# Install the matching service before releasing the legacy updater guard.
|
||||
subprocess.run(["bash", str(ROOT / "scripts/selfhost-update-service-install.sh"), "--directory", args.directory], check=True)
|
||||
raise SystemExit(subprocess.run([sys.executable, str(ROOT / "scripts/selfhost-update.py"), "adopt-storage",
|
||||
"--directory", args.directory]).returncode)
|
||||
command = [sys.executable, str(ROOT / "scripts/selfhost-storage-migrate.py"), args.command, "--directory", args.directory]
|
||||
if args.command == "plan":
|
||||
command += ["--tools-image", manifest["toolsImage"]]
|
||||
if args.writers_exclusive:
|
||||
command += ["--writers-exclusive"]
|
||||
raise SystemExit(subprocess.run(command).returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
70
scripts/storage/preserve-s3-content-type.patch
Normal file
70
scripts/storage/preserve-s3-content-type.patch
Normal file
@@ -0,0 +1,70 @@
|
||||
--- a/weed/filer/filerstore_wrapper.go
|
||||
+++ b/weed/filer/filerstore_wrapper.go
|
||||
@@ -213,7 +213,8 @@
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
- if !entry.IsDirectory() && entry.Mime == "application/octet-stream" {
|
||||
+ // An explicit S3 Content-Type is object metadata, not a filesystem default.
|
||||
+ if !entry.IsDirectory() && !entry.isS3Entry() && entry.Mime == "application/octet-stream" {
|
||||
entry.Mime = ""
|
||||
}
|
||||
}
|
||||
--- a/weed/filer/filerstore_wrapper_test.go
|
||||
+++ b/weed/filer/filerstore_wrapper_test.go
|
||||
@@ -8,6 +8,7 @@
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -17,6 +18,7 @@
|
||||
name string
|
||||
mode os.FileMode
|
||||
wantMime string
|
||||
+ extended map[string][]byte
|
||||
}{
|
||||
{
|
||||
name: "files strip octet-stream",
|
||||
@@ -24,6 +26,18 @@
|
||||
wantMime: "",
|
||||
},
|
||||
{
|
||||
+ name: "S3 owner keeps explicit octet-stream",
|
||||
+ mode: 0o660,
|
||||
+ wantMime: "application/octet-stream",
|
||||
+ extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte("test-owner")},
|
||||
+ },
|
||||
+ {
|
||||
+ name: "S3 ETag keeps explicit octet-stream",
|
||||
+ mode: 0o660,
|
||||
+ wantMime: "application/octet-stream",
|
||||
+ extended: map[string][]byte{s3_constants.ExtETagKey: []byte("test-etag")},
|
||||
+ },
|
||||
+ {
|
||||
name: "directories keep octet-stream",
|
||||
mode: os.ModeDir | 0o770,
|
||||
wantMime: "application/octet-stream",
|
||||
@@ -38,6 +52,12 @@
|
||||
name: "insert",
|
||||
run: func(fsw *FilerStoreWrapper, ctx context.Context, entry *Entry) error {
|
||||
return fsw.InsertEntry(ctx, entry)
|
||||
+ },
|
||||
+ },
|
||||
+ {
|
||||
+ name: "insert known absent",
|
||||
+ run: func(fsw *FilerStoreWrapper, ctx context.Context, entry *Entry) error {
|
||||
+ return fsw.InsertEntryKnownAbsent(ctx, entry)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -55,6 +75,7 @@
|
||||
wrapper := NewFilerStoreWrapper(store)
|
||||
entry := &Entry{
|
||||
FullPath: util.FullPath("/buckets/test/object"),
|
||||
+ Extended: tt.extended,
|
||||
Attr: Attr{
|
||||
Mode: tt.mode,
|
||||
Mime: "application/octet-stream",
|
||||
291
scripts/storage/s3.mjs
Normal file
291
scripts/storage/s3.mjs
Normal file
@@ -0,0 +1,291 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
||||
import { Transform } from 'node:stream';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A pilot also runs this contract inside the exact, already installed backend
|
||||
// image. Its own SDK dependency tree must pass before the host can cut over.
|
||||
const requireSdk = createRequire(process.env.FEDEO_STORAGE_SDK_ROOT
|
||||
? join(process.env.FEDEO_STORAGE_SDK_ROOT, 'package.json') : import.meta.url);
|
||||
const S3 = requireSdk('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = requireSdk('@aws-sdk/s3-request-presigner');
|
||||
|
||||
// The pilot deliberately has a tested bound; no untested multipart conversion.
|
||||
export const MAX_OBJECT_BYTES = 1024 * 1024 * 1024;
|
||||
export const MAX_OBJECTS = 200_000;
|
||||
export const client = config => new S3.S3Client({ ...config, forcePathStyle: true, maxAttempts: 3 });
|
||||
export const call = (s3, operation, input) => s3.send(new S3[`${operation}Command`](input));
|
||||
export const canonical = value => JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v);
|
||||
export const digest = value => createHash('sha256').update(canonical(value)).digest('hex');
|
||||
const requireThat = (condition, reason) => { if (!condition) throw new Error(reason); };
|
||||
|
||||
export async function atomicJson(path, value) {
|
||||
const temp = `${path}.tmp`;
|
||||
const file = await open(temp, 'w', 0o600);
|
||||
try { await file.writeFile(JSON.stringify(value, null, 2) + '\n'); await file.sync(); }
|
||||
finally { await file.close(); }
|
||||
await rename(temp, path);
|
||||
const parent = await open(join(path, '..'), 'r');
|
||||
try { await parent.sync(); } finally { await parent.close(); }
|
||||
}
|
||||
|
||||
export function validateKeys(objects) {
|
||||
requireThat(objects.length <= MAX_OBJECTS, `Pilotgrenze: höchstens ${MAX_OBJECTS} Objekte.`);
|
||||
const keys = new Set(objects.map(o => o.Key));
|
||||
requireThat(keys.size === objects.length, 'Doppelte Objektschlüssel.');
|
||||
for (const { Key: key, Size: size } of objects) {
|
||||
requireThat(typeof key === 'string' && key.length > 0 && Buffer.byteLength(key) <= 1024,
|
||||
'Ungültiger Objektschlüssel.');
|
||||
const segments = key.split('/');
|
||||
requireThat(!/[\x00-\x1f\x7f\\]/.test(key) && segments.every(s => s && s !== '.' && s !== '..'),
|
||||
`Nicht verlustfrei übertragbarer Schlüssel: ${JSON.stringify(key)}`);
|
||||
requireThat(Number.isSafeInteger(size) && size >= 0 && size <= MAX_OBJECT_BYTES,
|
||||
`Objekt außerhalb der getesteten Größe (${MAX_OBJECT_BYTES / 1024 / 1024} MiB): ${JSON.stringify(key)}`);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
requireThat(!keys.has(segments.slice(0, i).join('/')), `Datei-/Verzeichniskonflikt: ${JSON.stringify(key)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(s3, Bucket) {
|
||||
const result = [];
|
||||
let ContinuationToken;
|
||||
do {
|
||||
const page = await call(s3, 'ListObjectsV2', { Bucket, ContinuationToken });
|
||||
result.push(...(page.Contents || []).map(o => ({ Key: o.Key, Size: o.Size, ETag: o.ETag,
|
||||
LastModified: o.LastModified?.toISOString() })));
|
||||
requireThat(result.length <= MAX_OBJECTS, `Pilotgrenze: höchstens ${MAX_OBJECTS} Objekte.`);
|
||||
requireThat(!page.IsTruncated || (page.NextContinuationToken && page.NextContinuationToken !== ContinuationToken),
|
||||
'Ungültige S3-Paginierung.');
|
||||
ContinuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
|
||||
} while (ContinuationToken);
|
||||
return result.sort((a, b) => Buffer.compare(Buffer.from(a.Key), Buffer.from(b.Key)));
|
||||
}
|
||||
|
||||
async function optional(s3, operation, Bucket, absentCodes) {
|
||||
try { return await call(s3, operation, { Bucket }); }
|
||||
catch (error) { if (absentCodes.includes(error.name)) return {}; throw error; }
|
||||
}
|
||||
|
||||
function privateAcl(acl) {
|
||||
return (acl.Grants || []).every(g => g.Grantee?.Type === 'CanonicalUser' &&
|
||||
(g.Grantee.ID || '') === (acl.Owner?.ID || '') && g.Permission === 'FULL_CONTROL');
|
||||
}
|
||||
|
||||
export async function inspectBucket(s3, Bucket) {
|
||||
const buckets = await call(s3, 'ListBuckets', {});
|
||||
requireThat(buckets.Buckets?.length === 1 && buckets.Buckets[0].Name === Bucket,
|
||||
'Die Quelle muss genau den FEDEO-Bucket enthalten; weitere Buckets separat klären.');
|
||||
const v = await call(s3, 'GetBucketVersioning', { Bucket });
|
||||
requireThat(!v.Status, 'Aktivierte oder ausgesetzte Versionierung wird nicht unterstützt.');
|
||||
const versions = await call(s3, 'ListObjectVersions', { Bucket, MaxKeys: 1000 });
|
||||
// Even for an unversioned bucket MinIO returns current versions with VersionId=null.
|
||||
requireThat(!(versions.DeleteMarkers?.length) && (versions.Versions || []).every(x =>
|
||||
x.IsLatest && x.VersionId === 'null'), 'Alte Versionen oder Delete-Marker vorhanden.');
|
||||
// A versioning flag above is mandatory; still inspect all version pages to detect old history.
|
||||
let page = versions;
|
||||
while (page.IsTruncated) {
|
||||
const next = await call(s3, 'ListObjectVersions', { Bucket, KeyMarker: page.NextKeyMarker,
|
||||
VersionIdMarker: page.NextVersionIdMarker });
|
||||
requireThat(!(next.DeleteMarkers?.length) && (next.Versions || []).every(x => x.IsLatest && x.VersionId === 'null'),
|
||||
'Alte Versionen oder Delete-Marker vorhanden.');
|
||||
requireThat(next.NextKeyMarker !== page.NextKeyMarker || !next.IsTruncated, 'Ungültige Versionspaginierung.');
|
||||
page = next;
|
||||
}
|
||||
const lock = await optional(s3, 'GetObjectLockConfiguration', Bucket, ['ObjectLockConfigurationNotFoundError', 'ObjectLockConfigurationNotFound']);
|
||||
requireThat(!lock.ObjectLockConfiguration?.ObjectLockEnabled, 'Object Lock wird nicht unterstützt.');
|
||||
const encryption = await optional(s3, 'GetBucketEncryption', Bucket, ['ServerSideEncryptionConfigurationNotFoundError']);
|
||||
requireThat(!encryption.ServerSideEncryptionConfiguration?.Rules?.length, 'Bucket-Verschlüsselung benötigt einen eigenen Adapter.');
|
||||
const policy = await optional(s3, 'GetBucketPolicy', Bucket, ['NoSuchBucketPolicy']);
|
||||
requireThat(!policy.Policy, 'Eigene Bucket-Policy benötigt eine gesonderte Übernahme.');
|
||||
const lifecycle = await optional(s3, 'GetBucketLifecycleConfiguration', Bucket, ['NoSuchLifecycleConfiguration']);
|
||||
requireThat(!lifecycle.Rules?.length, 'Lifecycle-Regeln vorhanden.');
|
||||
const replication = await optional(s3, 'GetBucketReplication', Bucket, ['ReplicationConfigurationNotFoundError']);
|
||||
requireThat(!replication.ReplicationConfiguration, 'Bucket-Replikation vorhanden.');
|
||||
const notification = await call(s3, 'GetBucketNotificationConfiguration', { Bucket });
|
||||
requireThat(!['TopicConfigurations', 'QueueConfigurations', 'LambdaFunctionConfigurations'].some(k => notification[k]?.length)
|
||||
&& !notification.EventBridgeConfiguration, 'Bucket-Benachrichtigungen vorhanden.');
|
||||
const uploads = await call(s3, 'ListMultipartUploads', { Bucket, MaxUploads: 1 });
|
||||
requireThat(!uploads.Uploads?.length, 'Unvollständige Multipart-Uploads vorhanden.');
|
||||
const tags = await optional(s3, 'GetBucketTagging', Bucket, ['NoSuchTagSet', 'NoSuchTagSetError']);
|
||||
requireThat(!tags.TagSet?.length, 'Bucket-Tags vorhanden.');
|
||||
const cors = await optional(s3, 'GetBucketCors', Bucket, ['NoSuchCORSConfiguration']);
|
||||
requireThat(!cors.CORSRules?.length, 'Eigene CORS-Konfiguration vorhanden.');
|
||||
requireThat(privateAcl(await call(s3, 'GetBucketAcl', { Bucket })), 'Bucket-ACL ist nicht privat.');
|
||||
}
|
||||
|
||||
export function metadata(head) {
|
||||
requireThat(!head.ServerSideEncryption && !head.SSECustomerAlgorithm && !head.ObjectLockMode &&
|
||||
!head.ObjectLockLegalHoldStatus && !head.WebsiteRedirectLocation && (!head.StorageClass || head.StorageClass === 'STANDARD'),
|
||||
'Nicht unterstützte Objektattribute gefunden.');
|
||||
const result = {};
|
||||
for (const key of ['ContentType', 'ContentDisposition', 'ContentEncoding', 'ContentLanguage', 'CacheControl', 'Metadata']) {
|
||||
if (head[key] !== undefined) result[key] = head[key];
|
||||
}
|
||||
result.Metadata ||= {};
|
||||
if (head.Expires) result.Expires = head.Expires.toISOString();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function inventory(s3, Bucket) {
|
||||
await inspectBucket(s3, Bucket);
|
||||
const objects = await list(s3, Bucket);
|
||||
validateKeys(objects);
|
||||
for (const object of objects) {
|
||||
const input = { Bucket, Key: object.Key };
|
||||
const head = await call(s3, 'HeadObject', input);
|
||||
requireThat(head.ETag === object.ETag && head.ContentLength === object.Size, 'Quelle während Inventarisierung verändert.');
|
||||
object.metadata = metadata(head);
|
||||
requireThat(!(await call(s3, 'GetObjectTagging', input)).TagSet?.length, `Objekt-Tags vorhanden: ${JSON.stringify(object.Key)}`);
|
||||
requireThat(privateAcl(await call(s3, 'GetObjectAcl', input)), 'Objekt-ACL ist nicht privat.');
|
||||
}
|
||||
return { schemaVersion: 1, bucket: Bucket, count: objects.length,
|
||||
bytes: objects.reduce((n, o) => n + o.Size, 0), objects };
|
||||
}
|
||||
|
||||
export async function readHash(s3, Bucket, object, spool) {
|
||||
const response = await call(s3, 'GetObject', { Bucket, Key: object.Key, ...(object.ETag ? { IfMatch: object.ETag } : {}) });
|
||||
const hash = createHash('sha256');
|
||||
let bytes = 0;
|
||||
const inspect = new Transform({ transform(chunk, _, next) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_OBJECT_BYTES) return next(new Error('Objekt überschreitet Größenlimit.'));
|
||||
hash.update(chunk); next(null, chunk);
|
||||
}});
|
||||
if (spool) await pipeline(response.Body, inspect, createWriteStream(spool, { mode: 0o600 }));
|
||||
else for await (const chunk of response.Body) { bytes += chunk.length; hash.update(chunk);
|
||||
requireThat(bytes <= MAX_OBJECT_BYTES, 'Objekt überschreitet Größenlimit.'); }
|
||||
requireThat(bytes === object.Size, 'Objektgröße während Lesen verändert.');
|
||||
return { sha256: hash.digest('hex'), size: bytes, metadata: metadata(response) };
|
||||
}
|
||||
|
||||
export async function transfer(source, target, Bucket, directory, { verifyOnly = false } = {}) {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const current = await inventory(source, Bucket);
|
||||
const manifestPath = join(directory, 'inventory.json');
|
||||
let previous;
|
||||
try { previous = JSON.parse(await readFile(manifestPath, 'utf8')); }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
requireThat(!previous || canonical(previous) === canonical(current), 'Quellinventar seit Beginn verändert.');
|
||||
const targetObjects = await list(target, Bucket);
|
||||
requireThat(previous || targetObjects.length === 0, 'Ziel ist bei erster Kopie nicht leer.');
|
||||
const sourceKeys = new Set(current.objects.map(o => o.Key));
|
||||
requireThat(targetObjects.every(o => sourceKeys.has(o.Key)), 'Unerklärte zusätzliche Zielobjekte vorhanden.');
|
||||
if (!previous) await atomicJson(manifestPath, current);
|
||||
const existing = new Map(targetObjects.map(o => [o.Key, o]));
|
||||
const journal = await open(join(directory, 'objects.jsonl'), 'a', 0o600);
|
||||
const hashes = [];
|
||||
try {
|
||||
for (const object of current.objects) {
|
||||
const spool = join(directory, 'object.spool');
|
||||
try {
|
||||
const original = await readHash(source, Bucket, object, verifyOnly ? undefined : spool);
|
||||
requireThat(canonical(original.metadata) === canonical(object.metadata), 'Quellmetadaten verändert.');
|
||||
let copied;
|
||||
if (existing.has(object.Key) && existing.get(object.Key).Size === object.Size) {
|
||||
copied = await readHash(target, Bucket, { Key: object.Key, Size: object.Size });
|
||||
}
|
||||
if (canonical(copied) !== canonical(original)) {
|
||||
requireThat(!verifyOnly, `Integritätsfehler: ${JSON.stringify(object.Key)}`);
|
||||
const meta = { ...object.metadata };
|
||||
if (meta.Expires) meta.Expires = new Date(meta.Expires);
|
||||
await call(target, 'PutObject', { Bucket, Key: object.Key, ...meta,
|
||||
ContentLength: object.Size, ChecksumSHA256: Buffer.from(original.sha256, 'hex').toString('base64'),
|
||||
Body: object.Size === 0 ? Buffer.alloc(0) : createReadStream(spool) });
|
||||
copied = await readHash(target, Bucket, { Key: object.Key, Size: object.Size });
|
||||
requireThat(canonical(copied) === canonical(original), `Integritätsfehler nach Kopie: ${JSON.stringify(object.Key)}`);
|
||||
}
|
||||
hashes.push({ key: object.Key, ...original });
|
||||
await journal.writeFile(JSON.stringify({ key: object.Key, ...original, verifiedAt: new Date().toISOString() }) + '\n');
|
||||
await journal.sync();
|
||||
process.stdout.write(JSON.stringify({ phase: verifyOnly ? 'verifying' : 'copying', completed: hashes.length, total: current.count }) + '\n');
|
||||
} finally { await rm(spool, { force: true }); }
|
||||
}
|
||||
} finally { await journal.close(); }
|
||||
const after = await inventory(source, Bucket);
|
||||
requireThat(canonical(after) === canonical(current), 'Quelle während Kopie/Prüfung verändert.');
|
||||
const result = await list(target, Bucket);
|
||||
requireThat(canonical(result.map(({ Key, Size }) => ({ Key, Size }))) ===
|
||||
canonical(current.objects.map(({ Key, Size }) => ({ Key, Size }))), 'Schlüsselmenge oder Größen weichen ab.');
|
||||
const report = { schemaVersion: 1, verifiedAt: new Date().toISOString(), count: current.count, bytes: current.bytes,
|
||||
inventorySha256: digest(current), objectsSha256: digest(hashes), objects: hashes };
|
||||
await atomicJson(join(directory, 'verified.json'), report);
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function verifyManifest(target, Bucket, report) {
|
||||
const objects = await list(target, Bucket);
|
||||
requireThat(canonical(objects.map(o => ({ key: o.Key, size: o.Size }))) ===
|
||||
canonical(report.objects.map(o => ({ key: o.key, size: o.size }))), 'Restore: Schlüsselmenge/Größen abweichend.');
|
||||
for (const object of report.objects) {
|
||||
requireThat(canonical(await readHash(target, Bucket, { Key: object.key, Size: object.size })) ===
|
||||
canonical({ sha256: object.sha256, size: object.size, metadata: object.metadata }), 'Restore: Hash/Metadaten abweichend.');
|
||||
}
|
||||
return { count: report.count, bytes: report.bytes, restored: true };
|
||||
}
|
||||
|
||||
export async function cleanupProbe(s3, Bucket, probePath) {
|
||||
let record;
|
||||
try { record = JSON.parse(await readFile(probePath, 'utf8')); }
|
||||
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
||||
requireThat(/^fedeo-contract-[0-9a-f-]{36}\//.test(record.key), 'Ungültiges Probejournal.');
|
||||
await call(s3, 'DeleteObject', { Bucket, Key: record.key });
|
||||
await call(s3, 'DeleteObject', { Bucket, Key: record.key + '.stream' });
|
||||
}
|
||||
|
||||
export async function contract(s3, config, Bucket, { foreignBucket, probePath } = {}) {
|
||||
if (probePath) await cleanupProbe(s3, Bucket, probePath);
|
||||
const Key = `fedeo-contract-${randomUUID()}/Mandant ä + % #/Prüfung.pdf`;
|
||||
if (probePath) await atomicJson(probePath, { key: Key });
|
||||
const body = Buffer.from('FEDEO S3 Prüfung\n');
|
||||
try {
|
||||
await call(s3, 'PutObject', { Bucket, Key, Body: body, ContentType: 'application/pdf',
|
||||
ContentDisposition: 'inline; filename="test.pdf"', CacheControl: 'private, max-age=0', Metadata: { test: 'fedeo' } });
|
||||
const head = await call(s3, 'HeadObject', { Bucket, Key });
|
||||
requireThat(head.ContentLength === body.length && head.Metadata.test === 'fedeo', 'HEAD-Vertrag fehlgeschlagen.');
|
||||
const got = await call(s3, 'GetObject', { Bucket, Key });
|
||||
requireThat(Buffer.from(await got.Body.transformToByteArray()).equals(body), 'GET-Vertrag fehlgeschlagen.');
|
||||
// Generic MIME types are explicit S3 metadata even for a .pdf key. The
|
||||
// upstream filer used to drop octet-stream and infer PDF on later reads.
|
||||
await call(s3, 'PutObject', { Bucket, Key, Body: body, ContentType: 'application/octet-stream', Metadata: { test: 'fedeo' } });
|
||||
const genericHead = await call(s3, 'HeadObject', { Bucket, Key });
|
||||
const genericGet = await call(s3, 'GetObject', { Bucket, Key });
|
||||
const genericBody = Buffer.from(await genericGet.Body.transformToByteArray());
|
||||
requireThat(genericHead.ContentType === 'application/octet-stream' && genericGet.ContentType === 'application/octet-stream'
|
||||
&& genericHead.Metadata.test === 'fedeo' && genericBody.equals(body), 'Expliziter S3-MIME-Typ wurde verändert.');
|
||||
// Application exports use streams. Exercise exactly the SDK environment setting
|
||||
// applied by the pilot Compose file, including empty streams.
|
||||
for (const bytes of [Buffer.alloc(0), Buffer.alloc(128 * 1024, 42)]) {
|
||||
await call(s3, 'PutObject', { Bucket, Key: Key + '.stream', ContentLength: bytes.length,
|
||||
Body: Readable.from([bytes]), ContentType: 'application/zip' });
|
||||
const streamHead = await call(s3, 'HeadObject', { Bucket, Key: Key + '.stream' });
|
||||
requireThat(!streamHead.ContentEncoding && streamHead.ContentLength === bytes.length, 'Streaming-Metadaten abweichend.');
|
||||
const downloaded = await call(s3, 'GetObject', { Bucket, Key: Key + '.stream' });
|
||||
requireThat(Buffer.from(await downloaded.Body.transformToByteArray()).equals(bytes), 'Streaming-Inhalt abweichend.');
|
||||
}
|
||||
const url = await getSignedUrl(s3, new S3.GetObjectCommand({ Bucket, Key }), { expiresIn: 86400 });
|
||||
const signed = await fetch(url);
|
||||
requireThat(signed.ok && Buffer.from(await signed.arrayBuffer()).equals(body), 'Presigned-URL fehlgeschlagen.');
|
||||
const anonymous = await fetch(url.split('?')[0]);
|
||||
await anonymous.arrayBuffer();
|
||||
requireThat([401, 403].includes(anonymous.status), 'Anonymer Objektzugriff erlaubt!');
|
||||
const wrong = client({ ...config, credentials: { accessKeyId: 'fedeo-invalid', secretAccessKey: 'fedeo-invalid-secret' } });
|
||||
try { await denied(wrong, 'GetObject', { Bucket, Key }); } finally { wrong.destroy(); }
|
||||
if (foreignBucket) await denied(s3, 'ListObjectsV2', { Bucket: foreignBucket });
|
||||
await call(s3, 'DeleteObject', { Bucket, Key });
|
||||
try { await call(s3, 'HeadObject', { Bucket, Key }); throw new Error('DELETE nicht wirksam.'); }
|
||||
catch (error) { if (error.$metadata?.httpStatusCode !== 404) throw error; }
|
||||
} finally { await call(s3, 'DeleteObject', { Bucket, Key }); await call(s3, 'DeleteObject', { Bucket, Key: Key + '.stream' }); }
|
||||
return { operations: ['PUT', 'HEAD', 'GET', 'DELETE', 'SigV4', 'presigned-24h', 'anonymous-denied', 'wrong-key-denied'] };
|
||||
}
|
||||
|
||||
async function denied(s3, operation, input) {
|
||||
try { const value = await call(s3, operation, input); value.Body?.destroy(); }
|
||||
catch (error) { if ([401, 403].includes(error.$metadata?.httpStatusCode)) return; throw error; }
|
||||
throw new Error('Nicht autorisierter Zugriff wurde erlaubt.');
|
||||
}
|
||||
16
scripts/storage/seaweedfs.json
Normal file
16
scripts/storage/seaweedfs.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"image": "fedeo-seaweedfs:4.47-fedeo.1",
|
||||
"entrypoint": ["/bin/sh", "/etc/fedeo/start-seaweedfs.sh"],
|
||||
"command": [
|
||||
"server", "-dir=/data", "-master.dir=/data/master", "-ip=127.0.0.1", "-ip.bind=127.0.0.1",
|
||||
"-master.telemetry=false", "-master.volumeSizeLimitMB=1024", "-volume.max=0", "-volume.minFreeSpace=1GiB",
|
||||
"-filer", "-s3", "-s3.ip.bind=0.0.0.0", "-s3.port=9000", "-s3.config=/etc/fedeo/s3.json",
|
||||
"-s3.iam=false", "-s3.port.iceberg=0", "-s3.port.lance=0", "-s3.autoCreateBucket=false",
|
||||
"-s3.allowDeleteBucketNotEmpty=false"
|
||||
],
|
||||
"stop_grace_period": "60s",
|
||||
"healthcheck": {
|
||||
"test": ["CMD-SHELL", "wget -S -O /dev/null http://127.0.0.1:9000/ 2>&1 | grep -q ' 403 '"],
|
||||
"interval": "5s", "timeout": "3s", "retries": 30, "start_period": "10s"
|
||||
}
|
||||
}
|
||||
9
scripts/storage/start-seaweedfs.sh
Normal file
9
scripts/storage/start-seaweedfs.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Never start an anonymously writable gateway when its configuration is absent,
|
||||
# truncated, or replaced. The host generated and validated this exact file.
|
||||
test -n "${FEDEO_S3_CONFIG_SHA256:-}"
|
||||
test -s /etc/fedeo/s3.json
|
||||
actual_sha256="$(sha256sum /etc/fedeo/s3.json)"
|
||||
test "${actual_sha256%% *}" = "$FEDEO_S3_CONFIG_SHA256"
|
||||
exec /usr/bin/weed "$@"
|
||||
31
scripts/storage/sync-sdk-lock.mjs
Normal file
31
scripts/storage/sync-sdk-lock.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Preserve the backend's exact transitive SDK graph; top-level version equality alone is insufficient.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const backend = JSON.parse(readFileSync(new URL('../../backend/package-lock.json', import.meta.url)));
|
||||
const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)));
|
||||
const packages = { '': { name: manifest.name, version: manifest.version, dependencies: manifest.dependencies, engines: manifest.engines } };
|
||||
function resolve(from, dependency) {
|
||||
const parts = from.split('/').filter(Boolean);
|
||||
while (true) {
|
||||
const candidate = [...parts, 'node_modules', dependency].join('/');
|
||||
if (backend.packages[candidate]) return candidate;
|
||||
if (!parts.length) return null;
|
||||
parts.pop();
|
||||
}
|
||||
}
|
||||
const queue = Object.keys(manifest.dependencies).map(name => resolve('', name));
|
||||
while (queue.length) {
|
||||
const path = queue.pop();
|
||||
if (!path) throw new Error('Missing dependency in backend lockfile');
|
||||
if (packages[path]) continue;
|
||||
const entry = { ...backend.packages[path] };
|
||||
delete entry.dev;
|
||||
packages[path] = entry;
|
||||
for (const name of Object.keys(entry.dependencies || {})) queue.push(resolve(path, name));
|
||||
for (const name of Object.keys(entry.optionalDependencies || {})) {
|
||||
const resolved = resolve(path, name);
|
||||
if (resolved) queue.push(resolved);
|
||||
}
|
||||
}
|
||||
writeFileSync(new URL('./package-lock.json', import.meta.url), JSON.stringify({ name: manifest.name, version: manifest.version,
|
||||
lockfileVersion: 3, requires: true, packages: Object.fromEntries(Object.entries(packages).sort(([a], [b]) => a.localeCompare(b))) }, null, 2) + '\n');
|
||||
console.log(`Copied ${Object.keys(packages).length - 1} locked backend dependencies.`);
|
||||
6
scripts/storage/tests/Dockerfile.minio
Normal file
6
scripts/storage/tests/Dockerfile.minio
Normal file
@@ -0,0 +1,6 @@
|
||||
# Test fixture only. The pilot bundle uses the instance's already installed MinIO.
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32
|
||||
RUN wget -q -O /usr/local/bin/minio https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-amd64.RELEASE.2025-09-07T16-13-09Z \
|
||||
&& echo '7c5bd8512c6e966455b1d198209358b2d191c77a83ab377c4073281065fb855f /usr/local/bin/minio' | sha256sum -c - \
|
||||
&& chmod 755 /usr/local/bin/minio
|
||||
ENTRYPOINT ["minio"]
|
||||
175
scripts/storage/tests/host_integration.py
Normal file
175
scripts/storage/tests/host_integration.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real Docker host-state test with synthetic services, never a real FEDEO instance."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tarfile
|
||||
import uuid
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("storage_migration", ROOT / "selfhost-storage-migrate.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
def docker(*args):
|
||||
return subprocess.check_output(["docker", *map(str, args)], text=True, stderr=subprocess.PIPE).strip()
|
||||
|
||||
|
||||
def fixture():
|
||||
root = Path(tempfile.mkdtemp(prefix="fedeo-host-migration-"))
|
||||
project = "fedeo-pilot-" + uuid.uuid4().hex[:8]
|
||||
(root / "minio").mkdir()
|
||||
(root / "postgres").mkdir()
|
||||
(root / ".env").write_text("# synthetic test only\n")
|
||||
secret = "test-with-$-interpolation"
|
||||
env = {"S3_ENDPOINT": "http://minio:9000", "S3_REGION": "eu-central-1", "S3_BUCKET": "fedeo",
|
||||
"S3_ACCESS_KEY": "test-user", "S3_SECRET_KEY": secret, "FEDEO_VERSION": "3.3.0",
|
||||
"DATABASE_URL": "postgres://fedeo:test-db@db:5432/fedeo", "FEDEO_FILE_BACKEND": "s3"}
|
||||
idle = {"image": "node:22-alpine", "command": ["node", "-e", "setInterval(()=>{},1000)"],
|
||||
"restart": "unless-stopped", "networks": ["internal"],
|
||||
"healthcheck": {"test": ["CMD", "node", "-e", "process.exit(0)"], "interval": "1s"}}
|
||||
import copy
|
||||
services = {n: copy.deepcopy(idle) for n in ("backend", "frontend", "traefik", "node-exporter", "createbuckets")}
|
||||
services["createbuckets"].update(command=["node", "-e", "process.exit(0)"], restart="no")
|
||||
services["backend"]["environment"] = env
|
||||
services["backend"]["image"] = "fedeo-storage-tools:pilot"
|
||||
services["backend"]["entrypoint"] = ["node"]
|
||||
services["backend"]["command"] = ["-e", "setInterval(()=>{},1000)"]
|
||||
services["backend"]["depends_on"] = {"minio": {"condition": "service_healthy"}, "db": {"condition": "service_healthy"},
|
||||
"createbuckets": {"condition": "service_completed_successfully"}}
|
||||
services["minio"] = {"image": "fedeo-minio-fixture:2025-09-07", "restart": "unless-stopped",
|
||||
"command": ["server", "/data", "--console-address", ":9001"], "networks": ["internal"],
|
||||
"volumes": [str(root / "minio") + ":/data"],
|
||||
"environment": {"MINIO_ROOT_USER": "test-user", "MINIO_ROOT_PASSWORD": secret},
|
||||
"healthcheck": {"test": ["CMD", "node", "-e", "fetch('http://127.0.0.1:9000/minio/health/live').then(r=>process.exit(r.ok?0:1))"], "interval": "1s"}}
|
||||
services["db"] = {"image": "postgres:16-alpine", "restart": "unless-stopped", "networks": ["internal"],
|
||||
"volumes": [str(root / "postgres") + ":/var/lib/postgresql/data"],
|
||||
"environment": {"POSTGRES_USER": "fedeo", "POSTGRES_PASSWORD": "test-db", "POSTGRES_DB": "fedeo"},
|
||||
"healthcheck": {"test": ["CMD", "pg_isready", "-U", "fedeo"], "interval": "1s"}}
|
||||
config = {"name": project, "services": services, "networks": {"internal": {"name": project + "-internal"}}}
|
||||
(root / "docker-compose.yml").write_text(json.dumps(m.literal(config)))
|
||||
return root, project
|
||||
|
||||
|
||||
def seed(root, project, hidden=False):
|
||||
code = """import { client, call } from './s3.mjs';
|
||||
const s=client({endpoint:'http://minio:9000',region:'eu-central-1',credentials:{accessKeyId:'test-user',secretAccessKey:'test-with-$-interpolation'}});
|
||||
await call(s,'CreateBucket',{Bucket:'fedeo'});
|
||||
await call(s,'PutObject',{Bucket:'fedeo',Key:'tenant/filesbyid/1/ä + % #.pdf',Body:Buffer.from('fixture'),ContentType:'application/pdf'});"""
|
||||
if hidden:
|
||||
code += "for(const Key of ['hidden','hidden/child']) await call(s,'PutObject',{Bucket:'fedeo',Key,Body:'fixture'});"
|
||||
code += "s.destroy();"
|
||||
docker("run", "--rm", "--pull", "never", "--network", project + "-internal", "--entrypoint", "node", "fedeo-storage-tools:pilot", "--input-type=module", "-e", code)
|
||||
|
||||
|
||||
def run(case):
|
||||
root, project = fixture()
|
||||
base = ["compose", "-p", project, "-f", str(root / "docker-compose.yml")]
|
||||
try:
|
||||
docker(*base, "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "90")
|
||||
seed(root, project, hidden=case == "hidden-prefix")
|
||||
if case == "unhealthy-matrix":
|
||||
config = json.loads((root / "docker-compose.yml").read_text())
|
||||
config["services"]["matrix-element"] = {
|
||||
"image": "node:22-alpine", "command": ["node", "-e", "setInterval(()=>{},1000)"],
|
||||
"restart": "unless-stopped", "networks": ["internal"],
|
||||
"healthcheck": {"test": ["CMD", "node", "-e", "process.exit(1)"], "interval": "1s", "retries": 1}}
|
||||
(root / "docker-compose.yml").write_text(json.dumps(config))
|
||||
docker(*base, "up", "-d", "--pull", "never", "--no-deps", "matrix-element")
|
||||
cid = docker(*base, "ps", "--quiet", "matrix-element")
|
||||
for attempt in range(30):
|
||||
if json.loads(docker("inspect", cid))[0]["State"]["Health"]["Status"] == "unhealthy":
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise AssertionError("fixture must begin with an unhealthy Matrix container")
|
||||
retained = []
|
||||
if case.startswith("retired-"):
|
||||
(root / "matrix/postgres").mkdir(parents=True)
|
||||
(root / "matrix/postgres/retained.txt").write_text("retired matrix data")
|
||||
for service, mount in (("matrix-db", str(root / "matrix/postgres") + ":/var/lib/postgresql/data"),
|
||||
("matrix-redis", "/data")):
|
||||
retained.append(docker("create", "--pull", "never", "--restart=no", "--network", project + "-internal",
|
||||
"--label", "com.docker.compose.project=" + project, "--label", "com.docker.compose.service=" + service,
|
||||
"-v", mount, "node:22-alpine", "node", "-e", "process.exit(0)"))
|
||||
migration = m.Migration(root, "fedeo-storage-tools:pilot")
|
||||
with migration.engine.lock():
|
||||
if case == "hidden-prefix":
|
||||
try:
|
||||
migration.plan()
|
||||
raise AssertionError("hidden key conflict must block planning")
|
||||
except m.Error as error:
|
||||
assert "konflikt" in str(error)
|
||||
assert not (root / ".updates/status.json").exists()
|
||||
print(json.dumps({"case": case, "result": "passed", "evidence": str(root)}), flush=True)
|
||||
return
|
||||
migration.plan()
|
||||
assert migration.state["count"] == 1
|
||||
if retained:
|
||||
assert migration.state["retainedServices"] == ["matrix-db", "matrix-redis"]
|
||||
assert migration.state["matrixPaths"] == ["matrix"]
|
||||
assert not set(migration.state["retainedServices"]) & m.u.read_json(migration.home / "target.json")["services"].keys()
|
||||
migration.migrate(True)
|
||||
assert migration.state["phase"] == "verified"
|
||||
assert m.u.read_json(root / ".updates/status.json")["recoveryRequired"]
|
||||
# Simulate restart by reconstructing the process and persisted state.
|
||||
migration = m.Migration(root)
|
||||
if case.endswith("rollback"):
|
||||
migration.rollback()
|
||||
assert migration.state["phase"] == "rolled_back"
|
||||
assert not m.u.read_json(root / ".updates/status.json").get("recoveryRequired")
|
||||
assert migration.inspect(migration.state["containers"]["minio"]["id"])["State"]["Running"]
|
||||
else:
|
||||
migration.activate()
|
||||
assert migration.state["phase"] == "complete" and migration.state["writesReleased"]
|
||||
assert migration.state["restoreVerifiedAt"]
|
||||
if case == "unhealthy-matrix":
|
||||
assert migration.state["preexistingUnhealthyServices"] == ["matrix-element"]
|
||||
cid = docker(*base, "ps", "--quiet", "matrix-element")
|
||||
assert migration.inspect(cid)["State"]["Running"]
|
||||
old = migration.inspect(migration.state["containers"]["minio"]["id"])
|
||||
assert not old["State"]["Running"] and old["HostConfig"]["RestartPolicy"]["Name"] == "no"
|
||||
assert migration.state["network"] not in old["NetworkSettings"]["Networks"]
|
||||
try:
|
||||
migration.rollback()
|
||||
raise AssertionError("rollback after writes must fail")
|
||||
except m.Error:
|
||||
pass
|
||||
for cid in retained:
|
||||
old = migration.inspect(cid)
|
||||
assert not old["State"]["Running"] and old["HostConfig"]["RestartPolicy"]["Name"] == "no"
|
||||
if retained:
|
||||
with tarfile.open(migration.home / "backup/files.tar") as backup:
|
||||
assert backup.extractfile("matrix/postgres/retained.txt").read() == b"retired matrix data"
|
||||
print(json.dumps({"case": case, "result": "passed", "evidence": str(root)}), flush=True)
|
||||
except BaseException:
|
||||
print("Failure evidence: " + str(root), flush=True)
|
||||
log = root / ".updates/storage-migration/commands.log"
|
||||
if log.exists():
|
||||
print(log.read_text()[-8000:], flush=True)
|
||||
ids = docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project,
|
||||
"--filter", "label=com.docker.compose.service=objectstore").split()
|
||||
for cid in ids:
|
||||
logs = subprocess.run(["docker", "logs", "--tail", "60", cid], capture_output=True, text=True)
|
||||
print(logs.stdout + logs.stderr, flush=True)
|
||||
raise
|
||||
finally:
|
||||
ids = docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split()
|
||||
if ids:
|
||||
docker("rm", "-f", "-v", *ids)
|
||||
remaining = docker("ps", "-aq", "--filter", "label=io.fedeo.storage-job=" + str(root / ".updates/storage-migration")).split()
|
||||
if remaining:
|
||||
docker("rm", "-f", *remaining)
|
||||
docker("network", "rm", project + "-internal")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for case in (sys.argv[1:] or ["hidden-prefix", "rollback", "activate"]):
|
||||
assert case in ("hidden-prefix", "rollback", "activate", "retired-rollback", "retired-activate", "unhealthy-matrix")
|
||||
run(case)
|
||||
131
scripts/storage/tests/integration.mjs
Normal file
131
scripts/storage/tests/integration.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
// Explicit integration run; never discovers or connects to an existing FEDEO stack.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { client, call, contract, inventory, transfer, verifyManifest, MAX_OBJECT_BYTES } from '../s3.mjs';
|
||||
|
||||
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
const spec = JSON.parse(await readFile(new URL('../seaweedfs.json', import.meta.url), 'utf8'));
|
||||
if (process.env.FEDEO_SEAWEED_TEST_IMAGE) spec.image = process.env.FEDEO_SEAWEED_TEST_IMAGE;
|
||||
const root = await mkdtemp(join(tmpdir(), 'fedeo-storage-contract-'));
|
||||
const prefix = `fedeo-contract-${randomUUID().slice(0, 8)}`;
|
||||
const containers = [];
|
||||
const Bucket = 'fedeo';
|
||||
const credentials = { accessKeyId: 'fedeo-test-user', secretAccessKey: 'fedeo-test-secret-only' };
|
||||
const adminCredentials = { accessKeyId: 'fedeo-test-admin', secretAccessKey: 'fedeo-test-admin-secret' };
|
||||
process.env.AWS_REQUEST_CHECKSUM_CALCULATION = 'WHEN_REQUIRED';
|
||||
const config = endpoint => ({ endpoint, region: 'eu-central-1', credentials });
|
||||
let source, target, admin;
|
||||
function run(name, ...args) { containers.push(name); return docker('run', '-d', '--pull', 'never', '--name', name, ...args); }
|
||||
function endpoint(name) { return 'http://' + docker('port', name, '9000/tcp'); }
|
||||
async function ready(s3) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
try { await call(s3, 'ListBuckets', {}); return; }
|
||||
catch { await new Promise(resolve => setTimeout(resolve, 1000)); }
|
||||
}
|
||||
throw new Error('S3 did not become ready');
|
||||
}
|
||||
async function restoredContentsReady(s3, bucket, report) {
|
||||
// Like the host helper, wait for volume registration after a cold start.
|
||||
// ListBuckets can already succeed while reading object data still returns 500.
|
||||
const deadline = Date.now() + 180_000;
|
||||
for (;;) {
|
||||
try { return await verifyManifest(s3, bucket, report); }
|
||||
catch (error) {
|
||||
const transient = [500, 502, 503, 504].includes(error.$metadata?.httpStatusCode) ||
|
||||
['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH'].includes(error.code);
|
||||
if (!transient || Date.now() >= deadline) throw error;
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
async function weed(name, data) {
|
||||
await mkdir(join(data, 'master'), { recursive: true });
|
||||
run(name, '-p', '127.0.0.1::9000', '-v', `${data}:/data`, '-v', `${root}/config:/etc/fedeo:ro`,
|
||||
'-e', 'FEDEO_S3_CONFIG_SHA256=' + createHash('sha256').update(await readFile(join(root, 'config/s3.json'))).digest('hex'),
|
||||
'--entrypoint', spec.entrypoint[0], spec.image, ...spec.entrypoint.slice(1), ...spec.command);
|
||||
return endpoint(name);
|
||||
}
|
||||
try {
|
||||
await mkdir(join(root, 'config'));
|
||||
await writeFile(join(root, 'config/start-seaweedfs.sh'), await readFile(new URL('../start-seaweedfs.sh', import.meta.url)));
|
||||
assert.throws(() => docker('run', '--rm', '--pull', 'never', '-v', `${root}/config:/etc/fedeo:ro`,
|
||||
'--entrypoint', spec.entrypoint[0], spec.image, ...spec.entrypoint.slice(1), ...spec.command));
|
||||
await writeFile(join(root, 'config/s3.json'), JSON.stringify({ identities: [
|
||||
{ name: 'admin', credentials: [adminCredentials], actions: ['Admin', 'Read', 'Write', 'List', 'Tagging'] },
|
||||
{ name: 'fedeo', credentials: [credentials], actions: [`Read:${Bucket}`, `Write:${Bucket}`, `List:${Bucket}`] }
|
||||
] }));
|
||||
// Seaweed's credential JSON uses accessKey/secretKey rather than SDK names.
|
||||
const auth = JSON.parse(await readFile(join(root, 'config/s3.json'), 'utf8'));
|
||||
for (const identity of auth.identities) identity.credentials = identity.credentials.map(c => ({ accessKey: c.accessKeyId, secretKey: c.secretAccessKey }));
|
||||
await writeFile(join(root, 'config/s3.json'), JSON.stringify(auth));
|
||||
run(`${prefix}-source`, '-p', '127.0.0.1::9000', '-e', `MINIO_ROOT_USER=${credentials.accessKeyId}`,
|
||||
'-e', `MINIO_ROOT_PASSWORD=${credentials.secretAccessKey}`, process.env.FEDEO_MINIO_TEST_IMAGE || 'fedeo-minio-fixture:2025-09-07',
|
||||
'server', '/data', '--console-address', ':9001');
|
||||
const sourceConfig = config(endpoint(`${prefix}-source`));
|
||||
source = client(sourceConfig);
|
||||
await ready(source);
|
||||
await call(source, 'CreateBucket', { Bucket });
|
||||
await contract(source, sourceConfig, Bucket);
|
||||
const targetConfig = config(await weed(`${prefix}-target`, join(root, 'target')));
|
||||
target = client(targetConfig);
|
||||
admin = client({ ...targetConfig, credentials: adminCredentials });
|
||||
await ready(admin);
|
||||
await call(admin, 'CreateBucket', { Bucket });
|
||||
await call(admin, 'CreateBucket', { Bucket: 'foreign-private' });
|
||||
await contract(target, targetConfig, Bucket, { foreignBucket: 'foreign-private' });
|
||||
// Confirm unauthenticated management ports are not reachable through the container IP.
|
||||
const ip = JSON.parse(docker('inspect', `${prefix}-target`))[0].NetworkSettings.Networks.bridge.IPAddress;
|
||||
for (const port of [9333, 19333, 8888, 18888, 8080, 18080, 8181, 9101]) {
|
||||
assert.throws(() => docker('exec', `${prefix}-source`, 'node', '-e',
|
||||
`const s=require('node:net').connect(${port},${JSON.stringify(ip)});s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));s.setTimeout(1500,()=>process.exit(1))`));
|
||||
}
|
||||
for (let i = 0; i < 1003; i++) await call(source, 'PutObject', { Bucket, Key: `tenant/filesbyid/${i}/ä + % #.txt`,
|
||||
Body: Buffer.from(`fixture-${i}`), ContentType: i === 0 ? 'application/octet-stream' : 'text/plain', Metadata: { custom: 'preserved' } });
|
||||
await call(source, 'PutObject', { Bucket, Key: 'empty', Body: Buffer.alloc(0), ContentType: 'application/octet-stream' });
|
||||
await call(source, 'PutObject', { Bucket, Key: 'large.zip', ContentLength: MAX_OBJECT_BYTES,
|
||||
Body: Readable.from((function* () { for (let i = 0; i < MAX_OBJECT_BYTES / (1024 * 1024); i++) yield Buffer.alloc(1024 * 1024, i % 256); })()),
|
||||
ContentType: 'application/zip', ContentDisposition: 'attachment; filename="large.zip"',
|
||||
CacheControl: 'private', ContentLanguage: 'de', Expires: new Date('2030-01-01T00:00:00Z') });
|
||||
const before = await inventory(source, Bucket);
|
||||
assert.equal(before.count, 1005);
|
||||
assert.equal(before.objects.find(o => o.Key === 'large.zip').Size, MAX_OBJECT_BYTES);
|
||||
await call(source, 'PutObject', { Bucket, Key: 'unsupported-marker/', Body: '' });
|
||||
await assert.rejects(inventory(source, Bucket), /Schlüssel/);
|
||||
await call(source, 'DeleteObject', { Bucket, Key: 'unsupported-marker/' });
|
||||
const start = Date.now();
|
||||
const report = await transfer(source, target, Bucket, join(root, 'work'));
|
||||
await call(target, 'PutObject', { Bucket, Key: 'empty', Body: 'corrupt' });
|
||||
await assert.rejects(transfer(source, target, Bucket, join(root, 'work'), { verifyOnly: true }), /Integritätsfehler/);
|
||||
await transfer(source, target, Bucket, join(root, 'work')); // Resume repairs and re-reads, never trusts journal alone.
|
||||
await transfer(source, target, Bucket, join(root, 'work'), { verifyOnly: true });
|
||||
docker('stop', '-t', '60', `${prefix}-target`);
|
||||
docker('run', '--rm', '--pull', 'never', '-v', `${root}:/test`, '--entrypoint', '/bin/sh', spec.image,
|
||||
'-ec', 'tar -cpf /test/backup.tar -C /test/target .; mkdir /test/restored; tar -xpf /test/backup.tar -C /test/restored');
|
||||
const restoredConfig = config(await weed(`${prefix}-restored`, join(root, 'restored')));
|
||||
const restored = client(restoredConfig);
|
||||
const restoredAdmin = client({ ...restoredConfig, credentials: adminCredentials });
|
||||
try {
|
||||
await ready(restoredAdmin);
|
||||
await restoredContentsReady(restored, Bucket, report);
|
||||
await contract(restored, restoredConfig, Bucket, { foreignBucket: 'foreign-private' });
|
||||
} finally { restored.destroy(); restoredAdmin.destroy(); }
|
||||
console.log(JSON.stringify({ result: 'passed', objects: report.count, bytes: report.bytes,
|
||||
seconds: (Date.now() - start) / 1000, seaweed: spec.image, source: docker('image', 'inspect',
|
||||
process.env.FEDEO_MINIO_TEST_IMAGE || 'fedeo-minio-fixture:2025-09-07', '--format', '{{.Id}}'),
|
||||
evidence: root }, null, 2));
|
||||
} catch (error) {
|
||||
for (const name of containers) {
|
||||
const logs = spawnSync('docker', ['logs', '--tail', '30', name], { encoding: 'utf8' });
|
||||
console.error(logs.stdout + logs.stderr);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
source?.destroy(); target?.destroy(); admin?.destroy();
|
||||
for (const name of containers.reverse()) { try { docker('rm', '-f', name); } catch {} }
|
||||
// Preserve the isolated backup and report for inspection; no production paths are touched.
|
||||
}
|
||||
37
scripts/storage/tests/keys.test.mjs
Normal file
37
scripts/storage/tests/keys.test.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateKeys, MAX_OBJECT_BYTES, metadata } from '../s3.mjs';
|
||||
import { cleanupProbe, atomicJson } from '../s3.mjs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
test('accepts FEDEO keys, special characters, empty objects and size boundary', () => {
|
||||
validateKeys([{ Key: 'tenant/filesbyid/123/ä + %#.pdf', Size: 0 }, { Key: 'export.zip', Size: MAX_OBJECT_BYTES }]);
|
||||
});
|
||||
test('blocks lossy paths and file/directory collisions regardless of ordering', () => {
|
||||
for (const Key of ['/a', 'a/', 'a//b', 'a/../b', './a', 'a\\b', 'a\nb'])
|
||||
assert.throws(() => validateKeys([{ Key, Size: 1 }]));
|
||||
assert.throws(() => validateKeys([{ Key: 'a/b', Size: 1 }, { Key: 'a', Size: 1 }]));
|
||||
assert.throws(() => validateKeys([{ Key: 'a', Size: MAX_OBJECT_BYTES + 1 }]));
|
||||
});
|
||||
test('rejects encryption and nonportable attributes rather than dropping them', () => {
|
||||
for (const value of [{ ServerSideEncryption: 'AES256' }, { ObjectLockMode: 'GOVERNANCE' },
|
||||
{ StorageClass: 'GLACIER' }, { WebsiteRedirectLocation: '/other' }]) assert.throws(() => metadata(value));
|
||||
assert.deepEqual(metadata({ ContentType: 'application/pdf', Metadata: { custom: 'value' } }),
|
||||
{ ContentType: 'application/pdf', Metadata: { custom: 'value' } });
|
||||
});
|
||||
|
||||
test('interrupted contract cleanup deletes only its two journalled probe objects', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'fedeo-probe-test-'));
|
||||
try {
|
||||
const path = join(directory, 'probe.json');
|
||||
const key = 'fedeo-contract-11111111-1111-1111-1111-111111111111/test';
|
||||
await atomicJson(path, { key });
|
||||
const inputs = [];
|
||||
await cleanupProbe({ send: async command => { inputs.push(command.input); return {}; } }, 'bucket', path);
|
||||
assert.deepEqual(inputs, [{ Bucket: 'bucket', Key: key }, { Bucket: 'bucket', Key: key + '.stream' }]);
|
||||
await atomicJson(path, { key: 'real-business-file.pdf' });
|
||||
await assert.rejects(cleanupProbe({ send: async () => { throw new Error('must not send'); } }, 'bucket', path), /Probejournal/);
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
201
scripts/storage/tests/test_host.py
Normal file
201
scripts/storage/tests/test_host.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
spec = importlib.util.spec_from_file_location("storage_migration", Path(__file__).resolve().parents[2] / "selfhost-storage-migrate.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
class HostSafetyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
for name in ("minio", "postgres"):
|
||||
(self.root / name).mkdir()
|
||||
self.config = {"services": {n: {} for n in ("db", "minio", "createbuckets", "backend", "frontend", "traefik", "node-exporter")}}
|
||||
for name, path, target in (("minio", "minio", "/data"), ("db", "postgres", "/var/lib/postgresql/data")):
|
||||
self.config["services"][name]["volumes"] = [{"type": "bind", "source": str(self.root / path), "target": target}]
|
||||
self.config["services"]["minio"].update(networks={"internal": {}}, environment={"MINIO_ROOT_USER": "user", "MINIO_ROOT_PASSWORD": "secret"})
|
||||
self.config["services"]["db"]["environment"] = {"POSTGRES_USER": "u", "POSTGRES_PASSWORD": "p", "POSTGRES_DB": "db"}
|
||||
self.config["services"]["backend"]["environment"] = {"DATABASE_URL": "postgres://u:p@db:5432/db", "S3_ENDPOINT": "http://minio:9000",
|
||||
"S3_REGION": "eu-central-1", "S3_BUCKET": "fedeo", "S3_ACCESS_KEY": "user", "S3_SECRET_KEY": "secret"}
|
||||
|
||||
def test_rejects_external_or_unbacked_storage_and_secret_drift(self):
|
||||
m.validate_config(self.config, self.root)
|
||||
mutations = [lambda c: c["services"]["minio"].update(ports=["9000:9000"]),
|
||||
lambda c: c["services"]["backend"]["environment"].update(S3_ENDPOINT="https://external.example"),
|
||||
lambda c: c["services"]["backend"]["environment"].update(S3_SECRET_KEY="different"),
|
||||
lambda c: c["services"]["minio"]["volumes"][0].update(source="/elsewhere"),
|
||||
lambda c: c["services"].update(unknown={}),
|
||||
lambda c: c["services"]["backend"]["environment"].update(DATABASE_URL="postgres://u:p@db:5432/other")]
|
||||
for mutate in mutations:
|
||||
config = copy.deepcopy(self.config)
|
||||
mutate(config)
|
||||
with self.assertRaises(m.Error):
|
||||
m.validate_config(config, self.root)
|
||||
|
||||
def test_rejects_symlink_source(self):
|
||||
(self.root / "minio").rmdir()
|
||||
(self.root / "minio").symlink_to(self.root / "postgres")
|
||||
with self.assertRaises(m.Error):
|
||||
m.validate_config(self.config, self.root)
|
||||
|
||||
def test_retired_matrix_remains_stopped_and_outside_compose(self):
|
||||
container = {"Config": {"Labels": {"com.docker.compose.service": "matrix-redis"}},
|
||||
"State": {"Running": False}, "HostConfig": {"RestartPolicy": {"Name": "no"}},
|
||||
"Mounts": [{"Type": "volume", "Source": "/var/lib/docker/volumes/old/_data", "Destination": "/data"}]}
|
||||
indexed, retained = m.index_containers(self.config, [container])
|
||||
self.assertEqual(set(indexed), {"matrix-redis"})
|
||||
self.assertEqual(retained, {"matrix-redis": {"volumes": []}})
|
||||
self.assertNotIn("matrix-redis", self.config["services"])
|
||||
mutations = [lambda c: c["State"].update(Running=True),
|
||||
lambda c: c["State"].update(Restarting=True),
|
||||
lambda c: c["State"].update(Paused=True),
|
||||
lambda c: c["HostConfig"]["RestartPolicy"].update(Name="unless-stopped"),
|
||||
lambda c: c["HostConfig"].update(AutoRemove=True),
|
||||
lambda c: c["Config"]["Labels"].update({"com.docker.compose.service": "unknown"}),
|
||||
lambda c: c["Config"]["Labels"].update({"com.docker.compose.oneoff": "True"}),
|
||||
lambda c: c["Mounts"][0].update(Destination="/unexpected")]
|
||||
for mutate in mutations:
|
||||
changed = copy.deepcopy(container)
|
||||
mutate(changed)
|
||||
with self.assertRaises(m.Error):
|
||||
m.index_containers(self.config, [changed])
|
||||
with self.assertRaises(m.Error):
|
||||
m.index_containers(self.config, [container, container])
|
||||
|
||||
def test_retired_matrix_bind_mounts_keep_strict_backup_validation(self):
|
||||
container = {"Config": {"Labels": {"com.docker.compose.service": "matrix-db"}},
|
||||
"State": {"Running": False}, "HostConfig": {"RestartPolicy": {"Name": "no"}},
|
||||
"Mounts": [{"Type": "bind", "Source": "/outside/postgres", "Destination": "/var/lib/postgresql/data"}]}
|
||||
migration = m.Migration(self.root)
|
||||
_, retained = m.index_containers(self.config, [container])
|
||||
migration.engine.current = {"services": retained}
|
||||
migration.engine.retired_services = list(retained)
|
||||
with self.assertRaises(m.Error):
|
||||
migration.engine.validate_legacy_matrix()
|
||||
(self.root / "matrix/postgres").mkdir(parents=True)
|
||||
retained["matrix-db"]["volumes"][0]["source"] = str(self.root / "matrix/postgres")
|
||||
migration.engine.validate_legacy_matrix()
|
||||
self.assertEqual(migration.engine.matrix_backup_paths, ["matrix"])
|
||||
|
||||
def test_cold_index_detects_objects_hidden_by_minio_listing(self):
|
||||
source = self.root / "minio"
|
||||
(source / ".minio.sys").mkdir()
|
||||
(source / ".minio.sys/format.json").write_text(json.dumps({"format": "xl-single", "xl": {"version": "3"}}))
|
||||
(source / "fedeo/a").mkdir(parents=True)
|
||||
(source / "fedeo/a/xl.meta").write_bytes(b"opaque")
|
||||
m.validate_minio_listing(self.root, "fedeo", {"objects": [{"Key": "a"}]}, exact=True)
|
||||
(source / "fedeo/a/b").mkdir()
|
||||
(source / "fedeo/a/b/xl.meta").write_bytes(b"hidden-from-S3-LIST")
|
||||
with self.assertRaisesRegex(m.Error, "Konflikt|konflikt"):
|
||||
m.validate_minio_listing(self.root, "fedeo", {"objects": [{"Key": "a"}]}, exact=True)
|
||||
(source / "fedeo/a/b/xl.meta").unlink()
|
||||
with self.assertRaisesRegex(m.Error, "weichen ab"):
|
||||
m.validate_minio_listing(self.root, "fedeo", {"objects": [{"Key": "a"}, {"Key": "missing"}]}, exact=True)
|
||||
|
||||
def test_update_and_migration_share_exclusive_lock(self):
|
||||
a, b = m.Migration(self.root), m.Migration(self.root)
|
||||
with a.engine.lock():
|
||||
with self.assertRaises(m.Error):
|
||||
with b.engine.lock():
|
||||
self.fail("lock must be exclusive")
|
||||
|
||||
def test_failed_freeze_keeps_persistent_recovery_guard(self):
|
||||
migration = m.Migration(self.root)
|
||||
migration.state = {"startedAt": "test", "writesReleased": False, "containers": {"backend": {"id": "backend"}}}
|
||||
def fail(*args, **kwargs):
|
||||
state = m.u.read_json(migration.engine.home / "status.json")
|
||||
self.assertTrue(state["recoveryRequired"])
|
||||
self.assertEqual(m.u.read_json(migration.home / "state.json")["phase"], "freezing")
|
||||
raise m.Error("power loss")
|
||||
migration.docker = fail
|
||||
with self.assertRaises(m.Error):
|
||||
migration.freeze()
|
||||
self.assertFalse(m.u.read_json(migration.home / "state.json")["writesReleased"])
|
||||
|
||||
def test_backup_corruption_prevents_progress(self):
|
||||
migration = m.Migration(self.root)
|
||||
backup = migration.home / "backup"
|
||||
backup.mkdir()
|
||||
(backup / "files.tar").write_bytes(b"valid")
|
||||
m.u.write_json(backup / "complete.json", {"sha256": {"files.tar": m.sha_file(backup / "files.tar")}})
|
||||
migration.check_backup()
|
||||
(backup / "files.tar").write_bytes(b"corrupt")
|
||||
with self.assertRaises(m.Error):
|
||||
migration.check_backup()
|
||||
|
||||
def test_start_failure_is_already_past_rollback_boundary(self):
|
||||
migration = m.Migration(self.root)
|
||||
migration.state = {"startedAt": "test", "phase": "verified", "network": "internal", "writesReleased": False,
|
||||
"containers": {"minio": {"id": "old"}}}
|
||||
for method in ("assert_frozen", "verify_original", "check_backup", "cleanup_helper", "helper", "docker", "compose"):
|
||||
setattr(migration, method, Mock())
|
||||
migration.inspect = lambda _: {"NetworkSettings": {"Networks": {}}}
|
||||
migration.restore_test = Mock()
|
||||
migration.runner = Mock()
|
||||
(migration.home / "target.json").write_text('{}')
|
||||
def fail_start():
|
||||
self.assertTrue(m.u.read_json(migration.home / "state.json")["writesReleased"])
|
||||
raise m.Error("backend did not start")
|
||||
migration.start_application = fail_start
|
||||
from unittest.mock import patch
|
||||
(migration.home / "credentials.json").write_text('{"bucket":"fedeo"}')
|
||||
with patch.object(m, "validate_minio_listing"):
|
||||
(migration.home / "copy").mkdir()
|
||||
(migration.home / "copy/inventory.json").write_text('{}')
|
||||
with self.assertRaises(m.Error):
|
||||
migration.activate()
|
||||
with self.assertRaises(m.Error):
|
||||
migration.rollback()
|
||||
|
||||
def test_compose_roundtrip_preserves_dollar_signs(self):
|
||||
value = {"environment": {"KEY": "literal-$-$$-${NOT_AN_ENV_VAR}"}, "command": ["echo $VALUE"]}
|
||||
self.assertEqual(m.resolved(m.literal(value)), value)
|
||||
|
||||
def restart_fixture(self):
|
||||
migration = m.Migration(self.root)
|
||||
(self.root / "docker-compose.yml").write_text('{}')
|
||||
(migration.home / "target.json").write_text('{}')
|
||||
migration.state = {"writesReleased": True, "phase": "released", "containers": {
|
||||
"backend": {"running": True, "health": "unhealthy"},
|
||||
"db": {"running": True, "health": "healthy"},
|
||||
"matrix-synapse": {"running": True, "health": "healthy"},
|
||||
"matrix-element": {"running": True, "health": "unhealthy"},
|
||||
"matrix-redis": {"running": False, "health": None},
|
||||
}}
|
||||
migration.compose = Mock(return_value="matrix-element-id")
|
||||
migration.inspect = Mock(return_value={"State": {"Running": True}})
|
||||
return migration
|
||||
|
||||
def test_restart_preserves_existing_matrix_health_failure_but_waits_for_core(self):
|
||||
migration = self.restart_fixture()
|
||||
migration.start_application()
|
||||
calls = [call.args for call in migration.compose.call_args_list]
|
||||
start = next(args for args in calls if "up" in args and "--no-deps" not in args)
|
||||
self.assertIn("matrix-element", start)
|
||||
self.assertNotIn("matrix-redis", start)
|
||||
wait = next(args for args in calls if "--wait" in args)
|
||||
for name in ("backend", "db", "matrix-synapse"):
|
||||
self.assertIn(name, wait)
|
||||
self.assertNotIn("matrix-element", wait)
|
||||
self.assertEqual(migration.state["preexistingUnhealthyServices"], ["matrix-element"])
|
||||
self.assertEqual(migration.state["phase"], "complete")
|
||||
|
||||
def test_preexisting_unhealthy_matrix_must_still_restart(self):
|
||||
migration = self.restart_fixture()
|
||||
migration.inspect.return_value = {"State": {"Running": False}}
|
||||
with self.assertRaisesRegex(m.Error, "nicht wieder gestartet"):
|
||||
migration.start_application()
|
||||
self.assertEqual(migration.state["phase"], "released")
|
||||
self.assertFalse(any("traefik" in call.args for call in migration.compose.call_args_list))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
155
scripts/update/tests/host_storage_integration.py
Normal file
155
scripts/update/tests/host_storage_integration.py
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real migration -> adoption -> two updates, including a backup restore, on synthetic data.
|
||||
|
||||
Requires local storage test images and registry:2; never contacts a FEDEO instance.
|
||||
Release HTTP/health responses are fixtures; Compose, registry, SDK, PostgreSQL,
|
||||
backup, restore and provider validation use real processes and containers.
|
||||
"""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
spec = importlib.util.spec_from_file_location('storage_fixture', ROOT / 'scripts/storage/tests/host_integration.py')
|
||||
fixture = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(fixture)
|
||||
m, docker = fixture.m, fixture.docker
|
||||
u = m.u
|
||||
|
||||
|
||||
class Http:
|
||||
version = '3.4.0'
|
||||
|
||||
def json(self, url):
|
||||
return {'status': 'ok', 'product': 'FEDEO', 'version': self.version, 'revision': 'a' * 40}
|
||||
|
||||
|
||||
class Releases:
|
||||
def __init__(self, original, images):
|
||||
self.original, self.images = original, images
|
||||
|
||||
def download(self, version, destination):
|
||||
config = copy.deepcopy(self.original)
|
||||
for name, image in self.images.items():
|
||||
config['services'][name]['image'] = image['repository'] + ':fixture'
|
||||
config['services']['frontend']['entrypoint'] = ['node']
|
||||
config['services']['frontend']['command'] = ['-e', 'setInterval(()=>{},1000)']
|
||||
for name in ('traefik', 'node-exporter'):
|
||||
config['services'][name]['image'] = json.loads(docker('image', 'inspect', 'node:22-alpine'))[0]['Id']
|
||||
config['services']['backend']['environment']['FEDEO_VERSION'] = version
|
||||
path = destination / 'fixture.json'
|
||||
u.write_json(path, m.literal(config))
|
||||
rendered = docker('compose', '-f', path, 'config', '--format', 'yaml')
|
||||
(destination / 'docker-compose.selfhost.yml').write_text(rendered)
|
||||
for name in ('.env.example', 'selfhost-setup.sh', 'CHANGELOG.md'):
|
||||
(destination / name).write_text('# Synthetic update fixture\n')
|
||||
manifest = {'schemaVersion': 1, 'version': version, 'commit': 'a' * 40,
|
||||
'images': list(self.images.values()), 'migrations': []}
|
||||
u.write_json(destination / 'release-manifest.json', manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def run():
|
||||
root, project = fixture.fixture()
|
||||
registry = project + '-registry'
|
||||
registry_id = None
|
||||
work = Path(tempfile.mkdtemp(prefix='fedeo-storage-update-images-'))
|
||||
try:
|
||||
config = m.resolved(u.read_json(root / 'docker-compose.yml'))
|
||||
config['services']['backend']['environment'].update(ENCRYPTION_KEY='fixture-key', COOKIE_SECRET='fixture-$-cookie', JWT_SECRET='fixture-jwt')
|
||||
config['services']['frontend']['environment'] = {'NUXT_PUBLIC_API_BASE': 'https://fixture.example.test/backend'}
|
||||
u.write_json(root / 'docker-compose.yml', m.literal(config))
|
||||
docker('compose', '-p', project, '-f', root / 'docker-compose.yml', 'up', '-d', '--pull', 'never', '--wait', '--wait-timeout', '90')
|
||||
fixture.seed(root, project)
|
||||
# Include the stopped Matrix orphan seen on the first real instance.
|
||||
(root / 'matrix/postgres').mkdir(parents=True)
|
||||
(root / 'matrix/postgres/retained.txt').write_text('retired Matrix')
|
||||
retained = docker('create', '--pull', 'never', '--restart=no', '--network', project + '-internal',
|
||||
'--label', 'com.docker.compose.project=' + project,
|
||||
'--label', 'com.docker.compose.service=matrix-db',
|
||||
'-v', str(root / 'matrix/postgres') + ':/var/lib/postgresql/data', 'node:22-alpine')
|
||||
migration = m.Migration(root, 'fedeo-storage-tools:pilot')
|
||||
with migration.engine.lock():
|
||||
migration.plan()
|
||||
migration.migrate(True)
|
||||
migration.activate()
|
||||
print(json.dumps({'phase': 'migration_complete', 'evidence': str(root)}), flush=True)
|
||||
for name in u.CORE_SERVICES:
|
||||
migration.wait_ready(docker('compose', '-p', project, '-f', root / 'docker-compose.yml', 'ps', '-q', name))
|
||||
http = Http()
|
||||
updater = u.Updater(root, http=http)
|
||||
assert updater.adopt_storage()['status'] == 'adopted'
|
||||
print(json.dumps({'phase': 'storage_adopted'}), flush=True)
|
||||
|
||||
registry_id = docker('run', '-d', '--pull', 'never', '--name', registry, '-p', '127.0.0.1::5000', 'registry:2')
|
||||
port = json.loads(docker('inspect', registry_id))[0]['NetworkSettings']['Ports']['5000/tcp'][0]['HostPort']
|
||||
repository = 'localhost:' + port + '/fedeo-update-fixture'
|
||||
(work / 'Dockerfile').write_text('FROM fedeo-storage-tools:pilot\n'
|
||||
'RUN node -e "const fs=require(\'fs\');const p=JSON.parse(fs.readFileSync(\'package.json\'));'
|
||||
'p.scripts={migrate:\'node --version\'};fs.writeFileSync(\'package.json\',JSON.stringify(p))"\n'
|
||||
'ENTRYPOINT ["node"]\nCMD ["-e", "setInterval(()=>{},1000)"]\n')
|
||||
docker('build', '-t', repository + ':fixture', work)
|
||||
docker('push', repository + ':fixture')
|
||||
digest = json.loads(docker('image', 'inspect', repository + ':fixture'))[0]['RepoDigests'][0].split('@')[1]
|
||||
images = {name: {'name': name, 'repository': repository + '-' + name, 'digest': digest} for name in ('backend', 'frontend')}
|
||||
for image in images.values():
|
||||
docker('tag', repository + ':fixture', image['repository'] + ':fixture')
|
||||
docker('push', image['repository'] + ':fixture')
|
||||
# Copy health, networking and SDK settings from the original fixture, but start the new image's migration script.
|
||||
updater.releases = Releases(config, images)
|
||||
for version in ('3.4.0', '3.5.0'):
|
||||
http.version = version
|
||||
checked = updater.execute('check', version)
|
||||
assert checked['plan']['storageProvider'] == 'seaweedfs'
|
||||
assert not checked['plan']['requiresComposeAdoption']
|
||||
result = updater.execute('apply', version, expected_fingerprint=checked['plan']['approvalFingerprint'])
|
||||
assert result['status'] == 'succeeded'
|
||||
assert not migration.inspect(migration.state['containers']['minio']['id'])['State']['Running']
|
||||
old_matrix = migration.inspect(retained)
|
||||
assert not old_matrix['State']['Running'] and old_matrix['HostConfig']['RestartPolicy']['Name'] == 'no'
|
||||
backup = Path(result['backup'])
|
||||
with tarfile.open(backup / 'files.tar') as archive:
|
||||
assert archive.extractfile('matrix/postgres/retained.txt').read() == b'retired Matrix'
|
||||
assert 'storage/config/s3.json' in archive.getnames()
|
||||
assert 'minio' not in archive.getnames()
|
||||
metadata = u.read_json(backup / 'complete.json')
|
||||
for name, expected in metadata['sha256'].items():
|
||||
assert u.file_sha(backup / name) == expected
|
||||
print(json.dumps({'phase': 'update_complete', 'version': version, 'backup': str(backup)}), flush=True)
|
||||
|
||||
# Restore the *updater's* cold archive, not the original migration backup.
|
||||
restore = root / '.updates/update-restore-test'
|
||||
restore.mkdir()
|
||||
subprocess.run(['tar', '-xpf', str(backup / 'files.tar'), '-C', str(restore)], check=True)
|
||||
store = updater.storage['service']
|
||||
restore_name = project + '-update-restore'
|
||||
try:
|
||||
docker('run', '-d', '--pull', 'never', '--name', restore_name, '--network', project + '-internal',
|
||||
'--label', 'com.docker.compose.project=' + project,
|
||||
'-v', str(restore / 'storage/seaweedfs') + ':/data', '-v', str(restore / 'storage/config') + ':/etc/fedeo:ro',
|
||||
'-e', 'FEDEO_S3_CONFIG_SHA256=' + updater.storage['configSha256']['s3.json'],
|
||||
'--entrypoint', store['entrypoint'][0], store['image'], *store['entrypoint'][1:], *store['command'])
|
||||
migration.helper('restore-verify', endpoint='http://' + restore_name + ':9000')
|
||||
finally:
|
||||
docker('rm', '-f', restore_name)
|
||||
print(json.dumps({'result': 'passed', 'restoredUpdaterBackup': str(backup), 'evidence': str(root)}), flush=True)
|
||||
except BaseException:
|
||||
print('Failure evidence: ' + str(root), flush=True)
|
||||
for path in sorted((root / '.updates').glob('*/commands.log')):
|
||||
print(str(path) + '\n' + path.read_text()[-5000:], flush=True)
|
||||
raise
|
||||
finally:
|
||||
ids = docker('ps', '-aq', '--filter', 'label=com.docker.compose.project=' + project).split()
|
||||
if ids:
|
||||
docker('rm', '-f', '-v', *ids)
|
||||
if registry_id:
|
||||
docker('rm', '-f', '-v', registry_id)
|
||||
docker('network', 'rm', project + '-internal')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -8,6 +8,7 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
spec = importlib.util.spec_from_file_location('update_service', ROOT / 'scripts/selfhost-update-service.py')
|
||||
@@ -69,6 +70,16 @@ class UnixConnection(http.client.HTTPConnection):
|
||||
|
||||
|
||||
class ServiceTests(unittest.TestCase):
|
||||
def test_storage_fields_are_sanitized_and_new_phases_remain_compatible(self):
|
||||
plan = s.public_plan({'storageProvider': 'seaweedfs', 'storageImageRetained': True,
|
||||
'storageBackupPaths': ['/private/storage'], 'service': {'secret': 'hidden'}})
|
||||
self.assertEqual(plan['storageProvider'], 'seaweedfs')
|
||||
self.assertTrue(plan['storageImageRetained'])
|
||||
self.assertNotIn('private', json.dumps(plan))
|
||||
self.assertNotIn('hidden', json.dumps(plan))
|
||||
self.assertEqual(s.public_state({'phase': 'checking_storage'})['phase'], 'validating')
|
||||
self.assertEqual(s.public_state({'phase': 'storage_adopted'})['phase'], 'resolved')
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
@@ -130,6 +141,7 @@ class ServiceTests(unittest.TestCase):
|
||||
self.controller.close()
|
||||
self.controller = s.Controller(self.root, updater_factory=self.factory)
|
||||
self.server.controller = self.controller
|
||||
self.assertEqual(self.request('GET', '/v1/status')[1]['request']['requestId'], request_id)
|
||||
status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(self.factory.calls), 1)
|
||||
@@ -231,6 +243,86 @@ class ServiceTests(unittest.TestCase):
|
||||
self.assertEqual(self.request('GET', '/v1/logs')[0], 404)
|
||||
self.assertEqual((self.controller.home / 'check-request-01.log').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_logs_include_failure_commands_and_events_with_credentials_redacted(self):
|
||||
(self.root / '.env').write_text('POSTGRES_PASSWORD="database-secret"\nAPI_TOKEN=api-secret\n'
|
||||
'SMTP_PASSWORD=complex,secret/suffix\n'
|
||||
'DATABASE_URL=postgres://user:db%2Fsecret@db/fedeo\n')
|
||||
(self.root / 'docker-compose.override.yml').write_text(' MINIO_ROOT_PASSWORD: "minio-secret"\n')
|
||||
self.factory.failure = s.engine.UpdateError('Registry unauthorized: database-secret')
|
||||
request_id = self.check()
|
||||
job = self.root / '.updates' / ('job-' + request_id)
|
||||
(job / 'target.env').write_text("SMTP_PASSWORD='previous-secret'\n")
|
||||
(job / 'commands.log').write_text(
|
||||
'\x1b[31mError: pull access denied\x1b[0m\n'
|
||||
'database-secret api-secret minio-secret previous-secret release-secret\n'
|
||||
'complex,secret/suffix complex%2Csecret%2Fsuffix db/secret\n'
|
||||
'https://user:url-secret@registry.test/image\n'
|
||||
'Authorization: Bearer header-secret\n'
|
||||
'{"password": "json-secret", "access_token": "query-secret"}\n'
|
||||
'TOKEN=unquoted-secret\n--password cli-secret\n')
|
||||
with patch.dict(s.os.environ, {'FEDEO_RELEASE_TOKEN': 'release-secret'}):
|
||||
status, result = self.request('GET', f'/v1/requests/{request_id}/logs')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(result['requestId'], request_id)
|
||||
logs = {entry['source']: entry['content'] for entry in result['logs']}
|
||||
self.assertIn('Registry unauthorized', logs['service'])
|
||||
self.assertIn('pull access denied', logs['commands'])
|
||||
self.assertIn('validating', logs['events'])
|
||||
for secret in ('database-secret', 'api-secret', 'minio-secret', 'previous-secret', 'release-secret',
|
||||
'url-secret', 'header-secret', 'json-secret', 'query-secret', 'unquoted-secret', 'cli-secret',
|
||||
'complex,secret/suffix', 'complex%2Csecret%2Fsuffix', 'db/secret', '\x1b'):
|
||||
self.assertNotIn(secret, json.dumps(result))
|
||||
self.assertTrue(self.request('GET', '/v1/status')[1]['capabilities']['requestLogs'])
|
||||
|
||||
def test_logs_survive_restart_and_find_legacy_jobs_without_crossing_requests(self):
|
||||
first = self.check()
|
||||
first_job = self.root / '.updates' / ('job-' + first)
|
||||
(first_job / 'commands.log').write_text('first request only\n')
|
||||
# Simulate the private request record from an older host service.
|
||||
record = self.controller.read(first)
|
||||
record.pop('job')
|
||||
s.engine.write_json(self.controller.path(first), record)
|
||||
second = self.check('check-request-02')
|
||||
(self.root / '.updates' / ('job-' + second) / 'commands.log').write_text('second request only\n')
|
||||
self.controller.close()
|
||||
self.controller = s.Controller(self.root, updater_factory=self.factory)
|
||||
self.server.controller = self.controller
|
||||
for request_id, own, other in [(first, 'first', 'second'), (second, 'second', 'first')]:
|
||||
payload = json.dumps(self.request('GET', f'/v1/requests/{request_id}/logs')[1])
|
||||
self.assertIn(own + ' request only', payload)
|
||||
self.assertNotIn(other + ' request only', payload)
|
||||
self.assertEqual(self.request('GET', '/v1/requests/missing-request/logs')[0], 404)
|
||||
self.assertEqual(self.request('GET', '/v1/requests/../../secret/logs')[0], 400)
|
||||
|
||||
def test_running_logs_are_bounded_and_do_not_expose_partial_secret_lines(self):
|
||||
self.factory.release.clear()
|
||||
request_id = 'check-running-logs'
|
||||
self.request('POST', '/v1/check', {'requestId': request_id})
|
||||
self.assertTrue(self.factory.started.wait(3))
|
||||
job = self.root / '.updates' / ('job-' + request_id)
|
||||
(job / 'commands.log').write_text('PASSWORD=' + 'secret-fragment' * 4000 + '\n' + 'old entry\n' * 900 + 'Last diagnostic: ä\n')
|
||||
result = self.request('GET', f'/v1/requests/{request_id}/logs')[1]
|
||||
commands = next(entry for entry in result['logs'] if entry['source'] == 'commands')
|
||||
self.assertTrue(commands['truncated'])
|
||||
self.assertLessEqual(len(commands['content'].encode()), s.LOG_BYTES)
|
||||
self.assertIn('Last diagnostic: ä', commands['content'])
|
||||
self.assertNotIn('secret-fragment', commands['content'])
|
||||
self.assertLess(len(json.dumps(result).encode()), 65536)
|
||||
|
||||
def test_logs_fail_closed_for_symlinks_and_unreadable_redaction_configuration(self):
|
||||
request_id = self.check()
|
||||
job = self.root / '.updates' / ('job-' + request_id)
|
||||
private = self.root / 'private.txt'
|
||||
private.write_text('never publish this')
|
||||
(job / 'commands.log').symlink_to(private)
|
||||
status, result = self.request('GET', f'/v1/requests/{request_id}/logs')
|
||||
self.assertEqual(status, 503)
|
||||
self.assertEqual(result['error']['code'], 'LOGS_UNAVAILABLE')
|
||||
self.assertNotIn('never publish this', json.dumps(result))
|
||||
(job / 'commands.log').unlink()
|
||||
(self.root / '.env').symlink_to(private)
|
||||
self.assertEqual(self.request('GET', f'/v1/requests/{request_id}/logs')[0], 503)
|
||||
|
||||
def test_api_rejects_unsupported_commands_options_bodies_and_browser_requests(self):
|
||||
for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'},
|
||||
{'requestId': 'check-request-01', 'directory': '/etc'},
|
||||
|
||||
278
scripts/update/tests/test_storage_provider.py
Normal file
278
scripts/update/tests/test_storage_provider.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Provider handover and update failure tests using real Compose parsing."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
import unittest
|
||||
|
||||
from test_updater import ROOT, Runner, u
|
||||
import test_updater as base
|
||||
|
||||
|
||||
class StorageRunner(Runner):
|
||||
def __init__(self, log, http):
|
||||
super().__init__(log, http)
|
||||
self.storage = None
|
||||
self.old_minio = None
|
||||
self.db_image = 'sha256:' + 'd' * 64
|
||||
|
||||
def run(self, args, **kwargs):
|
||||
if args[:2] == ['docker', 'inspect'] and args[2] in ('objectstore', 'minio', 'retained-minio', 'db'):
|
||||
self.calls.append(args)
|
||||
if args[2] == 'db':
|
||||
return json.dumps([{'Image': self.db_image, 'Config': {'Labels': {'com.docker.compose.service': 'db'}},
|
||||
'State': {'Running': True, 'Health': {'Status': 'healthy'}}}])
|
||||
return json.dumps([self.storage if args[2] == 'objectstore' else self.old_minio])
|
||||
if args[:3] == ['docker', 'image', 'inspect'] and u.IMAGE_ID.fullmatch(args[3]):
|
||||
self.calls.append(args)
|
||||
return args[3] if '--format' in args else json.dumps([{'Id': args[3], 'Size': 1000}])
|
||||
if args[:3] == ['docker', 'image', 'save']:
|
||||
self.calls.append(args)
|
||||
Path(args[args.index('-o') + 1]).write_bytes(b'preserved storage image')
|
||||
return ''
|
||||
if args[:2] == ['docker', 'compose']:
|
||||
if 'ps' in args and args[-1] in ('objectstore', 'traefik', 'node-exporter'):
|
||||
self.calls.append(args)
|
||||
return args[-1]
|
||||
if 'stop' in args and 'objectstore' in args:
|
||||
self.storage['State']['Running'] = False
|
||||
if 'start' in args or 'up' in args:
|
||||
if 'objectstore' in args or ('up' in args and '--no-deps' not in args):
|
||||
self.storage['State']['Running'] = True
|
||||
result = super().run(args, **kwargs)
|
||||
if 'ps' in args and 'backend\nfrontend' in result:
|
||||
result += '\nobjectstore'
|
||||
return result
|
||||
|
||||
|
||||
class StorageTests(unittest.TestCase):
|
||||
setUp = base.UpdateTests.setUp
|
||||
tearDown = base.UpdateTests.tearDown
|
||||
called = base.UpdateTests.called
|
||||
run_update = base.UpdateTests.run_update
|
||||
|
||||
def migrated(self):
|
||||
self.runner = StorageRunner(self.root / 'log', self.http)
|
||||
self.updater.runner_factory = lambda log: self.runner
|
||||
self.job = self.root / '.updates/storage-migration'
|
||||
self.job.mkdir()
|
||||
(self.root / 'storage/config').mkdir(parents=True)
|
||||
(self.root / 'storage/seaweedfs').mkdir()
|
||||
for name in ('s3.json', 'start-seaweedfs.sh'):
|
||||
(self.root / 'storage/config' / name).write_text('protected-' + name)
|
||||
(self.root / 'storage/seaweedfs/data').write_bytes(b'complete volume data')
|
||||
original = u.compose_strings(json.loads(subprocess.check_output([
|
||||
'docker', 'compose', '--project-directory', str(self.root), '--env-file', str(self.root / '.env'),
|
||||
'-f', str(self.root / 'docker-compose.yml'), 'config', '--format', 'json'])))
|
||||
u.write_json(self.job / 'original.json', original)
|
||||
target = copy.deepcopy(original)
|
||||
services = target['services']
|
||||
services.pop('minio'); services.pop('createbuckets')
|
||||
services['db']['image'] = self.runner.db_image
|
||||
services['backend']['environment'].update(AWS_REQUEST_CHECKSUM_CALCULATION='WHEN_REQUIRED', FEDEO_RUN_MIGRATIONS='false')
|
||||
services['backend']['depends_on'] = {'db': {'condition': 'service_healthy'}, 'objectstore': {'condition': 'service_healthy'}}
|
||||
weed = u.read_json(ROOT / 'scripts/storage/seaweedfs.json')
|
||||
weed.update(image='sha256:' + 'e' * 64, container_name='fedeo-objectstore', restart='unless-stopped',
|
||||
networks={'internal': {'aliases': ['minio']}}, user='0:0',
|
||||
environment={'FEDEO_S3_CONFIG_SHA256': u.file_sha(self.root / 'storage/config/s3.json')},
|
||||
volumes=[{'type': 'bind', 'source': str(self.root / 'storage/seaweedfs'), 'target': '/data'},
|
||||
{'type': 'bind', 'source': str(self.root / 'storage/config'), 'target': '/etc/fedeo', 'read_only': True}])
|
||||
services['objectstore'] = weed
|
||||
u.write_json(self.job / 'target.json', u.compose_strings(target, escape=True))
|
||||
(self.root / 'docker-compose.yml').write_bytes((self.job / 'target.json').read_bytes())
|
||||
network = original['networks']['internal']['name']
|
||||
self.runner.storage = {'Id': 'objectstore', 'Image': weed['image'],
|
||||
'Config': {'Labels': {'com.docker.compose.service': 'objectstore'},
|
||||
'Env': ['FEDEO_S3_CONFIG_SHA256=' + weed['environment']['FEDEO_S3_CONFIG_SHA256']]},
|
||||
'State': {'Running': True, 'Health': {'Status': 'healthy'}}, 'HostConfig': {},
|
||||
'NetworkSettings': {'Networks': {network: {'Aliases': ['minio']}}},
|
||||
'Mounts': [{'Type': 'bind', 'Source': v['source'], 'Destination': v['target'], 'RW': not v.get('read_only')} for v in weed['volumes']]}
|
||||
self.runner.old_minio = {'Id': 'retained-minio', 'Image': 'image-minio',
|
||||
'Config': {'Labels': {'com.docker.compose.service': 'minio'}}, 'State': {'Running': False},
|
||||
'HostConfig': {'RestartPolicy': {'Name': 'no'}}, 'NetworkSettings': {'Networks': {}}}
|
||||
self.runner.local_images.update([weed['image'], self.runner.db_image])
|
||||
state = {'schemaVersion': 1, 'phase': 'complete', 'provider': 'seaweedfs', 'writesReleased': True,
|
||||
'version': '3.0.0', 'restoreVerifiedAt': u.now(), 'weedImage': weed['image'], 'network': network,
|
||||
'fingerprint': {'.env': u.file_sha(self.root / '.env')}, 'count': 1, 'bytes': 20,
|
||||
'containers': {name: {'id': name, 'image': 'image-' + name} for name in u.CORE_SERVICES | {'minio'}}}
|
||||
state['containers']['db']['image'] = self.runner.db_image
|
||||
state['containers']['minio']['id'] = 'retained-minio'
|
||||
u.write_json(self.job / 'copy/verified.json', {'count': 1, 'bytes': 20, 'verifiedAt': u.now(), 'objects': [{}]})
|
||||
for name in ('restore-contract', 'acceptance', 'backend-acceptance'):
|
||||
u.write_json(self.job / name / 'result.json', {'operations': ['PUT', 'HEAD', 'GET', 'DELETE', 'SigV4', 'presigned-24h', 'anonymous-denied', 'wrong-key-denied']})
|
||||
u.write_json(self.job / 'backup/complete.json', {})
|
||||
(self.job / 'backup/seaweedfs.tar').write_bytes(b'verified cold restore archive')
|
||||
state['seaweedBackupSha256'] = u.file_sha(self.job / 'backup/seaweedfs.tar')
|
||||
u.write_json(self.job / 'state.json', state)
|
||||
u.write_json(self.root / '.updates/status.json', {'status': 'failed', 'phase': 'storage_migration',
|
||||
'job': str(self.job), 'recoveryRequired': True})
|
||||
return state
|
||||
|
||||
def adopt(self):
|
||||
self.migrated()
|
||||
self.assertEqual(self.updater.adopt_storage()['status'], 'adopted')
|
||||
self.assertFalse(self.updater.status()['recoveryRequired'])
|
||||
self.runner.calls.clear()
|
||||
|
||||
def test_adoption_is_read_only_for_services_and_idempotent(self):
|
||||
self.adopt()
|
||||
self.assertEqual(self.updater.adopt_storage()['status'], 'already_adopted')
|
||||
self.assertFalse(self.called('stop'))
|
||||
self.assertFalse(self.called('pull'))
|
||||
result = self.updater.execute('check', '3.1.0')
|
||||
self.assertEqual(result['plan']['storageProvider'], 'seaweedfs')
|
||||
self.assertFalse(result['plan']['requiresComposeAdoption'])
|
||||
self.assertEqual(result['plan']['storageBackupPaths'], ['storage'])
|
||||
|
||||
def test_incomplete_migration_cannot_be_adopted_or_resolved(self):
|
||||
state = self.migrated()
|
||||
for fields in ({'phase': 'released'}, {'writesReleased': False}, {'restoreVerifiedAt': None}):
|
||||
u.write_json(self.job / 'state.json', {**state, **fields})
|
||||
with self.assertRaisesRegex(u.UpdateError, 'vollständig'):
|
||||
self.updater.adopt_storage()
|
||||
self.assertTrue(self.updater.status()['recoveryRequired'])
|
||||
with self.assertRaisesRegex(u.UpdateError, 'adopt-storage'):
|
||||
self.updater.resolve('3.0.0')
|
||||
with self.assertRaisesRegex(u.UpdateError, 'adopt-storage'):
|
||||
self.run_update()
|
||||
|
||||
def test_adoption_rejects_modified_backup_missing_contract_and_changed_environment(self):
|
||||
self.migrated()
|
||||
for path in (self.job / 'backup/seaweedfs.tar', self.job / 'backend-acceptance/result.json', self.root / '.env'):
|
||||
content = path.read_bytes()
|
||||
path.write_text('{}')
|
||||
with self.assertRaises(u.UpdateError):
|
||||
self.updater.adopt_storage()
|
||||
self.assertTrue(self.updater.status()['recoveryRequired'])
|
||||
path.write_bytes(content)
|
||||
self.assertFalse((self.root / '.updates/storage-provider.json').exists())
|
||||
|
||||
def test_update_retains_storage_and_database_and_backs_up_all_storage(self):
|
||||
self.adopt()
|
||||
self.runner.real_tar = True
|
||||
result = self.updater.execute('apply', '3.1.0')
|
||||
self.assertEqual(result['status'], 'succeeded')
|
||||
self.assertFalse(self.runner.old_minio['State']['Running'])
|
||||
self.assertTrue(self.runner.storage['State']['Running'])
|
||||
self.assertFalse(any('minio' in image or 'seaweed' in image or u.IMAGE_ID.fullmatch(image) for image in self.runner.downloads))
|
||||
self.assertFalse(any('createbuckets' in a for a in self.runner.calls if 'run' in a))
|
||||
backup = Path(result['backup'])
|
||||
with tarfile.open(backup / 'files.tar') as archive:
|
||||
self.assertEqual(archive.extractfile('storage/seaweedfs/data').read(), b'complete volume data')
|
||||
self.assertIn('storage/config/s3.json', archive.getnames())
|
||||
self.assertNotIn('minio', archive.getnames())
|
||||
self.assertTrue((backup / 'storage-image.tar').is_file())
|
||||
self.assertTrue((backup / 'config/.updates/storage-provider.json').is_file())
|
||||
calls = self.runner.calls
|
||||
contract = next(i for i, a in enumerate(calls) if u.S3_UPGRADE_CHECK in a)
|
||||
self.assertLess(contract, next(i for i, a in enumerate(calls) if 'migrate' in a))
|
||||
# A later update also preserves the provider, with no new adoption required.
|
||||
(self.root / '.env').write_bytes(u.set_version((self.root / '.env').read_bytes(), '3.0.0'))
|
||||
target = u.read_json(self.root / 'docker-compose.yml')
|
||||
target['services']['backend']['environment']['FEDEO_VERSION'] = '3.0.0'
|
||||
u.write_json(self.root / 'docker-compose.yml', target)
|
||||
u.write_json(self.root / '.updates/installed.json', {'composeSha256': u.file_sha(self.root / 'docker-compose.yml')})
|
||||
self.assertEqual(self.updater.execute('apply', '3.1.0')['status'], 'succeeded')
|
||||
|
||||
def test_storage_drift_and_restarted_minio_block_before_maintenance(self):
|
||||
self.adopt()
|
||||
original = self.runner.old_minio['State'].copy()
|
||||
self.runner.old_minio['State']['Running'] = True
|
||||
with self.assertRaisesRegex(u.UpdateError, 'stillgelegt'):
|
||||
self.run_update()
|
||||
self.runner.old_minio['State'] = original
|
||||
(self.root / 'storage/config/s3.json').write_text('changed')
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Konfiguration'):
|
||||
self.run_update()
|
||||
self.assertFalse(self.called('stop'))
|
||||
|
||||
def test_storage_config_changed_during_pull_blocks_before_maintenance(self):
|
||||
self.adopt()
|
||||
self.runner.on_pull = lambda: (self.root / 'storage/config/s3.json').write_text('changed')
|
||||
with self.assertRaisesRegex(u.UpdateError, 'während'):
|
||||
self.run_update()
|
||||
self.assertFalse(self.called('stop'))
|
||||
|
||||
def test_backup_failure_restarts_seaweed_without_restarting_minio(self):
|
||||
self.adopt()
|
||||
self.runner.fail = lambda a: a[0] == 'tar' and '-cpf' in a
|
||||
with self.assertRaises(u.UpdateError):
|
||||
self.run_update()
|
||||
self.assertFalse(self.updater.status()['recoveryRequired'])
|
||||
start = next(a for a in self.runner.calls if 'start' in a)
|
||||
self.assertIn('objectstore', start)
|
||||
self.assertNotIn('minio', start)
|
||||
|
||||
def test_storage_must_remain_stopped_during_cold_backup(self):
|
||||
self.adopt()
|
||||
self.runner.on_stop = lambda: self.runner.storage['State'].update(Running=True)
|
||||
with self.assertRaisesRegex(u.UpdateError, 'kalten Sicherung'):
|
||||
self.run_update()
|
||||
self.assertFalse(self.called('migrate'))
|
||||
|
||||
def test_target_s3_failure_blocks_database_migrations_and_retains_recovery(self):
|
||||
self.adopt()
|
||||
self.runner.fail = lambda a: u.S3_UPGRADE_CHECK in a
|
||||
with self.assertRaises(u.UpdateError):
|
||||
self.run_update()
|
||||
self.assertTrue(self.updater.status()['recoveryRequired'])
|
||||
self.assertFalse(self.called('migrate'))
|
||||
self.assertTrue(self.updater.status()['storageProbeKey'].startswith('.fedeo-update-check/'))
|
||||
self.runner.fail = None
|
||||
self.assertEqual(self.updater.resolve('3.1.0')['status'], 'recovered')
|
||||
self.assertTrue(self.called(u.S3_CLEANUP_CHECK))
|
||||
self.assertIsNone(self.updater.status()['storageProbeKey'])
|
||||
|
||||
def test_symlink_and_changed_database_major_are_rejected(self):
|
||||
self.adopt()
|
||||
link = self.root / 'storage/seaweedfs/external'
|
||||
link.symlink_to(self.root / 'minio', target_is_directory=True)
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Links'):
|
||||
self.run_update()
|
||||
link.unlink()
|
||||
self.http.files['docker-compose.selfhost.yml'] = self.http.files['docker-compose.selfhost.yml'].replace(b'postgres:16', b'postgres:17')
|
||||
self.http.refresh()
|
||||
with self.assertRaisesRegex(u.UpdateError, 'PostgreSQL'):
|
||||
self.run_update()
|
||||
self.assertFalse(self.called('stop'))
|
||||
|
||||
def test_literal_dollars_survive_generated_compose(self):
|
||||
self.adopt()
|
||||
value = 'secret-$FOO-$$-with-dollar'
|
||||
config = u.compose_strings(u.read_json(self.root / 'docker-compose.yml'))
|
||||
config['services']['backend']['environment']['COOKIE_SECRET'] = value
|
||||
u.write_json(self.root / 'docker-compose.yml', u.compose_strings(config, escape=True))
|
||||
text = (self.root / '.env').read_text()
|
||||
import re
|
||||
(self.root / '.env').write_text(re.sub(r'(?m)^COOKIE_SECRET=.*$', "COOKIE_SECRET='" + value + "'", text))
|
||||
result = self.run_update()
|
||||
self.assertEqual(result['status'], 'succeeded')
|
||||
self.assertEqual(self.updater.candidate['services']['backend']['environment']['COOKIE_SECRET'], value)
|
||||
|
||||
def test_standard_installer_cannot_overwrite_a_planned_or_migrated_instance(self):
|
||||
import os
|
||||
original = (self.root / 'docker-compose.yml').read_bytes()
|
||||
(self.root / 'scripts').mkdir()
|
||||
setup = self.root / 'scripts/selfhost-setup.sh'
|
||||
setup.write_bytes((ROOT / 'scripts/selfhost-setup.sh').read_bytes())
|
||||
for relative in ('storage', '.updates/storage-provider.json', '.updates/storage-migration/state.json'):
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text('migration marker')
|
||||
result = subprocess.run(['bash', str(ROOT / 'scripts/selfhost-install.sh')], capture_output=True, text=True,
|
||||
env={**os.environ, 'FEDEO_DIR': str(self.root), 'FEDEO_TTY_INPUT': '/dev/null'})
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn('nicht überschreiben', result.stderr)
|
||||
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), original)
|
||||
for args in (['--force', '--no-start'], ['--uninstall', '--purge']):
|
||||
result = subprocess.run(['bash', str(setup), *args], capture_output=True, text=True,
|
||||
env={**os.environ, 'FEDEO_TTY_INPUT': '/dev/null'})
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn('nicht überschreiben oder entfernen', result.stderr)
|
||||
self.assertTrue((self.root / '.env').is_file())
|
||||
path.unlink()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -79,6 +79,32 @@ class Runner:
|
||||
self.matrix_containers = {}
|
||||
self.on_stop = None
|
||||
self.real_tar = False
|
||||
self.compose_configs = {}
|
||||
self.local_images = set()
|
||||
self.unavailable_images = set()
|
||||
self.downloads = []
|
||||
|
||||
@staticmethod
|
||||
def image_reference(image):
|
||||
# Docker resolves tag@digest using the repository and digest, not the tag.
|
||||
if '@' not in image:
|
||||
return image
|
||||
name, digest = image.split('@', 1)
|
||||
repository, slash, basename = name.rpartition('/')
|
||||
return repository + slash + basename.split(':', 1)[0] + '@' + digest
|
||||
|
||||
def pull_image(self, image, policy):
|
||||
reference = self.image_reference(image)
|
||||
if policy == 'never':
|
||||
if reference not in self.local_images:
|
||||
raise u.UpdateError('Image missing locally')
|
||||
return
|
||||
if policy == 'missing' and reference in self.local_images:
|
||||
return
|
||||
self.downloads.append(reference)
|
||||
if reference in self.unavailable_images:
|
||||
raise u.UpdateError('Registry unavailable for ' + reference)
|
||||
self.local_images.add(reference)
|
||||
|
||||
def run(self, args, output=None, input_file=None, **kwargs):
|
||||
self.calls.append(args)
|
||||
@@ -108,9 +134,12 @@ class Runner:
|
||||
capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise u.UpdateError('Compose invalid: ' + result.stderr)
|
||||
self.compose_configs[args[args.index('-f') + 1]] = json.loads(result.stdout)
|
||||
return result.stdout
|
||||
if 'ps' in args:
|
||||
if args[:2] == ['docker', 'ps']:
|
||||
if any(a.startswith('label=com.docker.compose.project=') for a in args):
|
||||
return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n' + '\n'.join(self.matrix_containers)
|
||||
return ''
|
||||
if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers:
|
||||
return args[-1]
|
||||
@@ -121,6 +150,15 @@ class Runner:
|
||||
container['State']['Running'] = 'start' in args
|
||||
if 'stop' in args and self.on_stop:
|
||||
self.on_stop()
|
||||
if args[:2] == ['docker', 'compose'] and any(command in args for command in ('pull', 'up', 'run')):
|
||||
services = self.compose_configs[args[args.index('-f') + 1]]['services']
|
||||
selected = [service for name, service in services.items() if name in args]
|
||||
if not selected:
|
||||
selected = list(services.values())
|
||||
for service in selected:
|
||||
policy = (args[args.index('--policy') + 1] if '--policy' in args else 'always') if 'pull' in args else (
|
||||
args[args.index('--pull') + 1] if '--pull' in args else service.get('pull_policy', 'missing'))
|
||||
self.pull_image(service['image'], policy)
|
||||
if 'pull' in args and self.on_pull:
|
||||
self.on_pull()
|
||||
if any('pg_database_size' in part for part in args):
|
||||
@@ -449,6 +487,26 @@ class UpdateTests(unittest.TestCase):
|
||||
self.run_update()
|
||||
self.assert_untouched()
|
||||
|
||||
def test_first_update_can_add_only_the_readonly_control_directory(self):
|
||||
control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n'
|
||||
self.assertIn(control_line, self.original_compose)
|
||||
(self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n'))
|
||||
result = self.run_update()
|
||||
self.assertEqual(result['status'], 'succeeded')
|
||||
self.assertIn(control_line, (self.root / 'docker-compose.yml').read_bytes())
|
||||
|
||||
def test_control_mount_bootstrap_rejects_write_access_or_another_host_directory(self):
|
||||
control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n'
|
||||
(self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n'))
|
||||
for replacement in (b' - /run/fedeo-updater:/run/fedeo-updater:rw\n',
|
||||
b' - /var/run/docker.sock:/run/fedeo-updater:ro\n'):
|
||||
with self.subTest(replacement=replacement):
|
||||
self.http.files['docker-compose.selfhost.yml'] = self.original_compose.replace(control_line, replacement)
|
||||
self.http.refresh()
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Datenpfade'):
|
||||
self.run_update()
|
||||
self.assertFalse(self.called('stop'))
|
||||
|
||||
def test_external_storage_fails_before_maintenance(self):
|
||||
(self.root / '.env').write_bytes(self.original_env.replace(b'http://minio:9000', b'https://storage.example.test'))
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Backup-Adapter'):
|
||||
@@ -473,6 +531,49 @@ class UpdateTests(unittest.TestCase):
|
||||
self.run_update()
|
||||
self.assert_untouched()
|
||||
|
||||
def test_cached_exact_images_work_with_unavailable_registries_and_forced_pull_overrides(self):
|
||||
self.updater.execute('check', '3.1.0')
|
||||
services = self.updater.candidate['services']
|
||||
refs = {name: self.runner.image_reference(service['image']) for name, service in services.items()}
|
||||
self.runner.local_images.update(refs.values())
|
||||
self.runner.unavailable_images.update(ref for ref in refs.values() if '@sha256:' in ref)
|
||||
(self.root / 'docker-compose.override.yml').write_text(
|
||||
'services:\n' + ''.join(f' {name}:\n pull_policy: always\n' for name in services))
|
||||
result = self.run_update()
|
||||
self.assertEqual(result['status'], 'succeeded')
|
||||
self.assertEqual(set(self.runner.downloads), {refs[name] for name in ('db', 'traefik', 'node-exporter')})
|
||||
self.assertEqual(len(self.runner.downloads), 3)
|
||||
|
||||
def test_missing_digest_is_downloaded_even_if_tag_or_another_digest_is_cached(self):
|
||||
self.updater.execute('check', '3.1.0')
|
||||
services = self.updater.candidate['services']
|
||||
for service in services.values():
|
||||
image = service['image']
|
||||
if '@' in image:
|
||||
self.runner.local_images.add(image.split('@')[0])
|
||||
self.runner.local_images.add(self.runner.image_reference(image).split('@')[0] + '@sha256:' + '0' * 64)
|
||||
self.assertEqual(self.run_update()['status'], 'succeeded')
|
||||
self.assertEqual(set(self.runner.downloads), {self.runner.image_reference(s['image']) for s in services.values()})
|
||||
|
||||
def test_missing_unavailable_digest_stops_before_maintenance_despite_cached_tag(self):
|
||||
self.updater.execute('check', '3.1.0')
|
||||
image = self.updater.candidate['services']['minio']['image']
|
||||
self.runner.local_images.add(image.split('@')[0])
|
||||
self.runner.unavailable_images.add(self.runner.image_reference(image))
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Registry unavailable'):
|
||||
self.run_update()
|
||||
self.assert_untouched()
|
||||
self.assertFalse(self.updater.status()['recoveryRequired'])
|
||||
|
||||
def test_cached_mutable_tag_still_requires_a_successful_registry_refresh(self):
|
||||
self.updater.execute('check', '3.1.0')
|
||||
services = self.updater.candidate['services']
|
||||
self.runner.local_images.update(self.runner.image_reference(s['image']) for s in services.values())
|
||||
self.runner.unavailable_images.add(services['db']['image'])
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Registry unavailable'):
|
||||
self.run_update()
|
||||
self.assert_untouched()
|
||||
|
||||
def test_configuration_changed_during_pull_is_detected(self):
|
||||
self.runner.on_pull = lambda: (self.root / '.env').write_bytes(self.original_env + b'\nNEW=value\n')
|
||||
with self.assertRaisesRegex(u.UpdateError, 'während'):
|
||||
|
||||
Reference in New Issue
Block a user