2 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 a7930c8721 chore(release): 0.2.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:53:07 +02:00
Florian WildeandClaude Opus 5.5 d06bbc6e53 fix(db): Migrationen ohne Fremdschlüsselprüfung und in einer Transaktion
Migration 0002 scheiterte auf einer Datenbank mit verknüpften Daten
(FOREIGN KEY constraint failed beim Neuaufbau von credentials) und
hinterließ einen halben Zustand, weil SQLite-DDL nicht transaktional lief.
Eigene Migrationsverbindung: foreign_keys=OFF, BEGIN IMMEDIATE,
transactional_ddl, foreign_key_check vor dem Commit. Tests mit echten,
verknüpften Daten und mit Abbruch mitten in der Migration.

update.sh: beim Zurückstufen keinen Changelog-Auszug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:51:15 +02:00
8 changed files with 231 additions and 6 deletions
+18 -1
View File
@@ -8,8 +8,24 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased] ## [Unreleased]
## [0.2.1] – 2026-10-11
### Behoben
- **Update von 0.1.x auf 0.2.0 scheiterte**: Migration 0002 brach auf einer
Datenbank mit Anbindungen ab (`FOREIGN KEY constraint failed` beim Neuaufbau der
Tabelle `credentials`). Weil SQLite-Migrationen nicht in einer Transaktion liefen,
blieb dabei ein halber Zustand zurück. Migrationen laufen jetzt ohne
Fremdschlüsselprüfung, aber mit Integritätsprüfung vor dem Commit und vollständig
in einer Transaktion: Ein Abbruch hinterlässt die Datenbank unverändert.
Geprüft mit Tests auf verknüpften Daten und an einer Kopie einer echten Datenbank.
- `netmover-update` zeigt beim Zurückstufen keinen irreführenden Changelog-Auszug mehr.
## [0.2.0] – 2026-10-11 ## [0.2.0] – 2026-10-11
> ⚠️ **Nicht verwenden** – das Update von 0.1.x scheitert an Migration 0002.
> Stattdessen 0.2.1 installieren. Wer 0.2.0 schon versucht hat: Die Datenbank aus der
> Sicherung von `netmover-update` zurückspielen und auf 0.2.1 aktualisieren.
### Hinzugefügt ### Hinzugefügt
- **Schlüsselverwaltung** (Seite „Schlüssel“): SSH-Schlüssel erzeugen (Ed25519) oder - **Schlüsselverwaltung** (Seite „Schlüssel“): SSH-Schlüssel erzeugen (Ed25519) oder
importieren (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH/PEM, auch mit Passphrase), importieren (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH/PEM, auch mit Passphrase),
@@ -149,7 +165,8 @@ Erste Version.
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.0...HEAD [Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...HEAD
[0.2.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.1...v0.2.0 [0.2.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1 [0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0 [0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
+31 -1
View File
@@ -14,10 +14,40 @@ Zeiten in Ortszeit (Europe/Berlin).
| 2026-10-10 | 19:18 | 19:34 | 0:16 | 5 | Weboberfläche und Anmeldung | | 2026-10-10 | 19:18 | 19:34 | 0:16 | 5 | Weboberfläche und Anmeldung |
| 2026-10-10 | 19:40 | 19:50 | 0:10 | 6 | Deployment, README, Release 0.1.0 | | 2026-10-10 | 19:40 | 19:50 | 0:10 | 6 | Deployment, README, Release 0.1.0 |
| 2026-10-10 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 | | 2026-10-10 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 |
| **Summe** | | | **1:35** | | | | 2026-10-10 | 22:45 | 23:00 | 0:15 | – | Echtes NAS/Paperless anbinden |
| 2026-10-10 | 23:31 | 23:58 | 0:27 | – | Schlüsselverwaltung, Update-Skript, 0.2.x |
| **Summe** | | | **2:17** | | |
--- ---
## 2026-10-10 · 23:31–23:58 · Schlüsselverwaltung, Update-Skript, Releases 0.2.0/0.2.1
**Erledigt**
- Schlüsselverwaltung (`keys.py`, Seite „Schlüssel“, Auswahl in SFTP-Anbindungen),
Migration 0002 (`credentials.managed`, `mounts.username`).
- `deploy/update.sh` → `netmover-update`: Tags holen, Sicherung (SQLite-Online-
Backup + Schlüssel + env), Checkout, `install.sh`, Healthcheck, Rückweg-Anleitung.
- VM auf Git-Klon `/opt/netmover/src` umgestellt (Repo ist öffentlich lesbar).
**Zwischenfall beim Update der VM auf 0.2.0**
- Migration 0002 scheiterte: `FOREIGN KEY constraint failed` bei `DROP TABLE
credentials` (Alembic-Batch baut die Tabelle neu; `foreign_keys=ON`, und die
Anbindungen verweisen darauf). Danach crashte jeder Neustart an der
übriggebliebenen `_alembic_tmp_credentials` (DDL lief ohne Transaktion).
- `netmover-update` hat den Fehler erkannt und die Rückweg-Befehle genannt.
Sicherung zurückgespielt, mit `netmover-update v0.1.1` zurückgestuft – in
~1 Minute wieder lauffähig, NAS-Mount blieb durchgehend eingehängt.
- Ursache, warum die Tests es nicht fanden: Migrationen wurden nur auf **leeren**
Datenbanken getestet. Neu: `tests/test_migrations.py` mit verknüpften Daten und
mit Abbruch mitten in der Migration (Datenbank muss unverändert bleiben).
- Fix: eigene Migrationsverbindung (FK aus, `BEGIN IMMEDIATE`, `transactional_ddl`,
`foreign_key_check` vor dem Commit). Vor dem Release an einer Kopie der echten
Datenbank auf der VM geprüft.
- Tag v0.2.0 bleibt (veröffentlicht), im Changelog als „nicht verwenden“ markiert.
**Lehre**
- Jede künftige Migration bekommt einen Test auf einer befüllten Datenbank.
## 2026-10-10 · 21:36–22:05 · Praxistest auf der Debian-13-VM, Release 0.1.1 ## 2026-10-10 · 21:36–22:05 · Praxistest auf der Debian-13-VM, Release 0.1.1
**Umgebung** **Umgebung**
+1 -1
View File
@@ -4,7 +4,7 @@
services: services:
netmover: netmover:
build: . build: .
image: netmover:0.2.0 image: netmover:0.2.1
container_name: netmover container_name: netmover
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
+2
View File
@@ -66,11 +66,13 @@ if [ "$NEWEST" = "$CURRENT" ] && [ "$CURRENT" != "$TARGET" ]; then
[ "$answer" = "j" ] || [ "$answer" = "J" ] || exit 1 [ "$answer" = "j" ] || [ "$answer" = "J" ] || exit 1
fi fi
if [ "$NEWEST" = "$TARGET" ]; then
echo echo
echo "==> Änderungen laut Changelog" echo "==> Änderungen laut Changelog"
git show "$TARGET:CHANGELOG.md" | awk -v cur="${CURRENT#v}" ' git show "$TARGET:CHANGELOG.md" | awk -v cur="${CURRENT#v}" '
/^## \[/ { if (index($0, "[" cur "]")) exit; show = ($0 !~ /Unreleased/) } /^## \[/ { if (index($0, "[" cur "]")) exit; show = ($0 !~ /Unreleased/) }
show' | head -n 60 show' | head -n 60
fi
echo echo
echo "==> Sicherung" echo "==> Sicherung"
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.2.0" __version__ = "0.2.1"
+29
View File
@@ -33,6 +33,35 @@ def make_engine(url: str) -> Engine:
return engine return engine
def make_migration_engine(url: str) -> Engine:
"""Verbindung nur für Migrationen.
* Fremdschlüssel AUS: SQLite baut Tabellen bei Änderungen neu auf (Alembic
„batch mode“) und löscht dabei die alte Tabelle. Mit eingeschalteter Prüfung
scheitert das, sobald andere Tabellen darauf verweisen (Praxisbefund beim
Update auf 0.2.0). Die Integrität prüft ``PRAGMA foreign_key_check`` danach.
* Echte Transaktion: pysqlite eigene Transaktionssteuerung abnehmen und selbst
``BEGIN`` senden. So wird eine abgebrochene Migration vollständig
zurückgerollt, statt einen halben Zustand zu hinterlassen.
"""
engine = create_engine(url, connect_args={"check_same_thread": False})
if url.startswith("sqlite"):
def on_connect(dbapi_conn: Any, _record: Any) -> None:
dbapi_conn.isolation_level = None
cur = dbapi_conn.cursor()
cur.execute("PRAGMA foreign_keys=OFF") # außerhalb einer Transaktion setzen
cur.execute("PRAGMA busy_timeout=5000")
cur.close()
def on_begin(conn: Any) -> None:
conn.exec_driver_sql("BEGIN IMMEDIATE")
event.listen(engine, "connect", on_connect)
event.listen(engine, "begin", on_begin)
return engine
def make_session_factory(engine: Engine) -> sessionmaker[Session]: def make_session_factory(engine: Engine) -> sessionmaker[Session]:
return sessionmaker(bind=engine, expire_on_commit=False) return sessionmaker(bind=engine, expire_on_commit=False)
+13 -2
View File
@@ -7,7 +7,7 @@ from logging.config import fileConfig
from alembic import context from alembic import context
from netmover.config import get_settings from netmover.config import get_settings
from netmover.db import make_engine from netmover.db import make_migration_engine
from netmover.models import Base from netmover.models import Base
config = context.config config = context.config
@@ -33,16 +33,27 @@ def run_migrations_offline() -> None:
def run_migrations_online() -> None: def run_migrations_online() -> None:
engine = make_engine(_url()) url = _url()
engine = make_migration_engine(url)
with engine.connect() as connection: with engine.connect() as connection:
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
render_as_batch=True, render_as_batch=True,
compare_type=True, compare_type=True,
# alles oder nichts: auch SQLite kann DDL in einer Transaktion
transactional_ddl=True,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
if url.startswith("sqlite"):
# Fremdschlüssel waren während der Migration aus – vor dem Commit prüfen.
bad = connection.exec_driver_sql("PRAGMA foreign_key_check").fetchall()
if bad:
raise RuntimeError(
f"Migration verletzt Fremdschlüssel ({len(bad)} Zeilen), "
f"zurückgerollt: {bad[:5]}"
)
engine.dispose() engine.dispose()
+136
View File
@@ -0,0 +1,136 @@
"""Migrationen auf Datenbanken MIT Daten.
Praxisbefund beim Update 0.1.1 → 0.2.0: Migration 0002 baut auf SQLite Tabellen neu
auf (batch mode) und löscht dafür die alte Tabelle. Mit eingeschalteter
Fremdschlüsselprüfung scheiterte das, sobald Anbindungen auf Zugangsdaten
verwiesen – die Tests liefen bis dahin nur auf leeren Datenbanken. Und weil die
Migration nicht in einer Transaktion lief, blieb ein halber Zustand zurück.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from alembic import command
from netmover.db import alembic_config, upgrade
def _seed(db: Path) -> None:
"""Datenbank auf Stand 0001 mit verknüpften Daten, wie auf einem echten System."""
c = sqlite3.connect(db)
c.execute("PRAGMA foreign_keys=ON")
c.executescript(
"""
INSERT INTO credentials (id, name, kind, username, secret_enc, created_at)
VALUES (1, 'mount:nas', 'password', 'scanner', 'gAAAA-x', '2026-10-10 20:00:00'),
(2, 'mount:paperless', 'ssh_key', 'root', 'gAAAA-y', '2026-10-10 20:00:00');
INSERT INTO mounts (id, name, slug, type, server, share, mountpoint, cred_id,
extra_options, auto_mount, enabled, state)
VALUES (1, 'NAS', 'nas', 'cifs', '10.0.10.6', 'Scans', '/mnt/netmover/nas', 1,
'', 1, 1, 'mounted'),
(2, 'paperless', 'paperless', 'sftp', '10.0.10.17', '/consume', NULL, 2,
'', 0, 1, 'mounted');
INSERT INTO rules (id, name, enabled, src_mount_id, src_subpath, dst_mount_id, dst_path,
pattern, pattern_type, recursive, interval_seconds, stability_seconds,
min_age_seconds, op_timeout_seconds, action, on_collision, dry_run,
quarantine_after, consecutive_failures, created_at)
VALUES (1, 'Scans → Paperless', 0, 1, '', 2, '', '*.pdf', 'glob', 0, 30, 5, 0, 120,
'move', 'rename', 0, 3, 0, '2026-10-10 20:00:00');
INSERT INTO runs (id, rule_id, started_at, status, "trigger", dry_run, seen, moved,
skipped, failed)
VALUES (1, 1, '2026-10-10 20:00:00', 'ok', 'manual', 1, 3, 3, 0, 0);
INSERT INTO run_items (run_id, src, status) VALUES (1, 'a.pdf', 'planned');
"""
)
c.commit()
c.close()
@pytest.fixture
def db_0001(tmp_path: Path) -> tuple[str, Path]:
db = tmp_path / "alt.db"
url = f"sqlite:///{db}"
upgrade(url, "0001")
_seed(db)
return url, db
def _state(db: Path) -> dict[str, object]:
c = sqlite3.connect(db)
try:
return {
"version": c.execute("SELECT version_num FROM alembic_version").fetchone()[0],
"tmp_tables": c.execute(
"SELECT name FROM sqlite_master WHERE name LIKE '_alembic_tmp%'"
).fetchall(),
"cred_columns": [r[1] for r in c.execute("PRAGMA table_info(credentials)")],
"mounts": c.execute("SELECT name, cred_id FROM mounts ORDER BY id").fetchall(),
"rules": c.execute("SELECT name, src_mount_id, dst_mount_id FROM rules").fetchall(),
"run_items": c.execute("SELECT COUNT(*) FROM run_items").fetchone()[0],
"fk_violations": c.execute("PRAGMA foreign_key_check").fetchall(),
}
finally:
c.close()
def test_upgrade_with_linked_data(db_0001: tuple[str, Path]) -> None:
url, db = db_0001
upgrade(url)
st = _state(db)
assert st["version"] == "0002"
assert st["tmp_tables"] == [] and st["fk_violations"] == []
assert "managed" in st["cred_columns"] # type: ignore[operator]
assert st["mounts"] == [("NAS", 1), ("paperless", 2)]
assert st["rules"] == [("Scans → Paperless", 1, 2)]
assert st["run_items"] == 1
c = sqlite3.connect(db)
assert c.execute("SELECT managed FROM credentials ORDER BY id").fetchall() == [(0,), (0,)]
assert c.execute("SELECT username FROM mounts ORDER BY id").fetchall() == [(None,), (None,)]
c.close()
def test_downgrade_with_linked_data(db_0001: tuple[str, Path]) -> None:
url, db = db_0001
upgrade(url)
command.downgrade(alembic_config(url), "0001")
st = _state(db)
assert st["version"] == "0001" and st["fk_violations"] == []
assert st["mounts"] == [("NAS", 1), ("paperless", 2)]
def test_failed_migration_leaves_database_untouched(
db_0001: tuple[str, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Bricht eine Migration mittendrin ab, muss alles beim Alten bleiben – keine
halb umgebauten Tabellen, kein _alembic_tmp-Rest, Version unverändert."""
from alembic.ddl.impl import DefaultImpl
url, db = db_0001
before = _state(db)
real_drop = DefaultImpl.drop_table
def boom(self: DefaultImpl, *a: object, **kw: object) -> None:
raise RuntimeError("Absturz mitten in der Migration")
monkeypatch.setattr(DefaultImpl, "drop_table", boom)
with pytest.raises(RuntimeError, match="Absturz"):
upgrade(url)
assert _state(db) == before
monkeypatch.setattr(DefaultImpl, "drop_table", real_drop)
upgrade(url) # danach klappt es ohne Handarbeit
assert _state(db)["version"] == "0002"
def test_app_engine_still_enforces_foreign_keys(db_url: str) -> None:
from sqlalchemy import text
from netmover.db import make_engine
engine = make_engine(db_url)
with engine.connect() as conn:
assert conn.execute(text("PRAGMA foreign_keys")).scalar() == 1
engine.dispose()