Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7930c8721 | ||
|
|
d06bbc6e53 |
+18
-1
@@ -8,8 +8,24 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
||||
|
||||
## [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
|
||||
|
||||
> ⚠️ **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
|
||||
- **Schlüsselverwaltung** (Seite „Schlüssel“): SSH-Schlüssel erzeugen (Ed25519) oder
|
||||
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
|
||||
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.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
|
||||
|
||||
@@ -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: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 |
|
||||
| **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
|
||||
|
||||
**Umgebung**
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
services:
|
||||
netmover:
|
||||
build: .
|
||||
image: netmover:0.2.0
|
||||
image: netmover:0.2.1
|
||||
container_name: netmover
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
||||
@@ -66,11 +66,13 @@ if [ "$NEWEST" = "$CURRENT" ] && [ "$CURRENT" != "$TARGET" ]; then
|
||||
[ "$answer" = "j" ] || [ "$answer" = "J" ] || exit 1
|
||||
fi
|
||||
|
||||
if [ "$NEWEST" = "$TARGET" ]; then
|
||||
echo
|
||||
echo "==> Änderungen laut Changelog"
|
||||
git show "$TARGET:CHANGELOG.md" | awk -v cur="${CURRENT#v}" '
|
||||
/^## \[/ { if (index($0, "[" cur "]")) exit; show = ($0 !~ /Unreleased/) }
|
||||
show' | head -n 60
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==> Sicherung"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
@@ -33,6 +33,35 @@ def make_engine(url: str) -> 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]:
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
|
||||
from netmover.config import get_settings
|
||||
from netmover.db import make_engine
|
||||
from netmover.db import make_migration_engine
|
||||
from netmover.models import Base
|
||||
|
||||
config = context.config
|
||||
@@ -33,16 +33,27 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
engine = make_engine(_url())
|
||||
url = _url()
|
||||
engine = make_migration_engine(url)
|
||||
with engine.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
compare_type=True,
|
||||
# alles oder nichts: auch SQLite kann DDL in einer Transaktion
|
||||
transactional_ddl=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user