Add Snacks app: automated video library encoder with hardware acceleration (#6)
Co-authored-by: Joachim Friberg <joachim.friberg@ip-solutions.se> Reviewed-on: phirna/zima-apps#6
This commit is contained in:
@@ -7,6 +7,7 @@ from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from app.docker_api import DockerApiError, DockerUsageResolver
|
||||
from app.dns_sync import DnsProvider, DnsSyncError, to_fqdn
|
||||
from app.interfaces import list_host_interfaces
|
||||
from app.ip_commands import CommandError, IpAddressManager
|
||||
from app.models import EntryView, IpEntry
|
||||
@@ -50,12 +51,19 @@ class EntryService:
|
||||
usage_resolver: DockerUsageResolver,
|
||||
ip_manager: IpAddressManager,
|
||||
interface_provider: Callable[[], list[str]] = list_host_interfaces,
|
||||
dns_provider: DnsProvider | None = None,
|
||||
dns_base_domain: str = "",
|
||||
dns_ttl_seconds: int = 120,
|
||||
):
|
||||
self._storage = storage
|
||||
self._usage_resolver = usage_resolver
|
||||
self._ip_manager = ip_manager
|
||||
self._interface_provider = interface_provider
|
||||
self._dns_provider = dns_provider
|
||||
self._dns_base_domain = dns_base_domain
|
||||
self._dns_ttl_seconds = dns_ttl_seconds
|
||||
self._lock = threading.Lock()
|
||||
self._dns_errors_by_id: dict[str, str] = {}
|
||||
|
||||
def list_interfaces(self) -> list[str]:
|
||||
interfaces = self._interface_provider()
|
||||
@@ -89,6 +97,8 @@ class EntryService:
|
||||
used=used,
|
||||
containers=containers,
|
||||
usage_known=usage_known,
|
||||
dns_desired=bool(self._dns_provider) and usage_known and used and entry.enabled,
|
||||
dns_last_error=self._dns_errors_by_id.get(entry.id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -100,6 +110,7 @@ class EntryService:
|
||||
entries = self._storage.list_entries()
|
||||
self._assert_device_exists(parsed["device"])
|
||||
self._assert_unique_binding(entries, ip=parsed["ip"], cidr=parsed["cidr"], device=parsed["device"])
|
||||
self._assert_unique_name(entries, name=parsed["name"])
|
||||
|
||||
created = IpEntry(
|
||||
id=uuid4().hex,
|
||||
@@ -129,6 +140,7 @@ class EntryService:
|
||||
device=parsed["device"],
|
||||
ignore_entry_id=entry_id,
|
||||
)
|
||||
self._assert_unique_name(entries, name=parsed["name"], ignore_entry_id=entry_id)
|
||||
updated = IpEntry(
|
||||
id=current.id,
|
||||
name=parsed["name"],
|
||||
@@ -145,6 +157,7 @@ class EntryService:
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
index, entry = _find_entry(entries, entry_id)
|
||||
previous_enabled = entry.enabled
|
||||
|
||||
if enabled:
|
||||
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
|
||||
@@ -154,6 +167,12 @@ class EntryService:
|
||||
self._ip_manager.ensure_absent(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = False
|
||||
|
||||
try:
|
||||
self._sync_dns_for_entry_locked(entry, strict=True)
|
||||
except Exception: # noqa: BLE001
|
||||
self._rollback_enable_change(entry, previous_enabled)
|
||||
raise
|
||||
|
||||
entries[index] = entry
|
||||
self._storage.save_entries(entries)
|
||||
return entry
|
||||
@@ -166,8 +185,10 @@ class EntryService:
|
||||
raise ConflictError("Disable entry before deleting")
|
||||
|
||||
self._assert_not_used(entry)
|
||||
self._delete_dns_for_entry_locked(entry, strict=True)
|
||||
remaining = [candidate for candidate in entries if candidate.id != entry_id]
|
||||
self._storage.save_entries(remaining)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
|
||||
def reconcile_enabled_entries(self) -> list[str]:
|
||||
errors: list[str] = []
|
||||
@@ -186,6 +207,84 @@ class EntryService:
|
||||
self._storage.save_entries(entries)
|
||||
return errors
|
||||
|
||||
def reconcile_dns_records(self) -> list[str]:
|
||||
if not self._dns_provider:
|
||||
return []
|
||||
|
||||
errors: list[str] = []
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
usage_map, usage_known, usage_error = self._resolve_usage(entries)
|
||||
if not usage_known:
|
||||
msg = f"Docker usage check failed for DNS reconcile: {usage_error or 'unknown error'}"
|
||||
for entry in entries:
|
||||
self._dns_errors_by_id[entry.id] = msg
|
||||
return [msg]
|
||||
|
||||
for entry in entries:
|
||||
used = bool(usage_map.get(entry.ip, set()))
|
||||
desired = entry.enabled and used
|
||||
try:
|
||||
self._apply_dns_state_locked(entry, desired)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
except (DnsSyncError, DependencyError, ConflictError) as exc:
|
||||
self._dns_errors_by_id[entry.id] = str(exc)
|
||||
errors.append(f"{entry.name}: {exc}")
|
||||
return errors
|
||||
|
||||
def _rollback_enable_change(self, entry: IpEntry, previous_enabled: bool) -> None:
|
||||
try:
|
||||
if previous_enabled:
|
||||
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = True
|
||||
else:
|
||||
self._ip_manager.ensure_absent(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = False
|
||||
except CommandError:
|
||||
pass
|
||||
|
||||
def _sync_dns_for_entry_locked(self, entry: IpEntry, strict: bool) -> None:
|
||||
if not self._dns_provider:
|
||||
return
|
||||
|
||||
usage_map, usage_known, usage_error = self._resolve_usage([entry])
|
||||
if not usage_known:
|
||||
msg = f"Docker usage check failed: {usage_error or 'unknown error'}"
|
||||
self._dns_errors_by_id[entry.id] = msg
|
||||
if strict:
|
||||
raise DependencyError(msg)
|
||||
return
|
||||
|
||||
desired = entry.enabled and bool(usage_map.get(entry.ip, set()))
|
||||
self._apply_dns_state_locked(entry, desired)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
|
||||
def _delete_dns_for_entry_locked(self, entry: IpEntry, strict: bool) -> None:
|
||||
if not self._dns_provider:
|
||||
return
|
||||
|
||||
try:
|
||||
fqdn = to_fqdn(entry.name, self._dns_base_domain)
|
||||
self._dns_provider.delete_a_record(fqdn)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
except DnsSyncError as exc:
|
||||
self._dns_errors_by_id[entry.id] = str(exc)
|
||||
if strict:
|
||||
raise ConflictError(f"DNS delete failed for {entry.name}: {exc}") from exc
|
||||
|
||||
def _apply_dns_state_locked(self, entry: IpEntry, desired: bool) -> None:
|
||||
if not self._dns_provider:
|
||||
return
|
||||
|
||||
try:
|
||||
fqdn = to_fqdn(entry.name, self._dns_base_domain)
|
||||
if desired:
|
||||
self._dns_provider.upsert_a_record(fqdn, entry.ip, self._dns_ttl_seconds)
|
||||
else:
|
||||
self._dns_provider.delete_a_record(fqdn)
|
||||
except DnsSyncError as exc:
|
||||
raise ConflictError(f"DNS sync failed for {entry.name}: {exc}") from exc
|
||||
|
||||
def _assert_not_used(self, entry: IpEntry) -> None:
|
||||
try:
|
||||
usage = self._usage_resolver.resolve_ip_usage({entry.ip})
|
||||
@@ -223,6 +322,14 @@ class EntryService:
|
||||
if entry.ip == ip and entry.cidr == cidr and entry.device == device:
|
||||
raise ConflictError("Entry with same ip/cidr/device already exists")
|
||||
|
||||
def _assert_unique_name(self, entries: list[IpEntry], name: str, ignore_entry_id: str | None = None) -> None:
|
||||
target = name.strip().lower()
|
||||
for entry in entries:
|
||||
if ignore_entry_id and entry.id == ignore_entry_id:
|
||||
continue
|
||||
if entry.name.strip().lower() == target:
|
||||
raise ConflictError("Entry name must be unique")
|
||||
|
||||
def _assert_device_exists(self, device: str) -> None:
|
||||
interfaces = self.list_interfaces()
|
||||
if device not in interfaces:
|
||||
@@ -258,15 +365,15 @@ def _parse_payload(payload: dict) -> dict:
|
||||
if any(ch.isspace() for ch in device):
|
||||
raise ValidationError("Field 'device' cannot contain whitespace")
|
||||
|
||||
raw_cidr = payload.get("cidr")
|
||||
if raw_cidr is None:
|
||||
cidr_raw = payload.get("cidr")
|
||||
if cidr_raw is None:
|
||||
raise ValidationError("Field 'cidr' is required")
|
||||
try:
|
||||
cidr = int(raw_cidr)
|
||||
cidr = int(cidr_raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("Field 'cidr' must be an integer") from exc
|
||||
if cidr < 0 or cidr > 32:
|
||||
raise ValidationError("Field 'cidr' must be in range 0..32")
|
||||
raise ValidationError("Field 'cidr' must be between 0 and 32")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
|
||||
Reference in New Issue
Block a user