feat: Verwaltung von Anbindungen/Regeln und Export/Import
Formularprüfung und Speichern unabhängig vom Web-Framework; Passwörter leer = unverändert, SFTP-Schlüsselpaar wird erzeugt. Export/Import als JSON ohne Zugangsdaten und Webhook-URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f008c216be
commit
e2587551b0
@@ -0,0 +1,543 @@
|
||||
"""Verwaltung von Anbindungen und Regeln – Prüfen und Speichern von Formulardaten.
|
||||
|
||||
Unabhängig vom Web-Framework: nimmt ein ``Mapping[str, str]`` (Formular) und
|
||||
liefert entweder gespeicherte Objekte oder ein Fehler-Dict ``{feld: meldung}``.
|
||||
|
||||
Passwörter: leer lassen heißt „unverändert“; sie werden nie zurückgegeben.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from netmover.crypto import SecretBox, generate_ssh_keypair
|
||||
from netmover.models import (
|
||||
CollisionPolicy,
|
||||
Credential,
|
||||
CredentialKind,
|
||||
Mount,
|
||||
MountState,
|
||||
MountType,
|
||||
PatternType,
|
||||
Rule,
|
||||
RuleAction,
|
||||
)
|
||||
from netmover.mounting.manager import CredentialData, MountConfig, MountManager
|
||||
from netmover.validation import (
|
||||
MountOption,
|
||||
ValidationError,
|
||||
parse_extra_options,
|
||||
safe_abspath,
|
||||
safe_relpath,
|
||||
slugify,
|
||||
validate_id,
|
||||
validate_mode,
|
||||
validate_port,
|
||||
validate_secret,
|
||||
validate_server,
|
||||
validate_share,
|
||||
validate_username,
|
||||
)
|
||||
|
||||
Errors = dict[str, str]
|
||||
|
||||
MIN_INTERVAL = 10
|
||||
_OWNER_RE = re.compile(r"^\d{1,10}(:\d{1,10})?$")
|
||||
|
||||
|
||||
class AdminError(Exception):
|
||||
"""Aktion nicht möglich – Meldung für die Oberfläche."""
|
||||
|
||||
|
||||
def _bool(form: Mapping[str, str], key: str) -> bool:
|
||||
return form.get(key, "") in ("on", "true", "1", "yes")
|
||||
|
||||
|
||||
def _int(
|
||||
form: Mapping[str, str],
|
||||
key: str,
|
||||
errors: Errors,
|
||||
*,
|
||||
default: int | None = None,
|
||||
minimum: int | None = None,
|
||||
label: str = "",
|
||||
) -> int | None:
|
||||
raw = (form.get(key) or "").strip()
|
||||
if raw == "":
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
errors[key] = "Bitte eine ganze Zahl angeben."
|
||||
return default
|
||||
if minimum is not None and value < minimum:
|
||||
errors[key] = f"{label or 'Wert'} muss mindestens {minimum} sein."
|
||||
return value
|
||||
|
||||
|
||||
# -- Anbindungen ------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MountInput:
|
||||
name: str = ""
|
||||
type: MountType = MountType.CIFS
|
||||
server: str = ""
|
||||
share: str = ""
|
||||
port: int | None = None
|
||||
username: str = ""
|
||||
password: str = "" # leer = unverändert
|
||||
domain: str = ""
|
||||
auth: CredentialKind = CredentialKind.PASSWORD # nur sftp: Schlüssel oder Passwort
|
||||
uid: int | None = None
|
||||
gid: int | None = None
|
||||
file_mode: str | None = None
|
||||
dir_mode: str | None = None
|
||||
extra_options: str = ""
|
||||
auto_mount: bool = True
|
||||
enabled: bool = True
|
||||
|
||||
def config(self) -> MountConfig:
|
||||
return MountConfig(
|
||||
name=self.name or "neu",
|
||||
type=self.type,
|
||||
server=self.server,
|
||||
share=self.share,
|
||||
port=self.port,
|
||||
uid=self.uid,
|
||||
gid=self.gid,
|
||||
file_mode=self.file_mode,
|
||||
dir_mode=self.dir_mode,
|
||||
extra_options=self.extra_options,
|
||||
)
|
||||
|
||||
|
||||
def mount_input_from_model(m: Mount) -> MountInput:
|
||||
c = m.credential
|
||||
return MountInput(
|
||||
name=m.name,
|
||||
type=m.type,
|
||||
server=m.server,
|
||||
share=m.share,
|
||||
port=m.port,
|
||||
username=c.username if c else "",
|
||||
domain=(c.domain or "") if c else "",
|
||||
auth=c.kind if c else CredentialKind.PASSWORD,
|
||||
uid=m.uid,
|
||||
gid=m.gid,
|
||||
file_mode=m.file_mode,
|
||||
dir_mode=m.dir_mode,
|
||||
extra_options=m.extra_options,
|
||||
auto_mount=m.auto_mount,
|
||||
enabled=m.enabled,
|
||||
)
|
||||
|
||||
|
||||
def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]:
|
||||
errors: Errors = {}
|
||||
data = MountInput(
|
||||
name=(form.get("name") or "").strip(),
|
||||
server=(form.get("server") or "").strip(),
|
||||
share=(form.get("share") or "").strip(),
|
||||
username=(form.get("username") or "").strip(),
|
||||
password=form.get("password") or "",
|
||||
domain=(form.get("domain") or "").strip(),
|
||||
extra_options=(form.get("extra_options") or "").strip(),
|
||||
auto_mount=_bool(form, "auto_mount"),
|
||||
enabled=_bool(form, "enabled"),
|
||||
)
|
||||
try:
|
||||
data.type = MountType(form.get("type", "cifs"))
|
||||
except ValueError:
|
||||
errors["type"] = "Unbekannter Typ."
|
||||
return data, errors
|
||||
try:
|
||||
data.auth = CredentialKind(form.get("auth") or "password")
|
||||
except ValueError:
|
||||
errors["auth"] = "Unbekannte Anmeldeart."
|
||||
|
||||
if not data.name:
|
||||
errors["name"] = "Name fehlt."
|
||||
elif len(data.name) > 100:
|
||||
errors["name"] = "Höchstens 100 Zeichen."
|
||||
else:
|
||||
try:
|
||||
slugify(data.name)
|
||||
except ValidationError as exc:
|
||||
errors["name"] = str(exc)
|
||||
|
||||
def check[T](key: str, fn: Callable[[], T], fallback: T) -> T:
|
||||
try:
|
||||
return fn()
|
||||
except ValidationError as exc:
|
||||
errors[key] = str(exc)
|
||||
return fallback
|
||||
|
||||
data.server = check("server", lambda: validate_server(data.server), data.server)
|
||||
data.share = check("share", lambda: validate_share(data.type, data.share), data.share)
|
||||
no_options: list[MountOption] = []
|
||||
check("extra_options", lambda: parse_extra_options(data.type, data.extra_options), no_options)
|
||||
check("password", lambda: validate_secret(data.password), "")
|
||||
if data.type is MountType.CIFS:
|
||||
data.uid = check("uid", lambda: validate_id("uid", form.get("uid")), None)
|
||||
data.gid = check("gid", lambda: validate_id("gid", form.get("gid")), None)
|
||||
data.file_mode = check(
|
||||
"file_mode", lambda: validate_mode("file_mode", form.get("file_mode")), None
|
||||
)
|
||||
data.dir_mode = check(
|
||||
"dir_mode", lambda: validate_mode("dir_mode", form.get("dir_mode")), None
|
||||
)
|
||||
if data.type is MountType.SFTP:
|
||||
data.port = check("port", lambda: validate_port(form.get("port")), None)
|
||||
if data.username:
|
||||
data.username = check("username", lambda: validate_username(data.username), data.username)
|
||||
if data.domain:
|
||||
check("domain", lambda: validate_username(data.domain), "")
|
||||
if data.type is MountType.SFTP and not data.username:
|
||||
errors["username"] = "SFTP braucht einen Benutzernamen."
|
||||
if data.type is not MountType.SFTP:
|
||||
data.auth = CredentialKind.PASSWORD
|
||||
return data, errors
|
||||
|
||||
|
||||
def credential_for_test(
|
||||
data: MountInput, existing: Mount | None, secrets: SecretBox
|
||||
) -> CredentialData | None:
|
||||
"""Zugangsdaten für „Testen“ aus dem Formular – leeres Passwort = gespeichertes."""
|
||||
if data.type in (MountType.NFS3, MountType.NFS4) or not data.username:
|
||||
return None
|
||||
secret = data.password
|
||||
stored = existing.credential if existing is not None else None
|
||||
if not secret and stored is not None and stored.kind is data.auth:
|
||||
secret = secrets.decrypt(stored.secret_enc)
|
||||
if not secret:
|
||||
return None
|
||||
return CredentialData(data.auth, data.username, secret, data.domain or None)
|
||||
|
||||
|
||||
_CONNECTION_FIELDS = (
|
||||
"type",
|
||||
"server",
|
||||
"share",
|
||||
"port",
|
||||
"uid",
|
||||
"gid",
|
||||
"file_mode",
|
||||
"dir_mode",
|
||||
"extra_options",
|
||||
)
|
||||
|
||||
|
||||
def save_mount(
|
||||
s: Session,
|
||||
secrets: SecretBox,
|
||||
mounts: MountManager,
|
||||
data: MountInput,
|
||||
mount_id: int | None = None,
|
||||
) -> tuple[Mount | None, Errors]:
|
||||
errors: Errors = {}
|
||||
m = s.get(Mount, mount_id) if mount_id is not None else None
|
||||
if mount_id is not None and m is None:
|
||||
raise AdminError("Anbindung existiert nicht.")
|
||||
dup = s.scalar(select(Mount.id).where(Mount.name == data.name))
|
||||
if dup is not None and dup != mount_id:
|
||||
errors["name"] = "Name ist bereits vergeben."
|
||||
return None, errors
|
||||
|
||||
if m is None:
|
||||
base = slugify(data.name)
|
||||
slug, n = base, 1
|
||||
while s.scalar(select(Mount.id).where(Mount.slug == slug)) is not None:
|
||||
n += 1
|
||||
slug = f"{base[:44]}-{n}"
|
||||
m = Mount(slug=slug, state=MountState.UNMOUNTED, extra_options="")
|
||||
if data.type.is_kernel_mount:
|
||||
m.mountpoint = str(mounts.mountpoint_for(slug))
|
||||
s.add(m)
|
||||
else:
|
||||
changed = [f for f in _CONNECTION_FIELDS if getattr(m, f) != getattr(data, f)]
|
||||
if (
|
||||
changed
|
||||
and m.type.is_kernel_mount
|
||||
and m.state in (MountState.MOUNTED, MountState.DEGRADED)
|
||||
):
|
||||
errors["_"] = (
|
||||
"Verbindungsdaten lassen sich nur ändern, wenn die Anbindung ausgehängt ist."
|
||||
)
|
||||
return None, errors
|
||||
if m.type is MountType.SFTP and (m.server != data.server or m.port != data.port):
|
||||
m.host_key = None # anderer Server → Hostschlüssel neu bestätigen
|
||||
if data.type.is_kernel_mount and not m.mountpoint:
|
||||
m.mountpoint = str(mounts.mountpoint_for(m.slug))
|
||||
if not data.type.is_kernel_mount:
|
||||
m.mountpoint = None
|
||||
|
||||
for f in (
|
||||
"name",
|
||||
"type",
|
||||
"server",
|
||||
"share",
|
||||
"port",
|
||||
"uid",
|
||||
"gid",
|
||||
"file_mode",
|
||||
"dir_mode",
|
||||
"extra_options",
|
||||
"auto_mount",
|
||||
"enabled",
|
||||
):
|
||||
setattr(m, f, getattr(data, f))
|
||||
if not data.type.is_kernel_mount:
|
||||
m.auto_mount = False
|
||||
|
||||
# Zugangsdaten: jede Anbindung pflegt ihre eigenen.
|
||||
cred = m.credential
|
||||
needs_cred = data.type is MountType.SFTP or (
|
||||
data.type is MountType.CIFS and bool(data.username)
|
||||
)
|
||||
if not needs_cred:
|
||||
if cred is not None:
|
||||
m.credential = None
|
||||
s.flush()
|
||||
if not cred.mounts:
|
||||
s.delete(cred)
|
||||
else:
|
||||
kind_changed = cred is not None and cred.kind is not data.auth
|
||||
if cred is None or kind_changed:
|
||||
if data.auth is CredentialKind.SSH_KEY:
|
||||
private, public = generate_ssh_keypair(f"netmover@{m.slug}")
|
||||
secret, pub = private, public
|
||||
else:
|
||||
if not data.password:
|
||||
errors["password"] = "Passwort fehlt." # noqa: S105
|
||||
s.rollback()
|
||||
return None, errors
|
||||
secret, pub = data.password, None
|
||||
if cred is None:
|
||||
cred = Credential(name=f"mount:{m.slug}")
|
||||
s.add(cred)
|
||||
m.credential = cred
|
||||
cred.kind = data.auth
|
||||
cred.secret_enc = secrets.encrypt(secret)
|
||||
cred.public_key = pub
|
||||
elif data.password and data.auth is CredentialKind.PASSWORD:
|
||||
cred.secret_enc = secrets.encrypt(data.password)
|
||||
cred.username = data.username
|
||||
cred.domain = data.domain or None
|
||||
try:
|
||||
s.flush()
|
||||
except IntegrityError:
|
||||
s.rollback()
|
||||
errors["_"] = "Speichern fehlgeschlagen (Name oder Mountpoint doppelt)."
|
||||
return None, errors
|
||||
return m, errors
|
||||
|
||||
|
||||
def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None:
|
||||
m = s.get(Mount, mount_id)
|
||||
if m is None:
|
||||
return
|
||||
used = s.scalars(
|
||||
select(Rule.name).where((Rule.src_mount_id == mount_id) | (Rule.dst_mount_id == mount_id))
|
||||
).all()
|
||||
if used:
|
||||
raise AdminError(f"Wird noch von Regeln benutzt: {', '.join(used)}")
|
||||
if m.type.is_kernel_mount and m.state is not MountState.UNMOUNTED:
|
||||
mounts.unmount(mount_id)
|
||||
cred = m.credential
|
||||
s.delete(m)
|
||||
s.flush()
|
||||
if cred is not None and not cred.mounts:
|
||||
s.delete(cred)
|
||||
|
||||
|
||||
# -- Regeln ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleInput:
|
||||
name: str = ""
|
||||
enabled: bool = True
|
||||
src_mount_id: int | None = None
|
||||
src_subpath: str = ""
|
||||
dst_kind: str = "mount" # "mount" | "local"
|
||||
dst_mount_id: int | None = None
|
||||
dst_path: str = ""
|
||||
pattern: str = "*"
|
||||
pattern_type: PatternType = PatternType.GLOB
|
||||
recursive: bool = False
|
||||
interval_seconds: int = 30
|
||||
stability_seconds: int = 5
|
||||
min_age_seconds: int = 0
|
||||
max_size_mb: int | None = None
|
||||
op_timeout_seconds: int = 120
|
||||
action: RuleAction = RuleAction.MOVE
|
||||
on_collision: CollisionPolicy = CollisionPolicy.RENAME
|
||||
set_owner: str = ""
|
||||
set_mode: str = ""
|
||||
dry_run: bool = False
|
||||
quarantine_path: str = ""
|
||||
quarantine_after: int = 3
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def rule_input_from_model(r: Rule) -> RuleInput:
|
||||
return RuleInput(
|
||||
name=r.name,
|
||||
enabled=r.enabled,
|
||||
src_mount_id=r.src_mount_id,
|
||||
src_subpath=r.src_subpath,
|
||||
dst_kind="mount" if r.dst_mount_id else "local",
|
||||
dst_mount_id=r.dst_mount_id,
|
||||
dst_path=r.dst_path,
|
||||
pattern=r.pattern,
|
||||
pattern_type=r.pattern_type,
|
||||
recursive=r.recursive,
|
||||
interval_seconds=r.interval_seconds,
|
||||
stability_seconds=r.stability_seconds,
|
||||
min_age_seconds=r.min_age_seconds,
|
||||
max_size_mb=r.max_size_mb,
|
||||
op_timeout_seconds=r.op_timeout_seconds,
|
||||
action=r.action,
|
||||
on_collision=r.on_collision,
|
||||
set_owner=r.set_owner or "",
|
||||
set_mode=r.set_mode or "",
|
||||
dry_run=r.dry_run,
|
||||
quarantine_path=r.quarantine_path or "",
|
||||
quarantine_after=r.quarantine_after,
|
||||
)
|
||||
|
||||
|
||||
def parse_rule_form(s: Session, form: Mapping[str, str]) -> tuple[RuleInput, Errors]:
|
||||
errors: Errors = {}
|
||||
d = RuleInput(
|
||||
name=(form.get("name") or "").strip(),
|
||||
enabled=_bool(form, "enabled"),
|
||||
src_subpath=form.get("src_subpath") or "",
|
||||
dst_kind=form.get("dst_kind") or "mount",
|
||||
dst_path=(form.get("dst_path") or "").strip(),
|
||||
pattern=(form.get("pattern") or "").strip() or "*",
|
||||
recursive=_bool(form, "recursive"),
|
||||
set_owner=(form.get("set_owner") or "").strip(),
|
||||
set_mode=(form.get("set_mode") or "").strip(),
|
||||
dry_run=_bool(form, "dry_run"),
|
||||
quarantine_path=form.get("quarantine_path") or "",
|
||||
)
|
||||
if not d.name:
|
||||
errors["name"] = "Name fehlt."
|
||||
for key, enum_cls in (
|
||||
("pattern_type", PatternType),
|
||||
("action", RuleAction),
|
||||
("on_collision", CollisionPolicy),
|
||||
):
|
||||
try:
|
||||
setattr(d, key, enum_cls(form.get(key) or getattr(d, key).value))
|
||||
except ValueError:
|
||||
errors[key] = "Ungültiger Wert."
|
||||
d.src_mount_id = _int(form, "src_mount_id", errors)
|
||||
d.dst_mount_id = _int(form, "dst_mount_id", errors) if d.dst_kind == "mount" else None
|
||||
d.interval_seconds = (
|
||||
_int(form, "interval_seconds", errors, default=30, minimum=MIN_INTERVAL, label="Intervall")
|
||||
or 30
|
||||
)
|
||||
d.stability_seconds = _int(form, "stability_seconds", errors, default=5, minimum=0) or 0
|
||||
d.min_age_seconds = _int(form, "min_age_seconds", errors, default=0, minimum=0) or 0
|
||||
d.max_size_mb = _int(form, "max_size_mb", errors, minimum=1)
|
||||
d.op_timeout_seconds = (
|
||||
_int(form, "op_timeout_seconds", errors, default=120, minimum=10, label="Zeitlimit") or 120
|
||||
)
|
||||
d.quarantine_after = _int(form, "quarantine_after", errors, default=3, minimum=1) or 3
|
||||
|
||||
src = s.get(Mount, d.src_mount_id) if d.src_mount_id else None
|
||||
if src is None:
|
||||
errors["src_mount_id"] = "Quelle wählen."
|
||||
elif not src.type.is_kernel_mount:
|
||||
errors["src_mount_id"] = "SFTP-Anbindungen sind nur als Ziel möglich."
|
||||
try:
|
||||
d.src_subpath = safe_relpath("src_subpath", d.src_subpath)
|
||||
except ValidationError as exc:
|
||||
errors["src_subpath"] = str(exc)
|
||||
if d.dst_kind == "mount":
|
||||
if d.dst_mount_id is None or s.get(Mount, d.dst_mount_id) is None:
|
||||
errors["dst_mount_id"] = "Ziel wählen."
|
||||
try:
|
||||
d.dst_path = safe_relpath("dst_path", d.dst_path)
|
||||
except ValidationError as exc:
|
||||
errors["dst_path"] = str(exc)
|
||||
if (
|
||||
d.dst_mount_id == d.src_mount_id
|
||||
and "src_subpath" not in errors
|
||||
and d.dst_path == d.src_subpath
|
||||
):
|
||||
errors["dst_path"] = "Quelle und Ziel sind derselbe Ordner."
|
||||
elif d.dst_kind == "local":
|
||||
try:
|
||||
d.dst_path = safe_abspath("dst_path", d.dst_path)
|
||||
except ValidationError as exc:
|
||||
errors["dst_path"] = str(exc)
|
||||
else:
|
||||
errors["dst_kind"] = "Ungültige Zielart."
|
||||
|
||||
if d.pattern_type is PatternType.REGEX:
|
||||
try:
|
||||
re.compile(d.pattern)
|
||||
except re.error as exc:
|
||||
errors["pattern"] = f"Ungültiger regulärer Ausdruck: {exc}"
|
||||
if d.stability_seconds >= d.op_timeout_seconds:
|
||||
errors["stability_seconds"] = "Muss kleiner als das Zeitlimit sein."
|
||||
if d.set_owner and not _OWNER_RE.fullmatch(d.set_owner):
|
||||
errors["set_owner"] = "Als „uid:gid“ angeben, z. B. „1000:1000“."
|
||||
try:
|
||||
d.set_mode = validate_mode("set_mode", d.set_mode) or ""
|
||||
except ValidationError as exc:
|
||||
errors["set_mode"] = str(exc)
|
||||
if d.quarantine_path:
|
||||
try:
|
||||
d.quarantine_path = safe_relpath("quarantine_path", d.quarantine_path)
|
||||
except ValidationError as exc:
|
||||
errors["quarantine_path"] = str(exc)
|
||||
return d, errors
|
||||
|
||||
|
||||
def save_rule(s: Session, d: RuleInput, rule_id: int | None = None) -> tuple[Rule | None, Errors]:
|
||||
dup = s.scalar(select(Rule.id).where(Rule.name == d.name))
|
||||
if dup is not None and dup != rule_id:
|
||||
return None, {"name": "Name ist bereits vergeben."}
|
||||
r = s.get(Rule, rule_id) if rule_id is not None else None
|
||||
if rule_id is not None and r is None:
|
||||
raise AdminError("Regel existiert nicht.")
|
||||
if r is None:
|
||||
r = Rule()
|
||||
s.add(r)
|
||||
for f in (
|
||||
"name",
|
||||
"enabled",
|
||||
"src_mount_id",
|
||||
"src_subpath",
|
||||
"dst_mount_id",
|
||||
"dst_path",
|
||||
"pattern",
|
||||
"pattern_type",
|
||||
"recursive",
|
||||
"interval_seconds",
|
||||
"stability_seconds",
|
||||
"min_age_seconds",
|
||||
"max_size_mb",
|
||||
"op_timeout_seconds",
|
||||
"action",
|
||||
"on_collision",
|
||||
"dry_run",
|
||||
"quarantine_after",
|
||||
):
|
||||
setattr(r, f, getattr(d, f))
|
||||
r.set_owner = d.set_owner or None
|
||||
r.set_mode = d.set_mode or None
|
||||
r.quarantine_path = d.quarantine_path or None
|
||||
s.flush()
|
||||
return r, {}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Export/Import der Konfiguration als JSON – ohne Zugangsdaten.
|
||||
|
||||
Ausgenommen sind Passwörter, private Schlüssel, Benutzernamen und die
|
||||
Webhook-URL (enthält meist ein Token). Nach einem Import müssen Zugangsdaten
|
||||
neu eingegeben werden. Anbindungen und Regeln werden über ihren Namen
|
||||
zugeordnet; vorhandene Namen werden übersprungen, nie überschrieben.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from netmover import __version__
|
||||
from netmover.models import (
|
||||
CollisionPolicy,
|
||||
Mount,
|
||||
MountState,
|
||||
MountType,
|
||||
PatternType,
|
||||
Rule,
|
||||
RuleAction,
|
||||
)
|
||||
from netmover.mounting.manager import MountManager
|
||||
from netmover.settings_store import AppSettings, NotifyMode
|
||||
from netmover.validation import (
|
||||
ValidationError,
|
||||
parse_extra_options,
|
||||
safe_relpath,
|
||||
slugify,
|
||||
validate_server,
|
||||
validate_share,
|
||||
)
|
||||
|
||||
FORMAT_VERSION = 1
|
||||
|
||||
MOUNT_FIELDS = (
|
||||
"name",
|
||||
"type",
|
||||
"server",
|
||||
"share",
|
||||
"port",
|
||||
"host_key",
|
||||
"uid",
|
||||
"gid",
|
||||
"file_mode",
|
||||
"dir_mode",
|
||||
"extra_options",
|
||||
"auto_mount",
|
||||
"enabled",
|
||||
)
|
||||
RULE_FIELDS = (
|
||||
"name",
|
||||
"enabled",
|
||||
"src_subpath",
|
||||
"dst_path",
|
||||
"pattern",
|
||||
"pattern_type",
|
||||
"recursive",
|
||||
"interval_seconds",
|
||||
"stability_seconds",
|
||||
"min_age_seconds",
|
||||
"max_size_mb",
|
||||
"op_timeout_seconds",
|
||||
"action",
|
||||
"on_collision",
|
||||
"set_owner",
|
||||
"set_mode",
|
||||
"dry_run",
|
||||
"quarantine_path",
|
||||
"quarantine_after",
|
||||
)
|
||||
SETTING_FIELDS = (
|
||||
"retention_days",
|
||||
"notify_mode",
|
||||
"notify_after",
|
||||
"notify_recovery",
|
||||
"webhook_template",
|
||||
"webhook_content_type",
|
||||
)
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
return value.value if hasattr(value, "value") else value
|
||||
|
||||
|
||||
def export_config(s: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
|
||||
names = {m.id: m.name for m in mounts}
|
||||
return {
|
||||
"format": FORMAT_VERSION,
|
||||
"netmover": __version__,
|
||||
"mounts": [{f: _plain(getattr(m, f)) for f in MOUNT_FIELDS} for m in mounts],
|
||||
"rules": [
|
||||
{
|
||||
**{f: _plain(getattr(r, f)) for f in RULE_FIELDS},
|
||||
"src_mount": names[r.src_mount_id],
|
||||
"dst_mount": names.get(r.dst_mount_id) if r.dst_mount_id else None,
|
||||
}
|
||||
for r in s.scalars(select(Rule).order_by(Rule.name))
|
||||
],
|
||||
"settings": {f: _plain(getattr(settings, f)) for f in SETTING_FIELDS},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportReport:
|
||||
created: list[str] = field(default_factory=list)
|
||||
skipped: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
settings: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def import_config(s: Session, mounts: MountManager, data: dict[str, Any]) -> ImportReport:
|
||||
report = ImportReport()
|
||||
if not isinstance(data, dict) or data.get("format") != FORMAT_VERSION:
|
||||
report.errors.append("Unbekanntes Format – erwartet wird ein NetMover-Export (format 1).")
|
||||
return report
|
||||
|
||||
for raw in data.get("mounts") or []:
|
||||
name = str(raw.get("name", ""))
|
||||
if s.scalar(select(Mount.id).where(Mount.name == name)) is not None:
|
||||
report.skipped.append(f"Anbindung „{name}“ (existiert)")
|
||||
continue
|
||||
try:
|
||||
mtype = MountType(raw["type"])
|
||||
slug = slugify(name)
|
||||
if s.scalar(select(Mount.id).where(Mount.slug == slug)) is not None:
|
||||
raise ValidationError("name", "Slug bereits vergeben")
|
||||
m = Mount(
|
||||
name=name,
|
||||
slug=slug,
|
||||
type=mtype,
|
||||
server=validate_server(str(raw["server"])),
|
||||
share=validate_share(mtype, str(raw["share"])),
|
||||
port=raw.get("port"),
|
||||
host_key=raw.get("host_key"),
|
||||
uid=raw.get("uid"),
|
||||
gid=raw.get("gid"),
|
||||
file_mode=raw.get("file_mode"),
|
||||
dir_mode=raw.get("dir_mode"),
|
||||
extra_options=str(raw.get("extra_options") or ""),
|
||||
# ohne Zugangsdaten nicht automatisch einhängen
|
||||
auto_mount=bool(raw.get("auto_mount")) and mtype is not MountType.CIFS,
|
||||
enabled=bool(raw.get("enabled", True)),
|
||||
state=MountState.UNMOUNTED,
|
||||
mountpoint=str(mounts.mountpoint_for(slug)) if mtype.is_kernel_mount else None,
|
||||
)
|
||||
parse_extra_options(mtype, m.extra_options)
|
||||
s.add(m)
|
||||
s.flush()
|
||||
report.created.append(f"Anbindung „{name}“")
|
||||
except (KeyError, ValueError, ValidationError) as exc:
|
||||
report.errors.append(f"Anbindung „{name}“: {exc}")
|
||||
|
||||
ids = {name: mid for mid, name in s.execute(select(Mount.id, Mount.name)).all()}
|
||||
for raw in data.get("rules") or []:
|
||||
name = str(raw.get("name", ""))
|
||||
if s.scalar(select(Rule.id).where(Rule.name == name)) is not None:
|
||||
report.skipped.append(f"Regel „{name}“ (existiert)")
|
||||
continue
|
||||
try:
|
||||
src = ids[raw["src_mount"]]
|
||||
dst = ids[raw["dst_mount"]] if raw.get("dst_mount") else None
|
||||
r = Rule(src_mount_id=src, dst_mount_id=dst)
|
||||
for f in RULE_FIELDS:
|
||||
if f in raw:
|
||||
setattr(r, f, raw[f])
|
||||
r.pattern_type = PatternType(r.pattern_type)
|
||||
r.action = RuleAction(r.action)
|
||||
r.on_collision = CollisionPolicy(r.on_collision)
|
||||
r.src_subpath = safe_relpath("src_subpath", r.src_subpath or "")
|
||||
if int(r.interval_seconds) < 10:
|
||||
raise ValueError("Intervall < 10 s")
|
||||
r.enabled = False # erst nach Eingabe der Zugangsdaten aktivieren
|
||||
s.add(r)
|
||||
s.flush()
|
||||
report.created.append(f"Regel „{name}“ (deaktiviert)")
|
||||
except KeyError as exc:
|
||||
report.errors.append(f"Regel „{name}“: Anbindung {exc} fehlt")
|
||||
except (ValueError, ValidationError) as exc:
|
||||
report.errors.append(f"Regel „{name}“: {exc}")
|
||||
|
||||
if isinstance(data.get("settings"), dict):
|
||||
report.settings = {f: data["settings"][f] for f in SETTING_FIELDS if f in data["settings"]}
|
||||
return report
|
||||
|
||||
|
||||
def apply_imported_settings(current: AppSettings, values: dict[str, Any]) -> AppSettings:
|
||||
"""Nach dem Commit des Imports aufrufen (eigene Transaktion)."""
|
||||
for key, value in values.items():
|
||||
setattr(current, key, NotifyMode(value) if key == "notify_mode" else value)
|
||||
return current
|
||||
Reference in New Issue
Block a user